画面はスタックで積まれる
Navigator のスタックという土台から、go_router によるパスでの宣言、認証ガードとしての redirect、タブごとに履歴を保つ StatefulShellRoute まで。画面と画面のあいだをどう設計するかを整理する。
ここまでは一つの画面を作る話でした。今回は視点を上げて、画面と画面のあいだを扱います。
連載 I の初回で、アプリの root に MaterialApp.router を置きました。あのとき「ルーティングの設定を
渡している」とだけ触れて先へ進んだ部分が、今回の主題です。土台にある Navigator のスタックから
始めて、go_router がなぜ必要になるのかを順に見ていきます。
§ 01STACKNavigator は画面のスタック
Flutter のナビゲーションの土台は Navigator——画面を積み重ねるスタックです。新しい画面へ進むのは
push、戻るのは pop。端末の戻るボタンやスワイプも、結局は pop です。
Navigator.push(context,MaterialPageRoute(builder: (context) => ItemDetailScreen(item: item)),);
Navigator は連載 I で見た Theme などと同じく、ツリーの祖先にいます。Navigator.of(context) が
context の位置から上へさかのぼって最も近い Navigator を見つける——だから push には context が
要るわけです。
そして前回見たとおり、pop は値を返せます。ダイアログも「スタックに積まれた一時的な層」なので、
同じ仕組みで結果を受け取っていました。この一貫性は覚えておく価値があります。
§ 02LIMIT命令的な push の限界
push と pop だけでアプリが作れるなら、話は簡単でした。しかし規模が大きくなると、次の要求が
出てきます。
- 通知や URL から特定の画面を直接開きたい(deep link)
- 未ログインなら、どこへ進もうとしてもログイン画面へ送りたい(ガード)
- Web でブラウザの戻るボタンと URL を正しく扱いたい
push は「いま、ここから、あの画面へ進め」という命令です。命令だけでこれらを満たそうとすると、
アプリ中に散らばったすべての push の前に認証チェックを書くことになり、一箇所でも書き忘れれば穴に
なります。deep link に至っては、目的の画面に至るスタックを自分で組み立て直す必要が出ます。
ここで「パスを書けば済むのでは」と思うかもしれません。実際、従来の MaterialApp にも
routes: {'/detail': ...} という名前付きルートがあり、Navigator.pushNamed(context, '/detail')
で遷移できます。
ただし、そのパスは行き先に付けた名前にすぎません。Map のキーと同じで、パスからスタックが
導かれるわけではない——積まれている画面は、あくまで push の履歴です。/items/:id のような
パラメータも静的な Map では表現できず、結局 onGenerateRoute で文字列を自分で解析することに
なります。簡易なルーターを手書きしているのと変わりません。
必要なのは、パスを書くことではなく、パスが画面の重なりを決めることでした。
そこで発想を変えます。「どう進むか」ではなく「どの状態がどのパスに対応するか」を宣言する。
/items/42 というパスに対して、表示すべき画面が決まっている——という形にすれば、その入口が
push でも通知でも URL でも同じ結果になります。これが宣言的ルーティングで、Flutter では
go_router がその標準的な選択肢です1Navigator.pushNamed と MaterialApp の routes: を使う旧来の名前付きルートもある。パスパラメータやガード、ネストしたナビゲーションを扱いにくいため、現在は go_router などの宣言的ルーターを使うのが一般的。。
§ 03GOROUTERパスで画面を宣言する
go_router では、パスと画面の対応を一箇所に並べます。
final router = GoRouter(initialLocation: '/',routes: [GoRoute(path: '/', builder: (context, state) => const HomeScreen()),GoRoute(path: '/items/:id',builder: (context, state) => ItemDetailScreen(id: state.pathParameters['id']!,),),GoRoute(path: '/sign-in', builder: (context, state) => const SignInScreen()),],);
これを MaterialApp.router に渡せば、ルーティングはこの定義が唯一の正になります。
MaterialApp.router(routerConfig: router)
:id の部分はパスパラメータで、state.pathParameters['id'] で受け取ります。画面がオブジェクトでは
なく文字列のパスで表現できるようになったこと——これが後の deep link とガードを可能にします。
移動には 2 つの入口があります。
context.go('/items/42')… その場所へ移動する。スタックはパスに対応する形へ置き換わるcontext.push('/items/42')… 現在の画面の上に積む
「詳細を開いて、戻ったら一覧に帰りたい」なら push、「タブを切り替える」「ログイン後にホームへ
移る」のように現在地そのものを変えるなら go です2Web ではこの違いがブラウザの履歴に直接現れる。go は現在地の置き換え、push は履歴を一つ増やす操作に対応する。。
§ 04REDIRECT入れない画面を一箇所で止める
宣言的にした最大の見返りが、この redirect です。すべての遷移が通る一点で、行き先を書き換え
られます。
GoRouter(redirect: (context, state) {final signedIn = authService.isSignedIn;final goingToSignIn = state.matchedLocation == '/sign-in';if (!signedIn && !goingToSignIn) return '/sign-in'; // 未ログインは追い返すif (signedIn && goingToSignIn) return '/'; // ログイン済みなら戻すreturn null; // そのまま通す},routes: [...],);
null を返せば「書き換えなし」、パスを返せばそこへ送られます。認証チェックがアプリ中に散らばら
ないのがポイントです。新しい画面を足しても、ガードは自動的に効きます。
注意点が 2 つあります。一つは無限ループ。上の例で goingToSignIn を見ずに常に /sign-in を返すと、
ログイン画面へ行こうとするたびにまたログイン画面へ送られ、リダイレクトが止まりません3go_router にはリダイレクト回数の上限(既定 5 回)があり、超えると例外になる。無限ループは黙って固まるのではなく、エラーとして表面化する。。
「送り先にいるときは null を返す」を必ず入れます。
もう一つは、redirect は遷移のときにしか走らないことです。画面を開いたままログアウトした場合、
誰も遷移していないので判定は動きません。認証状態の変化でルーターに再評価させるには、
refreshListenable にその変化を通知するものを渡します4refreshListenable に Listenable を渡すと、通知のたびに redirect が再評価される。ストリームから作る場合は GoRouterRefreshStream のような薄いアダプタを挟む。。
§ 05SHELLタブごとに履歴を持つ
ボトムナビゲーションのあるアプリでは、もう一段の要求があります。タブを切り替えても、各タブの 履歴が残っていてほしい——在庫タブで詳細画面まで進み、レシピタブを見て戻ってきたら、さっきの詳細が そのままであってほしい、という期待です。
これは「スタックが一本」では実現できません。タブの数だけスタックが要ります。go_router では
StatefulShellRoute がこれを引き受けます。
StatefulShellRoute.indexedStack(builder: (context, state, navigationShell) =>MainScaffold(navigationShell: navigationShell),branches: [StatefulShellBranch(routes: [GoRoute(path: '/stock', builder: (context, state) => const StockScreen()),]),StatefulShellBranch(routes: [GoRoute(path: '/recipes', builder: (context, state) => const RecipeScreen()),]),],)
branches の一つひとつが独立した Navigator——つまり独立したスタックです。builder に渡ってくる
navigationShell が現在の枝を保持していて、共通の外枠(MainScaffold)はここに置きます。
タブの切り替えは go ではなく goBranch を使います。
BottomNavigationBar(currentIndex: navigationShell.currentIndex,onTap: (index) => navigationShell.goBranch(index,// 同じタブをもう一度押したら、そのタブの先頭へ戻るinitialLocation: index == navigationShell.currentIndex,),)
各枝は IndexedStack で保持されるため、切り替えても状態が保たれます。裏を返せば全タブが生き続ける
ということでもあります5StatefulShellRoute.indexedStack は全ブランチの Widget を保持し続ける。状態が残る利点と引き換えに、各タブの購読やタイマーも動き続ける点は意識しておく。。
§ 06LIFETIMERouter を作り直さない
一つ、実際に踏みやすい落とし穴があります。GoRouter のインスタンスを build() の中で作らない
こと。
// やってはいけないWidget build(BuildContext context) {final router = GoRouter(routes: [...]); // 再構築のたびに新しいルーターreturn MaterialApp.router(routerConfig: router);}
連載 I で見たとおり build() は何度でも呼ばれます。そのたびに新しい GoRouter が生まれると、
それまでのスタックごと作り直され、開いていた画面が消えたり、タブの履歴が失われたりします。
ルーターはアプリの生存期間を通じて一つです。トップレベルの final に置くか、Riverpod を使って
いるなら Provider に持たせます。
final routerProvider = Provider<GoRouter>((ref) {return GoRouter(routes: [...]);});
Provider に置く利点は、認証状態などを ref 経由で redirect から参照できることです。ただしその
Provider 自体が頻繁に再生成されると同じ問題が起きるので、ルーターが依存するのは「変わりにくい
もの」だけに保ちます。
§ 07DEEPLINK外から特定の画面へ
最後に deep link です。画面がパスで表現できるようになったので、外部からの入口も同じ言葉で扱えます。 URL で起動されればそのパスが初期位置として解決され、通知のタップなら、ペイロードに入れておいた パスへ移動させるだけです。
void onNotificationTap(String payloadPath) {router.go(payloadPath); // 例: '/items/42'}
そして deep link で開かれた画面にも redirect は当然効きます。未ログインのまま通知から詳細画面を
開こうとすれば、ログイン画面へ送られる——入口をどれだけ増やしてもガードは一箇所のままです。
これが、命令的な push を宣言的なルーティングへ置き換えたことの、いちばん大きな見返りです。
§ 08SUMMARY画面のあいだを設計する地図
- 土台は
Navigatorのスタック。pushで積み、popで戻り、popは値を返せる - 命令的な
pushだけでは、deep link・ガード・Web の戻るに対応しきれない go_routerはパスと画面の対応を宣言する。goは移動、pushは積むredirectはすべての遷移が通る一点。nullで通し、パスで書き換える- リダイレクト先では
nullを返してループを断つ。状態変化での再評価はrefreshListenable - タブごとの履歴は
StatefulShellRoute。切り替えはgoBranch GoRouterをbuild()の中で作らない。作り直すとスタックが消える
これで画面の作り方と、画面のつなぎ方がそろいました。次回は、アプリ全体で見た目と文言を一貫させる
仕組み——ThemeData と多言語化を扱います。
- [1]
Navigator.pushNamedとMaterialAppのroutes:を使う旧来の名前付きルートもある。パスパラメータやガード、ネストしたナビゲーションを扱いにくいため、現在はgo_routerなどの宣言的ルーターを使うのが一般的。 ↩ - [2] Web ではこの違いがブラウザの履歴に直接現れる。
goは現在地の置き換え、pushは履歴を一つ増やす操作に対応する。 ↩ - [3]
go_routerにはリダイレクト回数の上限(既定 5 回)があり、超えると例外になる。無限ループは黙って固まるのではなく、エラーとして表面化する。 ↩ - [4]
refreshListenableにListenableを渡すと、通知のたびにredirectが再評価される。ストリームから作る場合はGoRouterRefreshStreamのような薄いアダプタを挟む。 ↩ - [5]
StatefulShellRoute.indexedStackは全ブランチの Widget を保持し続ける。状態が残る利点と引き換えに、各タブの購読やタイマーも動き続ける点は意識しておく。 ↩