🐉

【Flutter】go_router / go_router_builderとBottomNavigationBarの構築ガイド

2024/01/02に公開

はじめに

この記事は go_router/go_router_builder を使って BottomNavigationBar と連携する方法を段階的に試していった記事になります

go_router

go_router | Flutter Package

異なる画面間を移動するための便利な URL ベースの API を提供します。go_router を使用すると、Flutter の画面遷移やルーティング管理を非常にシンプルなコードで実装できます

go_router_builder

go_router_builder | Dart Package

Flutter の宣言型ルーティングパッケージである go_router 用のbuilderです。go_router_builder を使用すると、生成された強力に型指定されたルートヘルパーをサポートできます

環境構築や準備

各バージョンや環境

$ fvm --version
2.4.1

$ fvm flutter --version
Flutter 3.16.5 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 78666c8dc5 (5 days ago)2023-12-19 16:14:14 -0800
Engine • revision 3f3e560236
Tools • Dart 3.2.3 • DevTools 2.28.4

$ sw_vers
ProductName: macOS
ProductVersion: 13.4.1
ProductVersionExtra: (c)
BuildVersion: 22F770820d

1. プロジェクトの作成

今回は go_router_example というプロジェクト名で進めていきます。

mkdir go_router_example
cd go_router_example
fvm use 3.16.5 --force
fvm flutter create .

2. pubの追加

pubspec.yaml
dependencies:
  go_router: ^13.0.0

dev_dependencies:
  build_runner: ^2.4.7
  go_router_builder: ^2.4.0

上記を追加して fvm flutter pub get を実行します。

実装

1. Homeルートを実装

1-1. lib/hoge_page.dart の作成

事前に HomePage を作成しておきます。中身はHomePageが表示されている単純なものになります。

lib/hoge_page.dart
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text('Home Page'),
      ),
    );
  }
}

1-2. lib/router/router.dart の作成

以下の内容で router/router.dart を作成します。 またHomePage のimportも追加しときます。

lib/router/router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_example/hoge_page.dart';

part 'router.g.dart';

final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();

final router = GoRouter(
  routes: $appRoutes,
  initialLocation: HomeRoute.path,
  navigatorKey: rootNavigatorKey,
);

<HomeRoute>(
  path: HomeRoute.path,
)
class HomeRoute extends GoRouteData {
  const HomeRoute();

  static const path = '/home';

  
  Widget build(BuildContext context, GoRouterState state) => const HomePage();
}

以下コマンドを実施し router.g.dart を生成します。

fvm flutter pub run build_runner build --delete-conflicting-outputs
この時点で生成された `router.g.dart`
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'router.dart';

// **************************************************************************
// GoRouterGenerator
// **************************************************************************

List<RouteBase> get $appRoutes => [
      $homeRoute,
    ];

RouteBase get $homeRoute => GoRouteData.$route(
      path: '/home',
      factory: $HomeRouteExtension._fromState,
    );

extension $HomeRouteExtension on HomeRoute {
  static HomeRoute _fromState(GoRouterState state) => const HomeRoute();

  String get location => GoRouteData.$location(
        '/home',
      );

  void go(BuildContext context) => context.go(location);

  Future<T?> push<T>(BuildContext context) => context.push<T>(location);

  void pushReplacement(BuildContext context) =>
      context.pushReplacement(location);

  void replace(BuildContext context) => context.replace(location);
}

1-2. lib/app.dartlib/main.dart の作成

最後に app.dartmain.dart を以下内容で作成します。

  • app.dart

    lib/app.dart
    import 'package:flutter/material.dart';
    import 'package:go_router_example/router/router.dart';
    
    class App extends StatelessWidget {
      const App({super.key});
    
      
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Router Demo',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          routerConfig: router,
        );
      }
    }
    
  • main.dart

    lib/main.dart
    import 'package:flutter/material.dart';
    import 'package:go_router_example/app.dart';
    
    void main() {
      runApp(const App());
    }
    

実行してみて HomePage が表示されとけばOKです。

image1.png

2. BottomNavigationBarと連携 (状態が残らないパターン)

次に先ほど作成した HomePage と新たに作成する SettingsPage の2ページを切り替えれるBottomNavigationBarを持つページを作成してみたいと思います。

但し HomePage で操作して SettingsPage へ切り替えても HomePage操作した内容は残らずクリアされてしまうパターンとなります。後述するクリアされないパターンの比較としてこちらも試していきます。

BottomNavigationBarを持つページとして TopPage を作成する前提で router/router.dart を修正します。

2-1. lib/settings_page.dartlib/top_page.dart を作成

  • settings_page.dart

    lib/settings_page.dart
    import 'package:flutter/material.dart';
    
    class SettingsPage extends StatelessWidget {
      const SettingsPage({super.key});
    
      
      Widget build(BuildContext context) {
        return const Scaffold(
          body: Center(
            child: Text('Settings Page'),
          ),
        );
      }
    }
    
  • top_page.dart

    lib/top_page.dart
    import 'package:flutter/material.dart';
    import 'package:go_router_example/router/router.dart';
    
    enum PageIndex { home, settings }
    
    class TopPage extends StatefulWidget {
      const TopPage({required this.child, super.key});
    
      final Widget child;
    
      
      State<TopPage> createState() => _TopPageState();
    }
    
    class _TopPageState extends State<TopPage> {
      int _selectedIndex = 0;
    
      
      Widget build(BuildContext context) {
        return Scaffold(
          body: widget.child,
          bottomNavigationBar: _bottomNavigationBar(context),
        );
      }
    
      BottomNavigationBar _bottomNavigationBar(BuildContext context) {
        return BottomNavigationBar(
          currentIndex: _selectedIndex,
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: "Home",
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.settings),
              label: "Settings",
            ),
          ],
          onTap: (int index) {
            setState(() {
              _selectedIndex = index;
            });
            if (index == PageIndex.home.index) {
              const HomeRoute().go(context);
            } else if (index == PageIndex.settings.index) {
              const SettingsRoute().go(context);
            }
          },
        );
      }
    }
    

2-2. lib/router/router.dartSettingsPage 用の SettingsRoute 追加

lib/router/router.dart
<SettingsRoute>(
  path: SettingsRoute.path,
)
class SettingsRoute extends GoRouteData {
  const SettingsRoute();

  static const path = '/settings';

  
  Widget build(BuildContext context, GoRouterState state) => const SettingsPage();
}

2-3. lib/router/router.dartTopShellRoute を追加

  • TopPage 用のShellRouteDataになります
    • 子のrouteとして HomeRouteSettingsRoute を指定しています
    • TopShellRoute 用に NavigatorState を設定しています
      • $navigatorKey というstaticなクラス変数に設定します
lib/router/router.dart
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
// ↓新規に追加
final GlobalKey<NavigatorState> shellNavigatorKey = GlobalKey<NavigatorState>();

// ....

<TopShellRoute>(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<HomeRoute>(path: HomeRoute.path),
    TypedGoRoute<SettingsRoute>(path: SettingsRoute.path),
  ],
)
class TopShellRoute extends ShellRouteData {
  const TopShellRoute();

  static final GlobalKey<NavigatorState> $navigatorKey = shellNavigatorKey;

  
  Widget builder(
    BuildContext context,
    GoRouterState state,
    Widget navigator,
  ) {
    return TopPage(child: navigator);
  }
}
ここまでの `router.dart` 全体はこちら
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_example/hoge_page.dart';
import 'package:go_router_example/settings_page.dart';
import 'package:go_router_example/top_page.dart';

part 'router.g.dart';

final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> shellNavigatorKey = GlobalKey<NavigatorState>();

final router = GoRouter(
  routes: $appRoutes,
  initialLocation: HomeRoute.path,
  navigatorKey: rootNavigatorKey,
);

<TopShellRoute>(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<HomeRoute>(path: HomeRoute.path),
    TypedGoRoute<SettingsRoute>(path: SettingsRoute.path),
  ],
)
class TopShellRoute extends ShellRouteData {
  const TopShellRoute();

  static final GlobalKey<NavigatorState> $navigatorKey = shellNavigatorKey;

  
  Widget builder(
    BuildContext context,
    GoRouterState state,
    Widget navigator,
  ) {
    return TopPage(child: navigator);
  }
}

<HomeRoute>(
  path: HomeRoute.path,
)
class HomeRoute extends GoRouteData {
  const HomeRoute();

  static const path = '/home';

  
  Widget build(BuildContext context, GoRouterState state) => const HomePage();
}

<SettingsRoute>(
  path: SettingsRoute.path,
)
class SettingsRoute extends GoRouteData {
  const SettingsRoute();

  static const path = '/settings';

  
  Widget build(BuildContext context, GoRouterState state) =>
      const SettingsPage();
}

2-4. lib/home_page.dart を内部でカウンターのstateを持つように変更

  • hoge_page.dart

    lib/hoge_page.dart
    import 'package:flutter/material.dart';
    
    class HomePage extends StatefulWidget {
      const HomePage({super.key});
    
      
      State<HomePage> createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      int _counter = 0;
    
      void _incrementCounter() {
        setState(() {
          _counter++;
        });
      }
    
      
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const Text(
                  'You have pushed the button this many times:',
                ),
                Text(
                  '$_counter',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementCounter,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ),
        );
      }
    }
    

ここまできたら fvm flutter pub run build_runner build --delete-conflicting-outputs を実施し、 router.g.dart を生成しときます。

実行して以下の様に単純に Home PageSettings Page が切り替わる様になっとけばOKです。

ただ先に述べた通り、タブを切り替えると操作した内容(state)がクリアされるのでカウンターが0に戻っていると思います

image2.gif

3. BottomNavigationBarと連携 (状態が残るパターン)

3-1. 各Sub Route (HomeRoute, SettingsRoute) 用の StatefulShellBranch を作成

router.dart に以下を追加します。

lib/router/router.dart
// ↓NavigatorStateを新規追加
final GlobalKey<NavigatorState> homeNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> settingsNavigatorKey =
    GlobalKey<NavigatorState>();

// ...

class HomeShellBranchData extends StatefulShellBranchData {
  const HomeShellBranchData();
  static final GlobalKey<NavigatorState> $navigatorKey = homeNavigatorKey;
}

class SettingsShellBranchData extends StatefulShellBranchData {
  const SettingsShellBranchData();
  static final GlobalKey<NavigatorState> $navigatorKey = settingsNavigatorKey;
}

3-2. @TypedShellRoute を @TypedStatefulShellRoute に変更

lib/router/router.dart
<TopShellRoute>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<HomeShellBranchData>(
      routes: <TypedRoute<RouteData>>[
        TypedGoRoute<HomeRoute>(path: HomeRoute.path),
      ],
    ),
    TypedStatefulShellBranch<SettingsShellBranchData>(
      routes: <TypedRoute<RouteData>>[
        TypedGoRoute<SettingsRoute>(path: SettingsRoute.path),
      ],
    ),
  ],
)

この時点では TopShellRoute がエラーになっているかと思います。

3-3. TopShellRoute クラスが StatefulShellRouteData を継承する様に修正

lib/router/router.dart
class TopShellRoute extends StatefulShellRouteData {
  const TopShellRoute();

  static final GlobalKey<NavigatorState> $navigatorKey = shellNavigatorKey;

  
  Widget builder(
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigator,
  ) {
    return TopPage(child: navigator);
  }
}

3-4. TopPage が StatefulNavigationShell を受け取れる様に修正

lib/top_page.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

enum PageIndex { home, settings }

class TopPage extends StatefulWidget {
  const TopPage({required this.navigationShell, super.key});

  final StatefulNavigationShell navigationShell;

  
  State<TopPage> createState() => _TopPageState();
}

class _TopPageState extends State<TopPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: widget.navigationShell,
      bottomNavigationBar: _bottomNavigationBar(context),
    );
  }

  BottomNavigationBar _bottomNavigationBar(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: widget.navigationShell.currentIndex,
      items: const <BottomNavigationBarItem>[
        BottomNavigationBarItem(
          icon: Icon(Icons.home),
          label: "Home",
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.settings),
          label: "Settings",
        ),
      ],
      onTap: (int index) {
        widget.navigationShell.goBranch(
          index,
          initialLocation: index == widget.navigationShell.currentIndex,
        );
      },
    );
  }
}

先ほどまでの BottomNavigationBarcurrentIndex を制御する目的の _selectedIndexStatefulNavigationShellcurrentIndex に置き換わっています。

またタップ時の遷移では StatefulNavigationShell#goBranch を使用して遷移する様に変更してます。

ここまでできたら fvm flutter pub run build_runner build --delete-conflicting-outputs を実施し router.g.dart を生成しときます。

この時点の `router.dart`
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_example/hoge_page.dart';
import 'package:go_router_example/settings_page.dart';
import 'package:go_router_example/top_page.dart';

part 'router.g.dart';

final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> shellNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> homeNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> settingsNavigatorKey =
    GlobalKey<NavigatorState>();

final router = GoRouter(
  routes: $appRoutes,
  initialLocation: HomeRoute.path,
  navigatorKey: rootNavigatorKey,
);

<TopShellRoute>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<HomeShellBranchData>(
      routes: <TypedRoute<RouteData>>[
        TypedGoRoute<HomeRoute>(path: HomeRoute.path),
      ],
    ),
    TypedStatefulShellBranch<SettingsShellBranchData>(
      routes: <TypedRoute<RouteData>>[
        TypedGoRoute<SettingsRoute>(path: SettingsRoute.path),
      ],
    ),
  ],
)
class TopShellRoute extends StatefulShellRouteData {
  const TopShellRoute();

  static final GlobalKey<NavigatorState> $navigatorKey = shellNavigatorKey;

  
  Widget builder(
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigator,
  ) {
    return TopPage(navigationShell: navigator);
  }
}

class HomeShellBranchData extends StatefulShellBranchData {
  const HomeShellBranchData();
  static final GlobalKey<NavigatorState> $navigatorKey = homeNavigatorKey;
}

class SettingsShellBranchData extends StatefulShellBranchData {
  const SettingsShellBranchData();
  static final GlobalKey<NavigatorState> $navigatorKey = settingsNavigatorKey;
}

<HomeRoute>(
  path: HomeRoute.path,
)
class HomeRoute extends GoRouteData {
  const HomeRoute();

  static const path = '/home';

  
  Widget build(BuildContext context, GoRouterState state) => const HomePage();
}

<SettingsRoute>(
  path: SettingsRoute.path,
)
class SettingsRoute extends GoRouteData {
  const SettingsRoute();

  static const path = '/settings';

  
  Widget build(BuildContext context, GoRouterState state) =>
      const SettingsPage();
}

実行してみると、今回はタブを切り替えても操作した内容(state)が残っており、カウンターの値がそのままになっているのが分かると思います

image3.gif

4. fullscreenのダイアログを表示 (おまけ)

BottomNavigationBar内のページ上でボタンを押すとfullscreenのダイアログが表示されるパターンを実装して見たいと思います。設定画面(SettingsPage)上でカラーピッカーダイアログを表示する様なケースを想定して進めていきます。

4-1. ダイアログ用の画面を追加

lib/color_picker_dialog.dart を以下内容で作成します。

lib/color_picker_dialog.dart
import 'package:flutter/material.dart';

class ColorPickerDialog extends StatelessWidget {
  const ColorPickerDialog({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ColorPickerDialog')),
      body: Container(
        color: Colors.deepPurple.shade300,
        child: const Center(
          child: Text('ColorPickerDialog'),
        ),
      ),
    );
  }
}

4-2. router.dart 修正

以下を追加します。

lib/router/router.dart
<ColorPickerDialogRoute>(
  path: ColorPickerDialogRoute.path,
)
class ColorPickerDialogRoute extends GoRouteData {
  const ColorPickerDialogRoute();

  static const path = '/color_picker_dialog';

  
  Page<void> buildPage(BuildContext context, GoRouterState state) =>
      MaterialPage<Object>(
        fullscreenDialog: true,
        key: state.pageKey,
        child: const ColorPickerDialog(),
      );
}

4-3. settings_page.dart 修正

lib/settings_page.dart
import 'package:flutter/material.dart';
import 'package:go_router_example/router/router.dart';

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
            onPressed: () => const ColorPickerDialogRoute().push(context),
            child: const Text('Show Color Picker Dialog')),
      ),
    );
  }
}

ここまでできたら fvm flutter pub run build_runner build --delete-conflicting-outputs を実施し router.g.dart を生成し実行してみます。

↓ダイアログが表示されていればOKです。

image4.gif

リポジトリ

https://github.com/Slowhand0309/go_router_example

今回試した分は↑こちらに公開してます。

この記事は以下の情報を参考にして執筆しました

Discussion