go_router_builder
で(Bottom) Navigation Barを扱う際のRoute指定をどのようにするのが良いのかを考えました。
バージョンは現時点での最新2.7.1で試しています。
(普段go_routerは使っていないので、もしかしたらもっと簡単にできたり、間違っている可能性もあります)
そもそもなんでこんなことを考える必要があるのかというと、
Flutterは元々Navigatorと呼ばれる、従来のモバイルアプリでよく扱われているスタック形式で画面遷移を実現していたのですが(今でも使えます)、Router(通称Navigator2.0)の登場によりパスをあらかじめ設定して、意図したパスに遷移させる方式に変わりました。
そのRouterが、モバイルアプリでよく使われているNavigation Bar
の挙動と相性が悪いことが原因です。
Routerの登場は、Flutter WEBの登場により挙動を揃えるための進化だと思っていますが、個人的にはモバイルアプリだけなら以前のNavigatorでも問題ないと思っています。
ただ、時代の流れだったりDeepLinkの遷移が楽になるなどのメリットもあるため、今回はgo_router_builderでNavigation Barを扱う方法を考えます。
画面構成
StatefulNavigationShell
を取って、Bottom Navigation Barを持つページを便宜上 root_page
と置いています。
タブは home
と settings
の2つを用意して、画面に依らないページを detail_page
, loop_page
とします。
.
├── detail
│ └── detail_page.dart
├── loop
│ └── loop_page.dart
└── root
├── home
│ └── home_page.dart
├── root_page.dart
└── settings
└── settings_page.dart
実装
前述の通りRouterとの相性問題もあってか、元々はgo_routerでのNavigation Barの実装は面倒でしたが、2023年5月くらいにリリースされた7.1.0
でNavigation BarのState管理が楽になりました。今回は StatefulShellRoute
と TypedStatefulShellBranch
を使って実装します。
- root_page
root_pageはStatefulNavigationShellを渡して、タブを切り替えています。
class RootPage extends StatelessWidget {
const RootPage({
required this.navigationShell,
super.key,
});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
onDestinationSelected: _goBranch,
),
);
}
void _goBranch(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
}
- home_page
class HomePage extends StatelessWidget {
const HomePage({super.key});
static const String pagePath = '/home';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('home'),
),
body: Center(
child: ElevatedButton(
child: const Text(
'to detail',
),
onPressed: () {
},
),
),
);
}
}
- settings_page
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
static const String pagePath = '/settings';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('settings'),
),
body: Center(
child: ElevatedButton(
child: const Text(
'to detail',
),
onPressed: () {
},
),
),
);
}
}
- router
タブ内の遷移は一旦省略しています。
part 'router.g.dart';
final router = GoRouter(
routes: $appRoutes,
initialLocation: HomePage.pagePath,
debugLogDiagnostics: true,
);
@TypedStatefulShellRoute<RootRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<_HomePageData>(
routes: [
TypedGoRoute<HomeRouteData>(
path: HomePage.pagePath,
),
],
),
TypedStatefulShellBranch<_SettingsPageData>(
routes: [
TypedGoRoute<SettingsRouteData>(
path: SettingsPage.pagePath,
),
],
),
],
)
class RootRouteData extends StatefulShellRouteData {
const RootRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return RootPage(navigationShell: navigationShell);
}
}
class _HomePageData extends StatefulShellBranchData {
const _HomePageData();
}
class HomeRouteData extends GoRouteData {
const HomeRouteData();
@override
Widget build(BuildContext context, GoRouterState state) => const HomePage();
}
class _SettingsPageData extends StatefulShellBranchData {
const _SettingsPageData();
}
class SettingsRouteData extends GoRouteData {
const SettingsRouteData();
@override
Widget build(BuildContext context, GoRouterState state) =>
const SettingsPage();
}
- main
生成したrouterをMaterialAppにセットします。
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerDelegate: router.routerDelegate,
routeInformationParser: router.routeInformationParser,
routeInformationProvider: router.routeInformationProvider,
);
}
}
State管理
上記のコードはroot_pageに用意したタブを押して、TypedStatefulShellRoute
を使って定義したRouteの HomeRouteData
と SettingsRouteData
によって画面を切り替えています。
Statefulの名の通りhome_pageとsettings_pageは状態が保持されます。
そのためBottom Navigation Barのボタンを押してタブを切り替えてもrebuildはされず同じインスタンスが利用されます。
タブから遷移した画面はどうなるでしょうか?
detail_page
という画面を用意します。状態遷移をわかりやすくするため、中身はFlutterデフォルトのCounter pageにします。
- detail_page
class DetailPage extends StatefulWidget {
const DetailPage({super.key});
static const String pagePath = 'detail';
@override
State<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends State<DetailPage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Detail page'),
),
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),
),
);
}
}
パターン1
パターン1は、HomeRouteのみにDetailRouteを定義します。
- router
routerを書き換えます。
DetailRouteDataを作成してHomeのroutesに追加します。
Settingsのroutesには追加しません。(正確には自動生成されるコードで名前被りになってしまうため追加できません)
@TypedStatefulShellRoute<RootRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<_HomePageData>(
routes: [
TypedGoRoute<HomeRouteData>(
path: HomePage.pagePath,
routes: [
TypedGoRoute<DetailRouteData>(path: DetailPage.pagePath),
],
),
],
),
TypedStatefulShellBranch<_SettingsPageData>(
routes: [
TypedGoRoute<SettingsRouteData>(
path: SettingsPage.pagePath,
routes: [
],
),
],
),
],
)
class RootRouteData extends StatefulShellRouteData {
const RootRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return RootPage(navigationShell: navigationShell);
}
}
class DetailRouteData extends GoRouteData {
const DetailRouteData();
@override
Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
- home_page
ボタンを押したらDetailRouteDataを使って詳細画面に遷移します。
...
onPressed: () {
const DetailRouteData().go(context);
},
...
- settings_page
同様にこちらもDetailRouteDataから詳細画面に遷移します。
...
onPressed: () {
const DetailRouteData().go(context);
},
...
Homeタブからdetail_pageに遷移してインクリメントします。その後、Settingsタブからdetail_pageに遷移します。
そうすると、Homeからの遷移でクリックした状態がSettingsから遷移した時にも保持されています。
からくりとしては、RouterにHomeからのパスのみを宣言しているので、Settingsから遷移した場合も実際には /home/detail
に遷移していることになります。
パターン2
パターン1は意図している動作ではありません。 詳細画面は画面に依らないページになるので、別でルートを定義したらどうなるでしょうか?
- router
今度はTypedStatefulShellBranch
内に定義せずに、TypedGoRoute
を使って別で定義します。
@TypedStatefulShellRoute<RootRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<_HomePageData>(
routes: [
TypedGoRoute<HomeRouteData>(
path: HomePage.pagePath,
routes: [
],
),
],
),
TypedStatefulShellBranch<_SettingsPageData>(
routes: [
TypedGoRoute<SettingsRouteData>(
path: SettingsPage.pagePath,
routes: [
],
),
],
),
],
)
class RootRouteData extends StatefulShellRouteData {
const RootRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return RootPage(navigationShell: navigationShell);
}
}
@TypedGoRoute<DetailRouteData>(
path: DetailPage.pagePath,
)
class DetailRouteData extends GoRouteData {
const DetailRouteData();
@override
Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
- detail_page
この場合はパスを /
から始める必要があるので注意してください。
...
static const String pagePath = '/detail';
...
- home_page
pushに書き換えます。
...
onPressed: () {
const DetailRouteData().push<void>(context);
},
...
- settings_page
...
onPressed: () {
const DetailRouteData().push<void>(context);
},
...
今度は状態は保存されなくなったもののタブが消えました。
からくりとしては、Bottom Navigation Barの表示されているルートは /
から始まる /home
, /settings
になっていますが、今回の定義では /detail
から始まる別のルートになっています。
そのため、root_pageに定義されているBottom Navigation barは表示されず、毎回 /detail
にアクセスし直すため状態も保持されずアクセスの度に生成し直されています。
パターン3
場合によっては詳細画面などはタブを覆い隠す形で表示する時もありますが、パターン2はタブを使う旨味が消えてしまいます。
(今回settingsという名前でタブを作ってしまったのですが、どちらかというと設定画面などがタブに依らない画面表示をすることが多いかなと思います)
そこで、タブのRoute上で開きながら共通の画面を表示するパターンを考えます。
結論から言うとタブごとにrouterに別々のルート定義をします。
冗長ですが、それぞれの遷移方法のパスを定義するのは仕方ないのかなと思います。
(もしかしたらベストプラクティスがあるかもしれません 🙇)
- router
@TypedStatefulShellRoute<RootRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<_HomePageData>(
routes: [
TypedGoRoute<HomeRouteData>(
path: HomePage.pagePath,
routes: [
TypedGoRoute<HomeDetailRouteData>(path: DetailPage.pagePath),
],
),
],
),
TypedStatefulShellBranch<_SettingsPageData>(
routes: [
TypedGoRoute<SettingsRouteData>(
path: SettingsPage.pagePath,
routes: [
TypedGoRoute<SettingsDetailRoute>(path: DetailPage.pagePath),
],
),
],
),
],
)
class RootRouteData extends StatefulShellRouteData {
const RootRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return RootPage(navigationShell: navigationShell);
}
}
class _HomePageData extends StatefulShellBranchData {
const _HomePageData();
}
class HomeRouteData extends GoRouteData {
const HomeRouteData();
@override
Widget build(BuildContext context, GoRouterState state) => const HomePage();
}
class _SettingsPageData extends StatefulShellBranchData {
const _SettingsPageData();
}
class SettingsRouteData extends GoRouteData {
const SettingsRouteData();
@override
Widget build(BuildContext context, GoRouterState state) =>
const SettingsPage();
}
class HomeDetailRouteData extends GoRouteData {
const HomeDetailRouteData();
@override
Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
class SettingsDetailRoute extends GoRouteData {
const SettingsDetailRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
- home_page
pushに書き換えます。
...
onPressed: () {
const HomeDetailRouteData().push<void>(context);
},
...
- settings_page
...
onPressed: () {
const SettingsDetailRouteData().push<void>(context);
},
...
routeを別々で定義したため、それぞれのタブ事に状態が保存されるようになりました。
同じ画面に遷移することは多々ありますが、状態を共有したいことはあまりないと思うので、今回のようにタブ内で詳細画面のような共通の遷移先を定義する場合はこのようにすると良さそうです。
おまけ
loop_page
を追加して、settings -> detail -> loop -> detail -> … と遷移した場合の挙動を見てみます。
- router
@TypedStatefulShellRoute<RootRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<_HomePageData>(
routes: [
TypedGoRoute<HomeRouteData>(
path: HomePage.pagePath,
routes: [
TypedGoRoute<HomeDetailRouteData>(path: DetailPage.pagePath),
],
),
],
),
TypedStatefulShellBranch<_SettingsPageData>(
routes: [
TypedGoRoute<SettingsRouteData>(
path: SettingsPage.pagePath,
routes: [
TypedGoRoute<SettingsDetailRouteData>(path: DetailPage.pagePath),
TypedGoRoute<SettingsLoopRouteData>(path: LoopPage.pagePath),
],
),
],
),
],
)
...
class SettingsLoopRouteData extends GoRouteData {
const SettingsLoopRouteData();
@override
Widget build(BuildContext context, GoRouterState state) => const LoopPage();
}
- loop_page
class LoopPage extends StatelessWidget {
const LoopPage({super.key});
static const String pagePath = 'loop';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('loop page'),
),
body: Center(
child: ElevatedButton(
child: const Text(
'to detail',
),
onPressed: () {
const SettingsDetailRouteData().go(context);
},
),
),
);
}
}
- detail_page
...
ElevatedButton(
child: const Text(
'to root',
),
onPressed: () {
const SettingsRouteData().go(context);
},
),
ElevatedButton(
child: const Text(
'to loop',
),
onPressed: () {
const SettingsLoopRouteData().go(context);
},
),
...
タブはsettingsから移動していませんが、遷移ごとに画面の状態は保存されていません。
これは、settingsタブのトップが /settings
, 詳細画面が /settings/detail
, 今回追加したloop画面が /settings/loop
のパスになっていて、同じ階層同士で移動し合っているため遷移の度に破棄され再生成されます。
detail_pageからloop_pageに遷移してdetail_pageに戻った際に状態を保持したい場合はrouteの設定をこのようにします。
画面数が多いアプリはネストが無限に深くなっていくきますが、現状こうするしかなさそうです。
@TypedStatefulShellRoute<RootRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<_HomePageData>(
routes: [
TypedGoRoute<HomeRouteData>(
path: HomePage.pagePath,
routes: [
TypedGoRoute<HomeDetailRouteData>(path: DetailPage.pagePath),
],
),
],
),
TypedStatefulShellBranch<_SettingsPageData>(
routes: [
TypedGoRoute<SettingsRouteData>(
path: SettingsPage.pagePath,
routes: [
TypedGoRoute<SettingsDetailRouteData>(
path: DetailPage.pagePath,
routes: [
TypedGoRoute<SettingsLoopRouteData>(path: LoopPage.pagePath),
],
),
],
),
],
),
],
)
こうすれば、settings -> detail -> loop -> detailに遷移しても状態が保持されますし、ボタンを押しても、バックボタンで戻っても同じ挙動になります。
つまり、Routerで画面遷移を実現する場合はスタックの考えを捨てて、Webページと同じURLの感覚で扱っていく必要があるのかなと思います。