FlutterでBottomNavigationBarを表示したまま下層ページを画面遷移する 2021.08.25
以下のサンプルをデフォルトアプリの状態から手順で構築したときの記録。
- https://github.com/bizz84/nested-navigation-demo-flutter
- https://codewithandrea.com/articles/multiple-navigators-bottom-navigation-bar/
元記事では関数やMapを使って重複を最適化しているが、私のような初心者にはわかりにくかったのでこの記事ではあえてベタに書いている。
DartPad
環境構築
Flutterアプリを作成。
% flutter create flutter_nested_navigation_trial
% cd flutter_nested_navigation_trial
デフォルトサンプルから使わないものを削除。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const App(),
);
}
}
class App extends StatefulWidget {
const App({Key? key}) : super(key: key);
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text(
'Main',
),
],
),
),
);
}
}
BottomNavigationBarを追加
形だけBottomNavigationBarを追加。
class _AppState extends State<App> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
// ...
),
+ bottomNavigationBar: const BottomNavigation(),
);
}
}
class BottomNavigation extends StatelessWidget {
const BottomNavigation({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: const [
BottomNavigationBarItem(
icon: Icon(
Icons.layers,
color: Colors.red,
),
label: 'red',
),
// ... 以下同様
],
currentIndex: 0,
selectedItemColor: Colors.grey,
);
}
}
設定を切り出す
色は各所で使い回すので、サンプルと同じく切り出す。
enum TabItem { red, green, blue }
const Map<TabItem, String> tabName = {
TabItem.red: 'red',
TabItem.green: 'green',
TabItem.blue: 'blue',
};
const Map<TabItem, MaterialColor> activeTabColor = {
TabItem.red: Colors.red,
TabItem.green: Colors.green,
TabItem.blue: Colors.blue,
};
切り出した設定を使うようにBottomNavigationBarを修正。
class BottomNavigation extends StatelessWidget {
// ...
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: Icon(
Icons.layers,
color: activeTabColor[TabItem.red],
),
label: tabName[TabItem.red],
),
// ... 以下同様
],
// ...
);
}
}
タブの状態を保持し、更新できるようにする
変数とコールバックを定義してBottomNavigationBarに渡す。
class AppState extends State<App> {
// 現在のタブを保持する変数定義と初期値
var _currentTab = TabItem.red;
// タブの更新
void _selectTab(TabItem tabItem) {
setState(() => _currentTab = tabItem);
}
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
);
}
}
class BottomNavigation extends StatelessWidget {
const BottomNavigation({
Key? key,
required this.currentTab,
required this.onSelectTab,
}) : super(key: key);
final TabItem currentTab;
final void Function(TabItem) onSelectTab;
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
// ...
onTap: (index) => onSelectTab(TabItem.values[index]),
currentIndex: currentTab.index,
selectedItemColor: activeTabColor[currentTab]!,
);
}
}
これで、アクティブなタブアイテムがフォーカスされるようになった。
ValueChanged
onSelectTab
の型であるValueChangedが何なのか気になったが、コールバック関数らしい。
以下は同値らしく、JavaScriptを使っていた身としては下のほうがなじみやすいので変えてみた。
final ValueChanged<TabItem> onSelectTab;
final void Function(TabItem) onSelectTab;
ColorListPageを追加
まずは、Stackを追加して3種類のページを表示してみる。Z軸上に重ね合わせるので、最後に配置された青のリストだけが表示され、ほかのページは下に隠れてしまう。
class _AppState extends State<App> {
// ...
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: <Widget>[
ColorsListPage(
color: activeTabColor[TabItem.red]!,
title: tabName[TabItem.red]!,
onPush: (context) {}, // あとで
),
// ... 以下同様
]),
// ...
);
}
}
ColorListPageウィジェットはサンプル通りで、今後変更することもない。
Offstageで重ね合わせを解決する
Stackで重ね合わせただけではうまく行かなかったので、Offstageを追加してページの表示・非表示を制御する。
A widget that lays the child out as if it was in the tree, but without painting anything, without making the child available for hit testing, and without taking any room in the parent.
子をツリー内にあるかのようにレイアウトするウィジェットですが、何もペイントせず、子をヒットテストに使用できるようにすることも、親にスペースをとることもありません。
公式の説明はよくわからないが、
OffstageはレイアウトWidgetであり、表示/非表示を設定したいWidgetをchildに入れる。
ということらしい。
class _AppState extends State<App> {
// ...
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: <Widget>[
Offstage(
offstage: _currentTab != TabItem.red,
child: ColorsListPage(
color: activeTabColor[TabItem.red]!,
title: tabName[TabItem.red]!,
onPush: (context) {}, // あとで
),
),
// ... 以下同様
]),
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
);
}
}
Navigatorを追加
Navigatorウィジェットを返すTabNavigatorウィジェットを追加。サンプルとほぼ同じ。辞書的なbuilderが最初はわかりにくかったので、switch文で書き直してみた。
class TabNavigatorRoutes {
static const String root = '/';
static const String detail = '/detail';
}
class TabNavigator extends StatelessWidget {
const TabNavigator({
Key? key,
required this.navigatorKey,
required this.tabItem,
}) : super(key: key);
final GlobalKey<NavigatorState>? navigatorKey;
final TabItem tabItem;
void _push(BuildContext context, {int materialIndex = 500}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return ColorDetailPage(
color: activeTabColor[tabItem]!,
title: tabName[tabItem]!,
materialIndex: materialIndex,
);
},
),
);
}
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
initialRoute: TabNavigatorRoutes.root,
onGenerateRoute: (routeSettings) {
return MaterialPageRoute(
builder: (context) {
switch (routeSettings.name) {
case TabNavigatorRoutes.detail:
return ColorDetailPage(
color: activeTabColor[tabItem]!,
title: tabName[tabItem]!,
);
default:
return ColorsListPage(
color: activeTabColor[tabItem]!,
title: tabName[tabItem]!,
onPush: (materialIndex) => _push(context, materialIndex: materialIndex),
);
}
},
);
},
);
}
}
MaterialPageRoute
A modal route that replaces the entire screen with a platform-adaptive transition. For Android, the entrance transition for the page slides the route upwards and fades it in. The exit transition is the same, but in reverse.
画面全体をプラットフォームに適合したトランジションに置き換えるモーダルなルート。Androidの場合、ページに入る時は上にスライドさせてフェードインします。出る時も同じですが、方向が逆になります。
トランジションの原点になるウィジェット。NavigatorのonGenerateRouteで返却する。builderでcontextに応じて子ウィジェットを返す。
Navigatorに対応する状態を追加
AppStateにNavigatorStateを追加して、下層ページがNavigator(前項で追加したTabNavigator)を経由するように変更。
class _AppState extends State<App> {
var _currentTab = TabItem.red;
final _navigatorKeys = {
TabItem.red: GlobalKey<NavigatorState>(),
TabItem.green: GlobalKey<NavigatorState>(),
TabItem.blue: GlobalKey<NavigatorState>(),
};
void _selectTab(TabItem tabItem) {
setState(() => _currentTab = tabItem);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: <Widget>[
Offstage(
offstage: _currentTab != TabItem.red,
child: TabNavigator(
navigatorKey: _navigatorKeys[TabItem.red],
tabItem: TabItem.red,
),
),
// ... 以下同様
]),
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
);
}
}
ColorDetailPageウィジェットはサンプル通りで、今後変更することもない。
GlobalKey
Global keys uniquely identify elements. Global keys provide access to other objects that are associated with those elements, such as BuildContext. For StatefulWidgets, global keys also provide access to State.
グローバルキーは一意にelementを特定する。グローバルキーは、BuildContextなど、elementに関連付けられている他のオブジェクトへのアクセスを提供します。 StatefulWidgetsの場合、グローバルキーはStateへのアクセスも提供します。
あまり深い理解はできていないが、これでStateを特定できるようだ。
基本的にはここまででサンプルと同じものが構成できた。
WillPopScopeを追加
Androidで、タブアイテムがredでないときはBackボタンがredへ戻るようにする。redにいるときはアプリがバックグラウンドになる。これがないと、ColorDetailPageからいきなりバックグラウンドになる。(手元ではHot restart時しか再現できなかったが…)
class AppState extends State<App> {
// ...
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final isFirstRouteInCurrentTab =
!await _navigatorKeys[_currentTab]!.currentState!.maybePop();
if (isFirstRouteInCurrentTab) {
// if not on the 'main' tab
if (_currentTab != TabItem.red) {
// select 'main' tab
_selectTab(TabItem.red);
// back button handled by app
return false;
}
}
// let system handle back button if we're on the first route
return isFirstRouteInCurrentTab;
},
child: Scaffold(
body: Stack(children: <Widget>[
// ...
]),
bottomNavigationBar: BottomNavigation(
// ...
),
),
);
}
// ...
}
WillPopScope
前の画面に戻らせたくない時に使う
画面の戻るボタンが押された際に値を返す
Future.value(true)を返してしまうと、pop関数が2度実行され、遷移元の画面もpopされてしまいます。
戻るときに何かを実行したい場合は、onWillPop
コールバックで処理を書いてからfalse
を返す。
戻る動作自体を止めたい場合はonWillPop
コールバックでtrue
を返す。