【Flutter】go_router_builderでNavigationBar付きルートを構築する
はじめに
Flutter でアプリを開発する際、画面遷移を管理するルーティングは欠かせない要素です。
中でも go_router
は公式が推奨するルーティングパッケージとして広く使われています。
しかし、go_router
をそのまま使う場合、
- 値渡しが型安全でない
- ルートの定義や画面遷移がすべて手動で面倒
といった課題に直面しやすく、特に複雑なアプリでは管理が煩雑になりがちです。
このような課題を解決する手段として登場するのが go_router_builder
です。
型安全で保守性の高いルーティングを実現し、コードの自動生成によって開発効率も向上します。
さらに、NavigationBar
を使ったタブ型 UI の実装は、
ルーティング構成が複雑になりやすく、初心者がつまずきやすいポイントです。
そこで本記事では、go_router
と go_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)
サンプルプロジェクト
- タブでホーム画面と設定画面を行き来できる
- ホーム画面にはユーザーが一覧で表示されている
- ホーム画面で選択したユーザーの詳細情報を詳細画面で確認できる
- 設定画面でヘルプアイコンをタップすると、ヘルプ画面に遷移できる
- 不正なパスで遷移するボタンをタップするとエラーダイアログを出してホーム画面に遷移できる
ソースコード
使用パッケージ
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
補足
- 依存性注入
サンプルではriverpodを使って依存性の注入を行なっています。ここは他のパッケージまたは、コンストラクタ注入など任意のやり方で大丈夫です。
- ファイル分割
サンプルでは度々ファイルを純粋に分けるのではなくpart
とpart of
を使って分割しています。
詳しくは以下の記事で解説していますので、ご存知ではない方は一度ご覧ください。
アプリの画面構成とルート設計の概要
最初に作成する画面構成を整理しておきましょう。
闇雲に画面やルーティングを構築すると混乱してしまいます。
この章では、go_routerで NavigationBar
を使ったアプリのルーティング構成を4つのレイヤーに分けて解説します。
アプリの構成
NavigationBar
を使ったアプリケーションは以下の要素で構成されています。
- AppRoot
- StatefulShellRoute
- Branch
- ScreenRoute
ルート構成を図で確認してみましょう。
AppRoot
アプリの大元に位置する部分です。
すべてのルートはこのAppRootを起点として構成されます
StatefulShellRoute
NavigationBar
を使ったルーティングを束ねる部分です。
StatefulShellRoute
はgo_routerパッケージが提供するクラスで、次に説明するそれぞれの Branch
の状態を維持、管理するクラスです。
Stateful
という名の通り、ブランチを切り替えてもそれぞれのブランチの状態を保持することができます。
Branch
StatefulShellRouteに載せる分岐ルートです。
NavigationBar
の各タブに対応する画面群(ブランチ)を構成します
ScreenRoute
ブランチに入れる画面単位のrouteです。
画面を作成する
この章からは具体的な構築方法を解説していきます。
まずはルートの定義前に画面を作成します。
画面の作成順番は順不同で大丈夫です。
アプリの大元になる画面
アプリの大元になる画面は以下のように非常にシンプルです。
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に切り出して定義します。
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
を実装した画面が以下です。
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に該当するユーザー情報を表示する仕様にしています。
よってこの画面には引数が存在し、画面遷移の際には値渡しが発生します。
この点は後ほど解説します。
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
を継承したクラスを以下のように定義します。
// 各種ルートやブランチなどを定義した分割ファイル --->
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 navigator
を AppRootScreen
に渡します。
ナビゲーションバー上のrouteを束ねる:NavigationShellRouteの作成
ブランチとそのブランチ上の画面を管理するルートを StatefulShellRouteData
を継承したクラスで作成します。
part of '../route.dart';
/// ナビゲーションバーを含めた土台のシェルルート。
class NavigationShellRoute extends StatefulShellRouteData {
const NavigationShellRoute();
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return NavigationScreen(navigationShell: navigationShell);
}
}
ナビゲーションバー上の分岐:Branchの作成
それぞれのブランチを StatefulShellBranchData
を継承してクラスで定義します。
実装は以下のように非常に簡素です。
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
内ではこのルートで出す画面をそのまま返します。
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();
}
}
静的変数で path
と name
を定義しています。
path
は画面のルーティングを表すパスです。
path
と name
は後ほど使用するのですが、詳細は最後に解説します。
この path
の設定ルールが画面の条件によって変わってくる部分ですので、慎重に設定してください。
HomeScreen
はブランチ最初の画面でかつ、ブランチ間において最初に表示される画面です。また、値わたしはない画面でもあります。
そこで path
は一般的に最初のrouteにありがちな /
としました。
ここは任意で命名できます。( home
とかでも大丈夫)
ブランチの最初の画面だが、ブランチ間において2番目以降に表示される画面、値わたしはない画面の場合
SettingsBranch
の最初の画面にあたる SettingsRoute
のpathは必ずホーム画面を経由することから /
+ settings
として以下のようになります。
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
だけで良いです。
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
にも設定する必要があります。
設定の仕方は 画面パス
+ /
+ :
+ 変数名
というふうに設定します。
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>()
内に全体のルートを設定します。
// ...
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 {
// ...
}
配列でルートを設定しています。
ネストが深くてみづらいですが、段階を追ってみていくと以下のように読み解けます。
AppShellRoute
の route
には NavigationShellRoute
だけがが入っています。
⬇️
NavigationShellRoute
の route
には TypedStatefulShellBranch
として HomeBranch
と SettingsBranch
の二つが入っています。
⬇️
HomeBranch
の route
には HomeRoute
があり、そのネストした route
に DetailRoute
を設定しています。
⬇️
SettingsBranch
の route
には SettingsRoute
があり、そのネストした route
に HelpRoute
を設定しています。
最初にお見せした図を思い出すと構造がより理解しやすいと思います。
最後に自動生成コマンドを実行することでルート作成はようやく完了となります。
flutter pub run build_runner build --delete-conflicting-outputs
ルートをアプリに設定する
最後に今回作成したルートをアプリに適用します。
GoRouter を作成する
GoRouter
のインスタンスにルートを設定します。
インスタンスはriverpodのproviderとして定義します。
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
には自動生成してできたルートを渡しています。
part of 'route.dart';
// **************************************************************************
// GoRouterGenerator
// **************************************************************************
List<RouteBase> get $appRoutes => [
$appShellRoute,
];
go_router_builderとは関係ないですが、errorPageBuilder
はもしも間違ったパスで画面遷移を試みた場合に表示されるページを設定しておきます。
開発上ちゃんと設定すれば出てこないはずですが、例えばDeepLink経由などもしもの場合を想定して設定しておきましょう。
_ErrorPage
このページではダイアログを出して、ボタンを押したらホーム画面に戻るようにしています。
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(),
);
}
}
また、debugLogDiagnostics
を true
にしておくとログで現在設定されているルートや画面遷移した場合のフルパスが出力されます。
ここは任意で大丈夫です。
ログは以下のような形で出力されます。
[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.router
の routerConfig
に設定すればアプリに適用することができます。
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
を抜粋して載せていますが、push
や replace
も同じです。
引数なしの通常の遷移
// 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_router と go_router_builder を組み合わせて
NavigationBar
を採用したアプリのルーティングを「4 層構成」で組み立てる手順を解説しました。
- AppRoot → StatefulShellRoute → Branch → ScreenRoute という階層モデルで設計すると、画面追加や引数受け渡しが整理しやすくなります。
-
go_router_builder
を使えば、型安全・自動生成のおかげで
ルート定義や画面遷移がシンプルになり、メンテナンス性も向上します。 -
StatefulNavigationShell
でタブごとの履歴を保持できるため、複雑なBottomNavigationBar
実装もコード量を抑えて実現できます。
まずはサンプルプロジェクトをビルドして挙動を確認し、
自分のアプリに AppRoot と NavigationShell の骨組みを取り込んでみてください。
本記事が「タブ付きアプリのルーティング、何から手を付ければいいの?」という悩みを解消する一助となれば幸いです。
ぜひ実践で活用してみてください!
Discussion