Flutterのgo_routerを使って色々な遷移の実装する方法についてまとめました。
go_router 14.1.0
はじめに
この記事ではFlutterの画面遷移ライブラリ go_router でいろんな遷移方法を実現する方法についてまとめます。
go_router は Flutter でURLベースの画面遷移を実現するパッケージです。
セットアップ
まず go_router を以下の通りインストールします。
flutter pub add go_router
同じ階層の画面に遷移する
それではまず簡単な画面遷移を行います。
画面は階層化することでスタックして「戻る」ことができますが、まずは同階層の画面へ遷移する方法から見ていきます。
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(const MyApp()); // GoRouterクラスでルート(画面遷移の構造)を設定 final GoRouter _router = GoRouter( routes: [ // ’/'と'/screen_b'の2つのルートを設定 // トップレベルのパスはスラッシュから始める必要がある GoRoute(path: '/', builder: (context, state) => const ScreenA()), GoRoute(path: '/screen_b', builder: (context, state) => const ScreenB()), ], ); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { // MaterialApp.routerを使用してGoRouterを使用する // routerConfigにルート設定(上記で作った_router)を渡す return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample'); } } // サンプル画面: ScreenA class ScreenA extends StatelessWidget { const ScreenA({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen A'), backgroundColor: Colors.blueAccent), body: Center( child: ElevatedButton( // context.goでルートのパスを指定して遷移 onPressed: () => context.go('/screen_b'), child: const Text('Go To Screen B'), ), ), ); } } // サンプル画面: ScreenB class ScreenB extends StatelessWidget { const ScreenB({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen B'), backgroundColor: Colors.greenAccent), body: Center( child: ElevatedButton( onPressed: () => context.go('/'), child: const Text('Go To Screen A'), ), ), ); } }
説明はコメントに記載しました。
- GoRouter クラスでルート(画面遷移の構造)を定義する
- MaterialApp.router を使って各ウィジェットでgo_routerを使えるようにする
- context.go(’パス’)で遷移
というのが基本的な使い方になります。
実行結果は下図の通りです。
ちなみに上記のコードでは/
というパスのルートを定義していますが、デフォルトではこの名前のルートが初期画面になります。
明示的に初期画面を指定する場合には、以下のようにGoRouter
のコンストラクタ引数である initialLocation
にパスを指定します。
final GoRouter _router = GoRouter( // 初期表示画面のパスを指定 initialLocation: '/screen_a', routes: [ GoRoute(path: '/screen_a', builder: (context, state) => const ScreenA()), GoRoute(path: '/screen_b', builder: (context, state) => const ScreenB()), ], );
子階層の画面に遷移する
前節のように同階層に遷移する場合、画面がスタックに積まれず、AppBar
に戻るボタンが表示されません。
もし画面をスタックに積んで戻れるようにしたい場合には、以下のようにGoRoute
のコンストラクタのroutes
引数に子画面のGoRoute
を与えます。
戻る処理自体は Pop
メソッドで行います。
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(const MyApp()); final GoRouter _router = GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => const ScreenA(), routes: [ // '/'の子ルートとして'screen_b'を定義 GoRoute( // 子ルートのpathにはスラッシュをつけない path: 'screen_b', builder: (context, state) => const ScreenB(), ), ], ), ], ); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample'); } } // サンプル画面: ScreenA class ScreenA extends StatelessWidget { const ScreenA({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen A'), backgroundColor: Colors.blueAccent), body: Center( child: ElevatedButton( // パスは各ルートのパスをスラッシュで繋げたものを指定 onPressed: () => context.go('/screen_b'), child: const Text('Go To Screen B'), ), ), ); } } // サンプル画面: ScreenB class ScreenB extends StatelessWidget { const ScreenB({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen B'), backgroundColor: Colors.greenAccent), body: Center( child: ElevatedButton( // Popメソッドで親階層に戻る onPressed: () => context.pop(), child: const Text('Pop'), ), ), ); } }
コメントにも記載しましたが、子ルートを指定した場合には、遷移時には各階層のpath引数を連結したものをパスとして指定します。
例えばより深い階層であれば /screen_b/screenc
のようになります。
ちなみに1段階下の画面を飛ばして2階層下の画面に一気に遷移することもできます。
ただしPopは単純に親階層に戻るだけなので、この場合の戻り先は遷移時に飛ばした「1段階下の画面」になります。
実行結果は下図の通りです。
画面の一部の領域で遷移を行う
次に、画面の一部領域だけで画面遷移が発生するケースを考えます。
具体的には下図のようにBottomNavigationBarが存在し、それ以外の領域(コンテンツ領域と呼びます)で画面が切り替わるケースです。
このようなケースでは以下のようにShellRoute
クラスを使って階層構造を表現します。
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(const MyApp()); final GoRouter _router = GoRouter( initialLocation: '/screen_a', routes: [ // 画面の一部の領域で遷移を行うにはShellRouteを使う ShellRoute( builder: (BuildContext context, GoRouterState state, Widget child) { // コンテンツ画面をラップする画面を返す // コンテンツ画面はchildとして渡される return RootScreen(child: child); }, // コンテンツ画面はShellRouteの子として設定する routes: <RouteBase>[ GoRoute( path: '/screen_a', builder: (BuildContext context, GoRouterState state) { return const ScreenA(); }, ), GoRoute( path: '/screen_b', builder: (BuildContext context, GoRouterState state) { return const ScreenB(); }, ), ], ), ], ); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample'); } } // BottomNavigationBarを持つルート画面 // コンストラクタで渡すchildがコンテンツ領域に表示される class RootScreen extends StatelessWidget { const RootScreen({required this.child, super.key}); final Widget child; @override Widget build(BuildContext context) { return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Screen A', ), BottomNavigationBarItem( icon: Icon(Icons.settings), label: 'Screen B', ), ], currentIndex: _getCurrentIndex(context), onTap: (int index) => _onItemTapped(index, context), ), ); } // 現在のBottomNavigationBarのインデックスを取得 static int _getCurrentIndex(BuildContext context) { // GoRouterのパスから現在の画面を判定 final String location = GoRouterState.of(context).uri.path; if (location.startsWith('/screen_a')) { return 0; } if (location.startsWith('/screen_b')) { return 1; } throw Exception('Unknown location: $location'); } // BottomNavigationBarのタップ時の処理 void _onItemTapped(int index, BuildContext context) { // インデックスに応じて画面遷移 switch (index) { case 0: GoRouter.of(context).go('/screen_a'); case 1: GoRouter.of(context).go('/screen_b'); default: throw Exception('Unknown index: $index'); } } } // コンテンツ画面: ScreenA class ScreenA extends StatelessWidget { const ScreenA({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen A'), backgroundColor: Colors.blueAccent), body: const SizedBox(), ); } } // コンテンツ画面: ScreenB class ScreenB extends StatelessWidget { const ScreenB({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen B'), backgroundColor: Colors.greenAccent), body: const SizedBox(), ); } }
これを実行すると下図の結果が得られます。
画面の一部の領域から子に遷移する
次に、前節で作ったコンテンツ領域から、さらに子画面に遷移する実装を行います。
以下は、Screen Aから詳細画面に遷移できるようにしたコードです。
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(const MyApp()); final GoRouter _router = GoRouter( initialLocation: '/screen_a', routes: [ ShellRoute( builder: (BuildContext context, GoRouterState state, Widget child) { return RootScreen(child: child); }, routes: <RouteBase>[ GoRoute( path: '/screen_a', builder: (BuildContext context, GoRouterState state) { return const ScreenA(); }, // screen_aに子ルートを追加 routes: <RouteBase>[ GoRoute( path: 'details', builder: (BuildContext context, GoRouterState state) { return const DetailsScreen(label: 'Details of Screen A'); }, ), ], ), GoRoute( path: '/screen_b', builder: (BuildContext context, GoRouterState state) { return const ScreenB(); }, ), ], ), ], ); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample'); } } class RootScreen extends StatelessWidget { const RootScreen({required this.child, super.key}); final Widget child; @override Widget build(BuildContext context) { return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Screen A', ), BottomNavigationBarItem( icon: Icon(Icons.settings), label: 'Screen B', ), ], currentIndex: _getCurrentIndex(context), onTap: (int index) => _onItemTapped(index, context), ), ); } static int _getCurrentIndex(BuildContext context) { final String location = GoRouterState.of(context).uri.path; if (location.startsWith('/screen_a')) { return 0; } if (location.startsWith('/screen_b')) { return 1; } throw Exception('Unknown location: $location'); } void _onItemTapped(int index, BuildContext context) { switch (index) { case 0: GoRouter.of(context).go('/screen_a'); case 1: GoRouter.of(context).go('/screen_b'); default: throw Exception('Unknown index: $index'); } } } // コンテンツ画面: ScreenA class ScreenA extends StatelessWidget { const ScreenA({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen A'), backgroundColor: Colors.blueAccent), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ const Text('Screen A'), TextButton( onPressed: () { // ScreenAの詳細画面に遷移 GoRouter.of(context).go('/screen_a/details'); }, child: const Text('View A details'), ), ], ), ), ); } } // コンテンツ画面: ScreenB class ScreenB extends StatelessWidget { const ScreenB({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen B'), backgroundColor: Colors.greenAccent), body: const SizedBox(), ); } } // コンテンツ画面: Details class DetailsScreen extends StatelessWidget { const DetailsScreen({required this.label, super.key}); final String label; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Details Screen'), backgroundColor: Colors.redAccent), body: Center(child: Text(label)), ); } }
実行結果は以下のとおりです。
タブを切り替えても状態がリセットされないようにする
さて、前節でタブ内で子画面に遷移できるようになりましたが、前節の実装ではタブを切り替えるとそのタブのトップルートに遷移してしまいます。
実際のユースケースを想定すると、タブ遷移前の状態を保っておいて欲しいものです。
これを行うには以下のように StatefulShellRoute
を使う必要があります。
これは内部的にはタブごとに別々の Navigator を使っており、タブごとの状態を保持することができます。
import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(const MyApp()); final GoRouter _router = GoRouter( initialLocation: '/screen_a', routes: [ // ShellRouteではなくStatefulShellRouteを使う StatefulShellRoute.indexedStack( builder: (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) { // RootScreenはStatefulNavigationShellを受け取るように変更 // StatefulNavigationShellが状態を保ったまま他タブに切り替える機能を持つ return RootScreen(navigationShell: navigationShell); }, branches: [ // タブごとにStatefulShellBranchを定義 StatefulShellBranch( routes: <RouteBase>[ // タブ内のルートを定義 GoRoute( path: '/screen_a', builder: (BuildContext context, GoRouterState state) { return const ScreenA(); }, routes: [ GoRoute( path: 'details', builder: (BuildContext context, GoRouterState state) { return const DetailsScreen(label: 'Details of Screen A'); }, ), ], ), ], ), // 2個目のタブを定義 StatefulShellBranch( routes: [ GoRoute( path: '/screen_b', builder: (BuildContext context, GoRouterState state) { return const ScreenB(); }, ), ], ), ], ), ], ); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample'); } } class RootScreen extends StatelessWidget { // StatefulNavigationShellを受け取るように変更 const RootScreen({required this.navigationShell, super.key}); final StatefulNavigationShell navigationShell; @override Widget build(BuildContext context) { return Scaffold( // bodyにはnavigationShellを渡す body: navigationShell, bottomNavigationBar: BottomNavigationBar( items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Screen A', ), BottomNavigationBarItem( icon: Icon(Icons.settings), label: 'Screen B', ), ], // タブインデックスはnavigationShellから取得できる currentIndex: navigationShell.currentIndex, onTap: (int index) => _onItemTapped(index, context), ), ); } // タップされた時の処理 void _onItemTapped(int index, BuildContext context) { // タブ間の遷移はこのように行う navigationShell.goBranch( index, // 第二引数にtrueを渡すと、タブのトップルートに遷移する // つまり以下のようにすると、非アクティブなタブ(現在のタブとは別のタブ)をクリック // した場合には状態を保ったままそのタブに遷移し、アクティブなタブ(現在のタブ)を // クリックした場合にはトップのルートに戻るという挙動になる initialLocation: index == navigationShell.currentIndex, ); } } // コンテンツ画面: ScreenA class ScreenA extends StatelessWidget { const ScreenA({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen A'), backgroundColor: Colors.blueAccent), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ const Text('Screen A'), TextButton( onPressed: () { // タブ内での遷移の仕方はここまでのやり方と同様 GoRouter.of(context).go('/screen_a/details'); }, child: const Text('View A details'), ), ], ), ), ); } } // コンテンツ画面: ScreenB class ScreenB extends StatelessWidget { const ScreenB({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Screen B'), backgroundColor: Colors.greenAccent), body: const SizedBox(), ); } } // コンテンツ画面: Details class DetailsScreen extends StatelessWidget { const DetailsScreen({required this.label, super.key}); final String label; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Details Screen'), backgroundColor: Colors.redAccent), body: Center(child: Text(label)), ); } }
少々変更点が多いですが、説明はコメントに記載したとおりです。
これを実行すると以下の結果が得られます。
タブ切り替え時に状態が保持されていることを確認できました。