はじめまして!wattah(わったー)です!
DeNA 24 新卒 Advent Calendar 2023 の 9日目の記事です🎄
この記事では、Flutter でアプリ開発を始める際に使える NavigationBar の実装レシピを紹介します!
では、コーヒーでも飲みながらサクッといきましょう!☕️
はじめに
前提として、ルーティングには GoRouter を使用しています。
また、go_router_builder で型をつけています。
go_router_builder は GoRotuer のクラスにアノテーションをつけて build_runner
を実行することで、type-safe に GoRouter の API を呼べるようにコードを自動生成してくれます。
各パッケージの詳しい使い方は、記事が冗長になってしまうので、ここでの説明は割愛します。
ドキュメントを見ていただくのが早いと思います。
ソースコード
環境
- Flutter version: 3.16.0
- Dart version: 3.2.0
- go_router version: 12.1.1
- go_router_builder version: 2.3.4
flutter doctor -v
[✓] Flutter (Channel stable, 3.16.0, on macOS 13.6.1 22G313 darwin-arm64, locale ja-JP)
• Flutter version 3.16.0 on channel stable at /Users/yukiwatanabe/.asdf/installs/flutter/3.16.0-stable
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision db7ef5bf9f (3 weeks ago), 2023-11-15 11:25:44 -0800
• Engine revision 74d16627b9
• Dart version 3.2.0
• DevTools version 2.28.2
[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
• Android SDK at /Users/yukiwatanabe/Library/Android/sdk
• Platform android-33, build-tools 33.0.0
• Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
• Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)
• All Android licenses accepted.
[✓] Xcode - develop for iOS and macOS (Xcode 14.3.1)
• Xcode at /Applications/Xcode_14.3.app/Contents/Developer
• Build 14E300c
• CocoaPods version 1.13.0
[✓] Chrome - develop for the web
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Android Studio (version 2021.3)
• Android Studio at /Applications/Android Studio.app/Contents
• Flutter plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/6351-dart
• Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)
[✓] VS Code (version 1.84.2)
• VS Code at /Applications/Visual Studio Code.app/Contents
• Flutter extension version 3.78.0
[✓] Connected device (3 available)
• iPhone 14 Pro Max (mobile) • D7F01C29-9A81-45D1-AFAD-5B66A0F08E21 • ios • com.apple.CoreSimulator.SimRuntime.iOS-16-4
(simulator)
• macOS (desktop) • macos • darwin-arm64 • macOS 13.6.1 22G313 darwin-arm64
• Chrome (web) • chrome • web-javascript • Google Chrome 120.0.6099.71
[✓] Network resources
• All expected network resources are available.
• No issues found!
ShellRoute と StatefulShellRoute
まず go_router_builder は置いておいて、GoRouter における話をします。
NavigationBar のように、ページ遷移の際に全画面のリビルドをさせずにナビゲーションさせたい場合には、ShellRoute か StatefulShellRoute を使うことが有効です。
使い分けとしては、ページ遷移の際に状態を保持するかどうかで決めることができます。
ShellRoute
: 状態を保持する必要がない
StatefulShellRoute
: 状態を保持する必要がある
動作の様子とルーティングの階層はこんな感じになります。
ShellRoute
GoRouter
└─ ShellRoute
├─ GoRoute('/alpha')
│ └─ GoRoute('details')
└─ GoRoute('/beta')
StatefulShellRoute
GoRouter
└─ StatefulShellRoute
├─ StatefulShellBranch
│ └─ GoRoute('/alpha')
│ └─ GoRoute('details')
└─ StatefulShellBranch
└─ GoRoute('/beta')
ShellRoute
では、Alpha Details Page に遷移した状態が保持されませんが、StatefulShellRoute
では保持されていることが分かります。
また、名前から想像できる通り StatefulShellRoute
は ShellRoute
よりもできることが多いです。
お馴染みの CODE WITH ANDREA で StatefulShellRoute
のより詳しい説明がなされているので、もしよければ見てみてください。
TypedShellRoute
go_router_builder を使って type-safe に ShellRoute
を呼ぶ方法を説明します。
GoRouter API を使用した実装部分はこのような感じです。
@TypedShellRoute<MyShellRouteData>(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<AlphaRouteData>(
path: '/alpha',
routes: <TypedRoute<RouteData>>[
TypedGoRoute<AlphaDetailsRouteData>(path: 'details')
],
),
TypedGoRoute<BetaRouteData>(
path: '/beta',
),
],
)
class MyShellRouteData extends ShellRouteData {
const MyShellRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
Widget navigator,
) {
return ScaffoldWithNavigation(child: navigator);
}
}
class AlphaRouteData extends GoRouteData {
const AlphaRouteData();
@override
Widget build(
BuildContext context,
GoRouterState state,
) =>
const AlphaPage();
}
class AlphaDetailsRouteData extends GoRouteData {
const AlphaDetailsRouteData();
@override
Widget build(
BuildContext context,
GoRouterState state,
) =>
const AlphaDetailsPage();
}
class BetaRouteData extends GoRouteData {
const BetaRouteData();
@override
Widget build(
BuildContext context,
GoRouterState state,
) =>
const BetaPage();
}
final router = GoRouter(
routes: $appRoutes,
initialLocation: '/alpha',
);
まず、ShellRouteData
を継承して、@TypedShellRoute
をつけます。
ShellRouteData
には ShellRoute
を返す $route
メソッドが用意されています。
自動生成されたコードを見ると、$route
が呼ばれていることが分かります。
$appRotues
は GoRouter
クラスをインスタンス化する際に、routes
プロパティに流します。
List<RouteBase> get $appRoutes => [
$myShellRouteData,
];
RouteBase get $myShellRouteData => ShellRouteData.$route(
factory: $MyShellRouteDataExtension._fromState,
routes: [
GoRouteData.$route(...
TypedGoRoute
についても似たような形です。
継承元の GoRouteData
には GoRoute
を返す $route
が用意されています。
生成されたコードでも GoRouteData.$route(...
と、 GoRoute
を呼んでいることが分かります。
次に、MyShellRouteData
の override された builder
関数で返している ScaffoldWithNavigation
の実装はこのようになります。
class ScaffoldWithNavigation extends StatelessWidget {
const ScaffoldWithNavigation({
required this.child,
super.key,
});
final Widget child;
int getCurrentIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
if (location == '/beta') {
return 1;
}
return 0;
}
@override
Widget build(BuildContext context) {
final currentIndex = getCurrentIndex(context);
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home),
label: 'Alpha',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Beta',
),
],
onDestinationSelected: (int index) {
switch (index) {
case 0:
const AlphaRouteData().go(context);
break;
case 1:
const BetaRouteData().go(context);
break;
}
},
),
);
}
}
現在のインデックスは、getCurrentIndex
関数を実装して取得できるようします。
GoRouterState
からロケーションを取得して、NavigationBar
のインデックスを返す関数です。
また、onDestinationSelected
で呼ばれるページ遷移の関数は自前で用意する必要があります。
TypedStatefulShellRoute
次に、type-safe な StatefulShellRoute
について説明します。
まず、GoRouter API を使用した実装部分はこのような感じです。
@TypedStatefulShellRoute<MyStatefulShellRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<BranchAlphaData>(
routes: [
TypedGoRoute<AlphaRouteData>(
path: '/alpha',
routes: [
TypedGoRoute<AlphaDetailsRouteData>(path: 'details'),
],
),
],
),
TypedStatefulShellBranch<BranchBetaData>(
routes: [
TypedGoRoute<BetaRouteData>(path: '/beta'),
],
),
],
)
class MyStatefulShellRouteData extends StatefulShellRouteData {
const MyStatefulShellRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return ScaffoldWithNavigation(navigationShell: navigationShell);
}
}
class BranchAlphaData extends StatefulShellBranchData {
const BranchAlphaData();
}
class BranchBetaData extends StatefulShellBranchData {
const BranchBetaData();
}
// 以下は TypedShellRoute のときと同じなので省略します
class AlphaRouteData extends GoRouteData {
...
}
class AlphaDetailsRouteData extends GoRouteData {
...
}
class BetaRouteData extends GoRouteData {
...
}
final router = GoRouter(
initialLocation: '/alpha',
routes: $appRoutes,
);
新しく StatefulShellRouteData
と StatefulShellBranchData
が出てきました。
StatefulShellBranchData
は内部の $branch
メソッドで StatefulShellBranch
を返します。
StatefulShellRouteData
については注意が必要です。
それは、StatefulShellRouteData
内部の $route
メソッドで返されるものが StatefulShellRoute()
と StatefulShellRoute.indexedStack()
の2パターンある点です。
どちらも、StatefulWidget
を継承した navigationShell
というシェルに関する状態を持つ Widget について、それを含む形で builder
として ScaffoldWithNavigation
を返すのは共通しています。
しかし、 MyStatefulShellRouteData
内で $navigationContainerBuilder
関数を定義しているか否かで、返すインスタンスが異なります。
定義していなければ StatefulShellRoute.indexedStack()
が返ります。
凝ったことをしない限りは、こちらで問題ないです。
定義していれば StatefulShellRoute()
の navigatorContainerBuilder
プロパティに $navigationContainerBuilder
が渡されて返ります。
navigatorContainerBuilder
は引数に List<Widget>
を取ることができるため、ここに子ルートの Widget を渡して、ページ遷移時の挙動をカスタマイズできます。
go_router_builder の examples には、間にアニメーションをかませる例が記載されているので、もし良かったら試してみてください。
では次に、ScaffoldWithNavigation
の実装です。
class ScaffoldWithNavigation extends StatelessWidget {
const ScaffoldWithNavigation({
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: 'Alpha',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Beta',
),
],
onDestinationSelected: _goBranch,
),
);
}
void _goBranch(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
}
TypedShellRoute
のときよりもコードの量が少なくなったのが分かります。
これは先ほどちらっと紹介した navigationShell
が shell 周りの状態を持つ StatefulWidget
で、currentIndex
を取ることができたり、goBranch
メソッドが用意されていて、switch
によるページ遷移の自前実装が必要なくなるためです。
以上が TypedStatefulShellRoute
の説明になります。
おわりに
build_runner
を使う場合には、生成されたコードから中身を追わないと、挙動を正しく理解することが難しい場合があります。go_router_builder
も例外ではありません。
Flutter は比較的コードが追いやすいと思いますが、最初はよく分からず、つらい面も多々ありますよね。
僕は SPAJAM で TypedStatefulShellRoute
の沼にハマり、抜け出せませんでした😭
少しでも参考になれば幸いです。
明日の担当は shake さんです!
お楽しみに!!!