iOS版のTwitterなどで使われているお馴染みのUIです。ググれば様々な実装方法が出てきますが、たくさんあって混乱するので一度ここにまとめたいと思います。
1. CupertinoTab系のWidgetを使う
FlutterにはiOS風のUIを再現するためにCupertino*と名のつくWidget群が用意されており、その中のCupertinoTabScaffoldやCupertinoTabBar等を使うと固定タブバーが実現できます。この方法については以下の記事が詳しいです。
また公式ドキュメントにはブラウザで動かせるインタラクティブな実装例が掲載されているのでオススメです。
ただしこの方法だとCupertinoTabBar以外のタブバーが使えません。BottomNavigationBarやanimated_bottom_navigation_bar、curved_navigation_barのようなサードパーティー製のタブバーなどを使用したい場合は他の手法を選択する必要があります。
2. persistent_bottom_nav_barを使う
読んで字の如しなパッケージがあります。タブバーの見た目にはいくつか選択肢があり、かなり柔軟に使用できそうです。また拡張することで自分好みのスタイルも作ることができるようです。
3. go_routerを使う
go_routerはNavigator2.0を簡単に利用できるルーティングライブラリです。go_routerではShellRouteによる階層的なナビゲーションもサポートされており、これにより固定タブバーの永続化が実現できます。日本語記事では
あたりが分かりやすいかと思います。公式にもサンプルが用意されているため、go_routerをすでに使用している場合はこれを利用するのが一番楽かもしれません。
ただし、現状ShellRouteは各タブの状態を保持してくれません。例えばホームタブで作業したあと検索タブに遷移し、その後ホームタブに再び戻ると作業状態がリセットされてしまいます。
この問題についてはすでにGitHub上でissueが挙がっており、現在はプルリクエストのマージ待ちのようです。
追記(2023/5/27)
マージされたようですね。v7.1から使えるそうです。
4. navigator_scopeを使う
宣伝です。このために記事を書いたようなものです。前述のCupertinoTab*で実装されている入れ子のナビゲーション機能を抜き出し、より一般化してパッケージにまとめました。
とにかくシンプルなことが売りです。Navigator2.0は複雑すぎ、Navigation.pushしてNavigation.popするだけで十分、という場合におすすめです。新しいAPIを学ぶ必要もありません。またBottomNavigationBarだけでなく、NavigationBarやNavigationDrawer(横から飛び出すメニュー)、さらにはanimated_bottom_navigation_barやcurved_navigation_barなどの著名なサードパーティー製のタブバーも自由に利用できます。
使い方は簡単で、NavigatorScopeの中で各タブのNestedNavigatorを作るだけです。もちろん画面遷移の前後で各タブの状態は保持されます。
Scaffold(
bottomNavigationBar: NavigationBar( // お好きなタブバーをひとつ
selectedIndex: selectedTabIndex,
destinations: tabs,
onDestinationSelected: (index) {
setState(() => selectedTabIndex = index);
},
...
),
body: NavigatorScope( // ローカルなNavigatorのハブとなるWidget
currentDestination: selectedTabIndex, // 現在アクティブなタブのインデックス
destinationCount: tabs.length,
destinationBuilder: (context, index) {
return NestedNavigator( // ローカルなNavigator
// このNavigatorのデフォルトのページを作成する
builder: (context) => Container(),
);
},
),
);
タブを切り替えるときはcurrentDestinationの値を変えてNavigatorScopeをrebuildするだけです。タブ内で画面遷移する場合は通常通りNavigator.pushやNavigator.popを使用します。詳しくはREADMEをご覧ください。
5. 自前で実装する
最終手段です。Deep linkingやらNavigator2.0やらを考慮しだすと複雑になりますが、基本的な部分だけを抜き出してみると案外簡単に実装できます。
CupertinoTab*の実装を理解する
前述の通り、CupertinoTab*系のWidget群はデフォルトで固定タブバーの機能を提供しています。特に重要なのはCupertinoTabViewとCupertinoTabScaffoldで、前者はタブのNavigatorを内部で保持し、後者は各タブの切り替えとそのNavigatorの状態保持を担当しています。Widgetツリーのイメージとしては下のような感じです。
CupertinoTabScaffold
└ _TabSwitchingView
├ CupertinoTabView
│ └ Navigator
├ CupertinoTabView
...
_TabSwitchingViewはCupertinoTabScaffold内で使われているプライベートなクラスで、Navigatorの切り替えや状態保持を実質的に担当しているWidgetです。またこれらは全てStatefulWidgetとして定義されているので、以後「CupertinoTabViewのbuildメソッド」のような表記がされていた場合は「対応するStateクラスのbuildメソッド」と読み替えてください。
CupertinoTabView
細かい話を抜きにすれば、CupertinoTabViewがしていることはNavigatorを1つ保持する、ただそれだけです。実際buildメソッドは以下のようにとてもシンプルな実装です(ソースコード)。
// _CupertinoTabViewState
@override
Widget build(BuildContext context) {
return Navigator(
key: widget.navigatorKey,
onGenerateRoute: _onGenerateRoute,
onUnknownRoute: _onUnknownRoute,
observers: _navigatorObservers,
restorationScopeId: widget.restorationScopeId,
);
}
初期ページを作成したり新しいページがNavigator.pushNamedされたりするとonGenerateRouteが呼ばれます。ここで作成されたRoute(デフォルトではCupertinoPageRoute)がページスタックに積まれることになるのですが、今回は関係ないので割愛します。
CupertinoTabScafold
各タブのNavigator(CupertinoTabView)はCupertinoTabScaffold内の_TabSwitchingViewが管理しています。CupertinoTabScaffoldのbuildメソッドを見てみましょう(ソースコード)。重要な部分以外は省略しました。_controller.indexが現在アクティブなタブのインデックスを表しており、これが変わるとsetStateでrebuildが走るように実装されています。また各Nvigatorはwidget.tabBuilderを通して_TabSwitchingViewに渡されます。
// _CupertinoTabScaffoldState
@override
Widget build(BuildContext context) {
...
Widget content = _TabSwitchingView(
currentTabIndex: _controller.index, // これが変わるとrebuild
tabCount: widget.tabBar.items.length,
tabBuilder: widget.tabBuilder, // 各タブのCupertinoTabViewを作成するbuilder
);
...
return DecoratedBox(
...
child: Stack(
children: <Widget>[
content,
...
],
),
);
}
最後に_TabSwitchingViewのbuildメソッドも見てみましょう(ソースコード)。こちらも至ってシンプルです。ネストが少々深いですが、要点は「前述のtabBuilderが作るCupertinoTabViewをOffstageで囲み、Stackで重ねる」ことです。Offstageを使うのは非アクティブなタブを非表示にするためです。HeroModeやTickerModeにも囲まれていますが、これは非表示タブのアニメーションを無効化するためです。親のCupertinoTabScaffoldに巻き込まれてrebuildされると各タブのactive = index == widget.currentTabIndex;の値が更新され、タブが切り替わります。
// _TabSwitchingViewState
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: List<Widget>.generate(widget.tabCount, (int index) {
// 各タブがアクティブかどうか?
final bool active = index == widget.currentTabIndex;
shouldBuildTab[index] = active || shouldBuildTab[index];
return HeroMode(
enabled: active,
child: Offstage(
offstage: !active, // 非アクティブなタブは非表示
child: TickerMode(
enabled: active,
child: FocusScope(
node: tabFocusNodes[index],
child: Builder(builder: (BuildContext context) {
return shouldBuildTab[index] // 一度も表示してないタブはbuildしない
? widget.tabBuilder(context, index) // TabViewを作る
: const SizedBox.shrink(); // プレースホルダー
}),
),
),
),
);
}),
);
}
}
色々と細かい話を省略しましたが、基本的にはNavigatorをOffstageで包み、Stackで重ねることで入れ子になったナビゲーションを実現できます。思いの外シンプルですね。これを一般化してまとめたのがnavigator_scopeです。
Flutter公式による実装例
公式ドキュメントにはNavigationBarを使用した実装例が掲載されています。実装の方針自体は前述のOffstageとStackを用いたものと大差ないですが、ブラウザ上で実際に動かせるので一見の価値があると思います。
まとめ
最後に雑ですが個人的フローチャートを書き残しておきます。
- go_routerを使っている 👉
ShellRoute(状態保存はプルリクエストのマージ待ち) - iOS風のUIを作りたい 👉
CupertinoTabScaffoldとその仲間たち -
Cupertinoは嫌だ 👉 persistent_bottom_nav_bar - とにかくシンプルが良い&好きなタブバーを使いたい 👉 navigator_scope(使ってみてネ)
- 全部嫌だ 👉 自炊🍚
終わり!
