🧭

【Flutter】go_router_builderでNavigationBar付きルートを構築する

に公開

はじめに

Flutter でアプリを開発する際、画面遷移を管理するルーティングは欠かせない要素です。
中でも go_router は公式が推奨するルーティングパッケージとして広く使われています。

https://pub.dev/packages/go_router

しかし、go_router をそのまま使う場合、

  • 値渡しが型安全でない
  • ルートの定義や画面遷移がすべて手動で面倒
    といった課題に直面しやすく、特に複雑なアプリでは管理が煩雑になりがちです。

このような課題を解決する手段として登場するのが go_router_builder です。
型安全で保守性の高いルーティングを実現し、コードの自動生成によって開発効率も向上します。

https://pub.dev/packages/go_router_builder

さらに、NavigationBar を使ったタブ型 UI の実装は、
ルーティング構成が複雑になりやすく、初心者がつまずきやすいポイントです。

そこで本記事では、go_routergo_router_builder を組み合わせて、

NavigationBar を使ったアプリのルーティング構成を 4 層モデルで整理・構築する方法を、

サンプルコード付きでわかりやすく解説していきます。

記事の対象者

  • Flutter でルーティングを扱う際に、画面遷移やパス定義の煩雑さを感じている方
  • go_router を使ってみたものの、ルート定義や値渡しでつまずいた経験がある方
  • NavigationBar を使ったタブ構成のルーティングを整理して実装したい方
  • go_router_builder の導入を検討しており、実際のプロジェクトでの使い方を学びたい方
  • 型安全でメンテナブルなルーティング構成を設計したいと考えている Flutter 開発者

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS
    15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android
    devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.100.0)

サンプルプロジェクト

  • タブでホーム画面と設定画面を行き来できる
  • ホーム画面にはユーザーが一覧で表示されている
  • ホーム画面で選択したユーザーの詳細情報を詳細画面で確認できる
  • 設定画面でヘルプアイコンをタップすると、ヘルプ画面に遷移できる
  • 不正なパスで遷移するボタンをタップするとエラーダイアログを出してホーム画面に遷移できる

https://youtu.be/qUgVneer2V0

ソースコード

https://github.com/HaruhikoMotokawa/go_router_builder_sample/tree/feature/set_up_route

使用パッケージ

dependencies:
  cupertino_icons: 1.0.2
  derry: 1.5.0
  flutter:
    sdk: flutter
  flutter_hooks: 0.21.2
  go_router: 15.2.0
  hooks_riverpod: 2.6.1
  riverpod_annotation: 2.6.1
  very_good_analysis: 8.0.0

dev_dependencies:
  build_runner: 2.4.15
  flutter_lints: 5.0.0
  flutter_test:
    sdk: flutter
  go_router_builder: 3.0.0
  riverpod_generator: 2.6.5
  riverpod_lint: 2.6.5

補足

  1. 依存性注入
    サンプルではriverpodを使って依存性の注入を行なっています。ここは他のパッケージまたは、コンストラクタ注入など任意のやり方で大丈夫です。

https://riverpod.dev/ja/

  1. ファイル分割
    サンプルでは度々ファイルを純粋に分けるのではなく partpart of を使って分割しています。
    詳しくは以下の記事で解説していますので、ご存知ではない方は一度ご覧ください。

https://zenn.dev/harx/articles/09d569d011bb4f

アプリの画面構成とルート設計の概要

最初に作成する画面構成を整理しておきましょう。
闇雲に画面やルーティングを構築すると混乱してしまいます。

この章では、go_routerで NavigationBar を使ったアプリのルーティング構成を4つのレイヤーに分けて解説します。

アプリの構成

NavigationBar を使ったアプリケーションは以下の要素で構成されています。

  1. AppRoot
  2. StatefulShellRoute
  3. Branch
  4. ScreenRoute

ルート構成を図で確認してみましょう。

AppRoot

アプリの大元に位置する部分です。
すべてのルートはこのAppRootを起点として構成されます

StatefulShellRoute

NavigationBar を使ったルーティングを束ねる部分です。
StatefulShellRoute はgo_routerパッケージが提供するクラスで、次に説明するそれぞれの Branch の状態を維持、管理するクラスです。
Statefulという名の通り、ブランチを切り替えてもそれぞれのブランチの状態を保持することができます。

Branch

StatefulShellRouteに載せる分岐ルートです。
NavigationBar の各タブに対応する画面群(ブランチ)を構成します

ScreenRoute

ブランチに入れる画面単位のrouteです。

画面を作成する

この章からは具体的な構築方法を解説していきます。
まずはルートの定義前に画面を作成します。
画面の作成順番は順不同で大丈夫です。

アプリの大元になる画面

アプリの大元になる画面は以下のように非常にシンプルです。

lib/presentation/screens/app_root/screen.dart
class AppRootScreen extends StatelessWidget {
  const AppRootScreen({
    required this.navigator,
    super.key,
  });

  final Widget navigator;

  
  Widget build(BuildContext context) {
    // この画面は常にポップできないようにする
    return PopScope(
      canPop: false,
      child: Scaffold(
        body: navigator,
      ),
    );
  }
}

誤ってこの画面上で context.pop を呼び出して画面が閉じてしまうことで画面が真っ暗になってしまわないように PopScope でラップしておきましょう。
あとはbodyには引数で Widget を受け取ります。
一般的にはwidgetの引数名は child としますが、今回は navigator としてみました。
理由は次の章で解説します。

StatefulShellRouteを管理するナビゲーション画面

まずはNavigationBar部分を作成します。
見通しが良くなるように別Widgetに切り出して定義します。

lib/presentation/screens/navigation/components/_app_navigation_bar.dart
part of '../screen.dart';

/// アプリのナビゲーションバー
class _AppNavigationBar extends StatelessWidget {
  const _AppNavigationBar({
    required this.navigationShell,
  });

  /// StatefulShellRouteの状態を管理するウィジェット
  ///
  /// go_routerの機能
  final StatefulNavigationShell navigationShell;

  
  Widget build(BuildContext context) {
    return NavigationBar(
      // StatefulNavigationShellが保持している現在のインデックスを割り当てる
      selectedIndex: navigationShell.currentIndex,
      destinations: const [
        NavigationDestination(
          icon: Icon(Icons.home),
          label: 'Home',
        ),
        NavigationDestination(
          icon: Icon(Icons.settings),
          label: 'Settings',
        ),
      ],
      onDestinationSelected: _select,
    );
  }

  /// タブをタップした際の処理
  ///
  /// 引数のインデックスに該当するブランチに移動し、
  void _select(int index) {
    // ナビゲーションシェルのページを切り替える
    navigationShell.goBranch(
      // 移動するブランチのインデックス
      index,
      // ブランチのルートページに戻すかどうか
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}

上記では最低限の実装ですが、各ブランチに該当するアイコンなどの設定のほかにナビゲーションバーのデザインなども設定できます。

特筆すべきは引数で受け取る StatefulNavigationShell です。
go_routerパッケージが提供する機能で、ブランチごとの状態を保持したり、ブランチの切り替えなどを行えるようにします。

これを引数で受け取ることで例えば _select 関数内で実装されているブランチの移動などを実装することができます。


上記の _AppNavigationBar を実装した画面が以下です。

lib/presentation/screens/navigation/screen.dart
part 'components/_app_navigation_bar.dart';

class NavigationScreen extends StatelessWidget {
  const NavigationScreen({
    required this.navigationShell,
    super.key,
  });

  final StatefulNavigationShell navigationShell;

  
  Widget build(BuildContext context) {
    // この画面をポップできないようにする
    return PopScope(
      canPop: false,
      child: Scaffold(
        body: navigationShell,
        bottomNavigationBar: _AppNavigationBar(
          navigationShell: navigationShell,
        ),
      ),
    );
  }
}

ここも AppRootScreen と同じように不用意に画面を pop してしまわないように PopScope を全体にラップさせます。
AppRootScreen との違いは引数に StatefulNavigationShell を受け取ることと、 bottomNavigationBar を設定している部分です。

ブランチで表示する画面達

ここでは画面ごとの詳細な実装は割愛しますが、以下のような構成になっています。

  • ホームブランチ -> ホーム画面 -> 詳細画面
  • 設定ブランチ -> 設定画面 -> ヘルプ画面

よって4つの画面を作成しておきます。

  • HomeScreen
  • DetailScreen
  • SettingsScreen
  • HelpScreen

詳細画面の実装だけは少しだけここで触れたいと思います。
詳細画面はユーザーのidを引数に取って、詳細画面ではそのidに該当するユーザー情報を表示する仕様にしています。
よってこの画面には引数が存在し、画面遷移の際には値渡しが発生します。
この点は後ほど解説します。

lib/presentation/screens/detail/screen.dart
class DetailScreen extends StatelessWidget {
  const DetailScreen({required this.userId, super.key});

  final int userId;
  
  // ...
}

ルートを定義する

ここからは上記で用意した画面達を使ってルーティングを組んでいきます。
ここが少し独特ですので一つ一つこなしていきましょう。

定義の仕方について

今回は route.dart に定義していきますが、原則一つのファイルに定義する必要があります。
全てを定義した後にbuild_runnerを実行してコードを自動生成するのですが、その際に生成されたコードを使用するため別ファイルに分けることができません。
そこで、このサンプルではpartを使って分割しています。

ここは好みや開発者の習熟度などにもよりますので、任意の方法で実装してみてください。

ちなみに、実装を分割したファイル構成は以下となります。

router
├── _route_data
│   ├── _branch_data.dart
│   ├── _detail_route.dart
│   ├── _help_route.dart
│   ├── _home_route.dart
│   ├── _navigation_shell_route.dart
│   └── _settings_route.dart
├── route.dart
└── route.g.dart

アプリの大元のroute:AppShellRouteの作成(下地)

まずは route.dart に大元のルートを定義しておきましょう。
ただ、まだこの段階ではコードの自動生成もできないので準備だけです。

ShellRouteData を継承したクラスを以下のように定義します。

lib/core/router/route/route.dart
// 各種ルートやブランチなどを定義した分割ファイル --->
part '_route_data/_branch_data.dart';
part '_route_data/_detail_route.dart';
part '_route_data/_help_route.dart';
part '_route_data/_home_route.dart';
part '_route_data/_navigation_shell_route.dart';
part '_route_data/_settings_route.dart';
// <------
part 'route.g.dart'; // 💡 最終的にこのファイルを対象にコードを自動生成する

/// アプリケーション全体のナビゲーションを管理するためのキー。
/// このキーを使うことで、アプリケーションのどこからでも
/// ナビゲーターに直接アクセスし、画面遷移を制御することができる。
final rootNavigationKey = GlobalKey<NavigatorState>();

/// アプリケーションの大元に位置するシェルルート
// このアノテーションはAppShellRouteにつける必要がある
<AppShellRoute>()
class AppShellRoute extends ShellRouteData {
  const AppShellRoute();

  static final GlobalKey<NavigatorState> $navigationKey = rootNavigationKey;

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

rootNavigationKey はグローバルに定義した GlobalKey<NavigatorState> です。
このキーはナビゲーションの制御をアプリ全体から行いたいときに使用します。

この変数を AppShellRoute クラス内の static final$navigationKey に代入していますが、
この $navigationKey という名前は go_router_builder のコード生成において特別な意味を持ちます。

具体的には、ShellRouteData を継承したクラスに static final $navigationKey を定義すると、
自動生成されるルーティングコード内でそのキーが ShellRoute.navigatorKey として適用されるようになります。

そのため、$ を付けるのは任意ではなく、go_router_builder がその名前を探して使うために必要な命名規則です。

最後に builder で返す画面を AppRootScreen として定義します。
ここで builder の引数で取れる Widget navigatorAppRootScreen に渡します。

ナビゲーションバー上のrouteを束ねる:NavigationShellRouteの作成

ブランチとそのブランチ上の画面を管理するルートを StatefulShellRouteData を継承したクラスで作成します。

lib/core/router/route/_route_data/_navigation_shell_route.dart
part of '../route.dart';

/// ナビゲーションバーを含めた土台のシェルルート。
class NavigationShellRoute extends StatefulShellRouteData {
  const NavigationShellRoute();

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

ナビゲーションバー上の分岐:Branchの作成

それぞれのブランチを StatefulShellBranchData を継承してクラスで定義します。
実装は以下のように非常に簡素です。

lib/core/router/route/_route_data/_branch_data.dart
part of '../route.dart';

// ナビゲーションバーに配置されるブランチ群を定義

class HomeBranch extends StatefulShellBranchData {
  const HomeBranch();
}

class SettingsBranch extends StatefulShellBranchData {
  const SettingsBranch();
}

画面単位のルート:Routeの作成

最後に画面単位で Route を作成していきます。
この時、その画面がどのような条件で表示される画面なのかによって多少実装内容が異なります。
具体的には以下の3つの条件です。

  • 画面がブランチの中で最初に表示される画面なのか
  • ブランチ同士で比較した場合に最初に表示されるか
  • 画面遷移の際に値渡しが発生するか

ブランチ最初の画面でかつ、ブランチ間において最初に表示される画面、値わたしはない画面

まずは全 Route 共通の実装です。

画面単位のルートを GoRouteData を継承したクラスで定義します。
また、with 句を使って _$HomeRoute をmixinとして差し込みます。
ただ、まだこの時点では自動生成していないのでエラーが出ますが、一旦ここでは無視しておきましょう。

build 内ではこのルートで出す画面をそのまま返します。

lib/core/router/route/_route_data/_home_route.dart
part of '../route.dart';

class HomeRoute extends GoRouteData with _$HomeRoute {
  const HomeRoute();

  static const String path = '/';
  static const String name = 'home_screen';

  
  Widget build(BuildContext context, GoRouterState state) {
    return const HomeScreen();
  }
}

静的変数で pathname を定義しています。
path は画面のルーティングを表すパスです。
pathname は後ほど使用するのですが、詳細は最後に解説します。

この path の設定ルールが画面の条件によって変わってくる部分ですので、慎重に設定してください。

HomeScreen はブランチ最初の画面でかつ、ブランチ間において最初に表示される画面です。また、値わたしはない画面でもあります。
そこで path は一般的に最初のrouteにありがちな / としました。
ここは任意で命名できます。( home とかでも大丈夫)


ブランチの最初の画面だが、ブランチ間において2番目以降に表示される画面、値わたしはない画面の場合

SettingsBranch の最初の画面にあたる SettingsRoute のpathは必ずホーム画面を経由することから / + settings として以下のようになります。

lib/core/router/route/_route_data/_settings_route.dart
part of '../route.dart';

class SettingsRoute extends GoRouteData with _$SettingsRoute {
  const SettingsRoute();

  static const String path = '/settings';
  static const String name = 'settings_screen';

  
  Widget build(BuildContext context, GoRouterState state) {
    return const SettingsScreen();
  }
}

ブランチの2番目以降の画面、値わたしはない画面の場合

ヘルプ画面は設定画面から遷移できる画面として設定したいのですが、ブランチの最初の画面には当たりません。
よって path はそのままその画面の path 単体の help だけで良いです。

lib/core/router/route/_route_data/_help_route.dart
part of '../route.dart';

class HelpRoute extends GoRouteData with _$HelpRoute {
  const HelpRoute();

  static const String path = 'help';
  static const String name = 'help_screen';

  
  Widget build(BuildContext context, GoRouterState state) {
    return const HelpScreen();
  }
}

ブランチの2番目以降の画面、値渡しを行う画面の場合

ホーム画面から遷移できる詳細画面の場合は、ホーム画面で選択したユーザーのidの受け渡しが発生します。
詳細画面では渡されたidに紐づけられたユーザー情報を表示したいからです。
その場合、引数の値を DetailRoute の引数に設定することに加えて、 path にも設定する必要があります。
設定の仕方は 画面パス + / + : + 変数名 というふうに設定します。

lib/core/router/route/_route_data/_detail_route.dart
part of '../route.dart';

class DetailRoute extends GoRouteData with _$DetailRoute {
  const DetailRoute({required this.userId});

  static const String path = 'detail/:userId';
  static const String name = 'detail_screen';

  final int userId;

  
  Widget build(BuildContext context, GoRouterState state) {
    return DetailScreen(userId: userId);
  }
}

アプリの大元のroute:AppShellRouteの作成(仕上げ)

ここまできたら最後に AppShellRoute につけたアノテーション、 @TypedShellRoute<AppShellRoute>() 内に全体のルートを設定します。

lib/core/router/route/route.dart
// ...

part 'route.g.dart';

<AppShellRoute>(
  routes: [
    TypedStatefulShellRoute<NavigationShellRoute>(
      branches: [
        TypedStatefulShellBranch<HomeBranch>(
          routes: [
            TypedGoRoute<HomeRoute>(
              path: HomeRoute.path,
              name: HomeRoute.name,
              routes: [
                TypedGoRoute<DetailRoute>(
                  path: DetailRoute.path,
                  name: DetailRoute.name,
                ),
              ],
            ),
          ],
        ), // <---------------------HomeBranch
        TypedStatefulShellBranch<SettingsBranch>(
          routes: [
            TypedGoRoute<SettingsRoute>(
              path: SettingsRoute.path,
              name: SettingsRoute.name,
              routes: [
                TypedGoRoute<HelpRoute>(
                  path: HelpRoute.path,
                  name: HelpRoute.name,
                ),
              ],
            ),
          ],
        ), // <---------------------SettingsBranch
      ],
    ), // <---------------------NavigationShellRoute
  ],
)
class AppShellRoute extends ShellRouteData {
  // ...
}

配列でルートを設定しています。
ネストが深くてみづらいですが、段階を追ってみていくと以下のように読み解けます。

AppShellRouteroute には NavigationShellRoute だけがが入っています。
⬇️
NavigationShellRouteroute には TypedStatefulShellBranch として HomeBranchSettingsBranch の二つが入っています。
⬇️
HomeBranchroute には HomeRoute があり、そのネストした routeDetailRoute を設定しています。
⬇️
SettingsBranchroute には SettingsRoute があり、そのネストした routeHelpRoute を設定しています。

最初にお見せした図を思い出すと構造がより理解しやすいと思います。

最後に自動生成コマンドを実行することでルート作成はようやく完了となります。

flutter pub run build_runner build --delete-conflicting-outputs

ルートをアプリに設定する

最後に今回作成したルートをアプリに適用します。

GoRouter を作成する

GoRouter のインスタンスにルートを設定します。
インスタンスはriverpodのproviderとして定義します。

lib/core/router/app_router/app_router.dart
part 'app_router.g.dart';
part 'components/_error_page.dart';


GoRouter appRouter(Ref ref) {
  return GoRouter(
    debugLogDiagnostics: true,
    initialLocation: const HomeRoute().location,
    routes: $appRoutes,
    errorPageBuilder: (context, state) => const MaterialPage(
      child: _ErrorPage(),
    ),
  );
}

initialLocation にはアプリを起動した場合の最初の画面を設定します。
今回はホーム画面を設定するのですが、パスではなく自動生成で設定されたgetterの location を渡します。

routes には自動生成してできたルートを渡しています。

lib/core/router/route/route.g.dart
part of 'route.dart';

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

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

go_router_builderとは関係ないですが、errorPageBuilder はもしも間違ったパスで画面遷移を試みた場合に表示されるページを設定しておきます。
開発上ちゃんと設定すれば出てこないはずですが、例えばDeepLink経由などもしもの場合を想定して設定しておきましょう。

_ErrorPage

このページではダイアログを出して、ボタンを押したらホーム画面に戻るようにしています。

lib/core/router/app_router/components/_error_page.dart
part of '../app_router.dart';

/// パスが見つからなかった場合のエラーページ
///
/// 例)DeepLink経由でのパスが見つからなかった場合など
class _ErrorPage extends HookWidget {
  const _ErrorPage();

  
  Widget build(BuildContext context) {
    useEffect(
      () {
        WidgetsBinding.instance.addPostFrameCallback((_) async {
          final isBackHome = await showDialog<bool>(
            context: context,
            builder: (context) {
              return AlertDialog(
                title: const Text('Error'),
                content: const Text('Page not found'),
                actions: [
                  TextButton(
                    onPressed: () => Navigator.of(context).pop(true),
                    child: const Text('OK'),
                  ),
                ],
              );
            },
          );
          if (isBackHome != null && isBackHome && context.mounted) {
            const HomeRoute().go(context);
          }
        });
        return null;
      },
      [],
    );
    return const Scaffold(
      body: SizedBox.shrink(),
    );
  }
}

また、debugLogDiagnosticstrue にしておくとログで現在設定されているルートや画面遷移した場合のフルパスが出力されます。
ここは任意で大丈夫です。

ログは以下のような形で出力されます。

[GoRouter] setting initial location /
[GoRouter] Full paths for routes:
           └─ (ShellRoute)
             └─ (ShellRoute)
               ├─/ (Widget)
               │ └─/detail/:userId (Widget)
               └─/settings (Widget)
                 └─/settings/help (Widget)
           known full paths for route names:
             home_screen => /
             detail_screen => /detail/:userId
             settings_screen => /settings
             help_screen => /settings/help
[GoRouter] Using MaterialApp configuration

アプリの routerConfig に設定する

あとはref経由で GoRouter のインスタンスを取得して、MaterialApp.routerrouterConfig に設定すればアプリに適用することができます。

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp.router(
      routerConfig: ref.read(appRouterProvider),
    );
  }
}

画面遷移の関数

go_router_builderを使った場合、画面遷移を実行する関数も少々違います。
遷移する場合に引数の有無によって2パターンありますが、基本的には型安全な形で呼び出すことができます。
以下では go を抜粋して載せていますが、pushreplace も同じです。

引数なしの通常の遷移

// go_routerの場合
context.go('/settings/help');

// go_router_builderを使った場合
const HelpRoute().go(context),

引数ありの値わたしが発生する遷移

final user = User(id: 1);

// go_routerの場合
context.go('/detail/${user.id}');

// go_router_builderを使った場合
DetailRoute(userId: user.id).go(context),

終わりに

この記事では go_routergo_router_builder を組み合わせて
NavigationBar を採用したアプリのルーティングを「4 層構成」で組み立てる手順を解説しました。

  • AppRoot → StatefulShellRoute → Branch → ScreenRoute という階層モデルで設計すると、画面追加や引数受け渡しが整理しやすくなります。
  • go_router_builder を使えば、型安全自動生成のおかげで
    ルート定義や画面遷移がシンプルになり、メンテナンス性も向上します。
  • StatefulNavigationShell でタブごとの履歴を保持できるため、複雑な BottomNavigationBar 実装もコード量を抑えて実現できます。

まずはサンプルプロジェクトをビルドして挙動を確認し、
自分のアプリに AppRootNavigationShell の骨組みを取り込んでみてください。

本記事が「タブ付きアプリのルーティング、何から手を付ければいいの?」という悩みを解消する一助となれば幸いです。

ぜひ実践で活用してみてください!

Discussion