Flutterで画面遷移する際にBottomNavigationBarを固定させる実装方法について解説します。
BottomNavigationBarを固定する実装方法について
タブごとにNavigatorを用意し、タブ内のページのみに重なるような形で画面遷移させることで、BottomNavigationBarが消えないようにします。
そもそもBottomNavigationBarが固定されない理由は、画面遷移時にBottomNavigationBarが実装されているページの上に重なってしまうためです。
FlutterではNavigatorという仕組みを使って画面遷移を行なっていますが、一般的なNavigatorの取得方法であるNavigator.of(context)は、引数のcontextを起点にElementのツリーを親の方向に操作して、Navigatorを取得する処理を行なっています。
そのためNavigatorを自前で実装しなければ、この時取得されるNavigatorはMaterialAppのNavigatorになってしまい、画面遷移時にBottomNavigatoinBarが実装されているページの上に重なる形になるため見えなくなってしまいます。(=固定されなくなる)
つまりBottomNavigationBarを固定するためには、タブごとにNavigatorを用意しタブ内のページのみに重なるような形で画面遷移させる必要があります。
画面遷移の実装方法について
それぞれのNavigatorにkeyを持たせて、画面遷移時にkey経由でNavigatorにアクセスして、pushやpopを呼び出して画面遷移させます。
ほとんどのページはタブ内で画面遷移させたいですが、ログインや設定などといったアプリ全体に影響を及ぼすような処理を行う画面は、タブ外に表示させたいケースもあります。
そのために
- 各タブのNavigatorに紐づくkey(tabNavigatorKey)
- MaterialAppのNavigatorに紐づくkey(rootNavigatorKey)
の2つを用意し、
- タブ内に表示したい場合はtabNavigatorKey
- タブ外に表示したい場合はrootNavigatorKey
を使用して、画面遷移を行います。
タブ選択について
StackにPageを重ねて選択中のPageのみ表示することでタブ選択を実装します。
表示 / 非表示の切り替えを行うWidgetは下記の3つがあります。
Opacity
- 不透明度を渡すことで表示切り替えを行います。
- Widgetは透明な状態で表示されることになるため、タップやスクロールなどのWidgetに対する操作を無効にするためには、IgnorePointerで囲む必要があります。
Offstage
- フラグを渡すことで表示切り替えを行います。(表示 = false)
- 状態を維持します。
Visibility
- フラグを渡すことで表示切り替えを行います。(表示 = true)
- Offstageよりも高度な制御を行うことができます。
今回は以下の理由によりOffstageを使用しています。
- 表示非表示の切り替えのみを行いたい
- 状態は維持したい
- 透明度の変更は行わない
ソースコード
以上をもとに実装したソースコードは下記になります。(右記リポジトリにも保存しています: https://github.com/moimoi-prog/fixed_bottom_bar)
import 'package:fixed_bottom_tab/pages/pages.dart';
import 'package:fixed_bottom_tab/routes.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: rootNavigatorKey,
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routes: routes,
home: const RootPage(),
);
}
}
import 'package:fixed_bottom_tab/routes.dart';
import 'package:fixed_bottom_tab/tab_navigator.dart';
import 'package:fixed_bottom_tab/tab_type.dart';
import 'package:flutter/material.dart';
class RootPage extends StatefulWidget {
const RootPage({
super.key,
});
@override
RootPageState createState() => RootPageState();
}
class RootPageState extends State<RootPage> {
TabType currentTabType = TabType.home;
void selectTab(TabType selectTab) {
setState(() {
currentTabType = selectTab;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: <Widget>[
_buildTabItem(
currentTabType,
TabType.home,
),
_buildTabItem(
currentTabType,
TabType.taskList,
),
],
),
bottomNavigationBar: BottomNavigationBar(
items: TabType.values
.map(
(e) => BottomNavigationBarItem(
icon: Icon(e.toIcon),
label: e.toText,
),
)
.toList(),
onTap: (index) {
if (index == currentTabType.index) {
tabNavigatorKeys[currentTabType]!.currentState!.popUntil
.call((route) => route.isFirst);
} else {
selectTab(TabType.values[index]);
}
},
currentIndex: currentTabType.index,
),
);
}
Widget _buildTabItem(
TabType currentTabType,
TabType tabItem,
) {
return Offstage(
offstage: currentTabType != tabItem,
child: TabNavigator(
navigationKey: tabNavigatorKeys[tabItem]!,
tabItem: tabItem,
),
);
}
}
import 'package:fixed_bottom_tab/routes.dart';
import 'package:flutter/material.dart';
enum TabType {
home,
taskList,
}
extension TabTypeExtension on TabType {
String get toText {
switch (this) {
case TabType.home:
return 'ホーム';
case TabType.taskList:
return 'タスク';
}
}
String get toRouteName {
switch (this) {
case TabType.home:
return homeRoute;
case TabType.taskList:
return taskListRoute;
}
}
IconData get toIcon {
switch (this) {
case TabType.home:
return Icons.home_outlined;
case TabType.taskList:
return Icons.task_outlined;
}
}
}
import 'package:fixed_bottom_tab/routes.dart';
import 'package:fixed_bottom_tab/tab_type.dart';
import 'package:flutter/material.dart';
class TabNavigator extends StatelessWidget {
const TabNavigator({
Key? key,
required this.tabItem,
required this.navigationKey,
}) : super(key: key);
final TabType tabItem;
final GlobalKey<NavigatorState> navigationKey;
@override
Widget build(BuildContext context) {
return Navigator(
key: navigationKey,
initialRoute: '/',
onGenerateRoute: (settings) {
if (settings.name == '/') {
// RootPageの各タブの先頭ページの場合
return MaterialPageRoute<Widget>(
builder: (context) {
return routes[tabItem.toRouteName]!(context);
},
);
} else {
return MaterialPageRoute<Widget>(
builder: (context) {
return routes[settings.name!]!(context);
},
settings: RouteSettings(
arguments: settings.arguments,
),
// fullscreenDialog: true,
);
}
},
);
}
}
import 'package:fixed_bottom_tab/pages/pages.dart';
import 'package:fixed_bottom_tab/tab_type.dart';
import 'package:flutter/material.dart';
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
final Map<TabType, GlobalKey<NavigatorState>> tabNavigatorKeys = {
TabType.home: GlobalKey<NavigatorState>(),
TabType.taskList: GlobalKey<NavigatorState>(),
};
const rootRoute = '/';
const homeRoute = '/home';
const accountDetailRoute = '/account/detail';
const settingRoute = '/setting';
const taskListRoute = '/task/list';
const taskDetailRoute = '/task/detail';
Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
homeRoute: (BuildContext context) => const HomePage(),
accountDetailRoute: (BuildContext context) => const AccountDetailPage(),
settingRoute: (BuildContext context) => const SettingPage(),
taskListRoute: (BuildContext context) => const TaskListPage(),
taskDetailRoute: (BuildContext context) => const TaskDetailPage(),
};
List<String> showOutsideTabRouts = [
settingRoute,
];
Future<T?> pushNamed<T extends Object?>(
BuildContext context,
String routeName, {
Object? arguments,
}) {
if (showOutsideTabRouts.contains(routeName)) {
return rootNavigatorKey.currentState!.pushNamed(
routeName,
arguments: arguments,
);
} else {
return Navigator.of(context).pushNamed(
routeName,
arguments: arguments,
);
}
}
export 'account_detail_page.dart';
export 'home_page.dart';
export 'root_page.dart';
export 'setting_page.dart';
export 'task_detail_page.dart';
export 'task_list_page.dart';
import 'package:fixed_bottom_tab/routes.dart';
import 'package:flutter/material.dart';
class HomePage extends StatelessWidget {
const HomePage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ホーム'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextButton(
onPressed: () {
pushNamed(
context,
accountDetailRoute,
);
},
child: const Text('詳細'),
),
TextButton(
onPressed: () {
pushNamed(
context,
settingRoute,
);
},
child: const Text('設定'),
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
class AccountDetailPage extends StatelessWidget {
const AccountDetailPage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('アカウント詳細'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text(
'アカウントの詳細ページです',
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
class SettingPage extends StatelessWidget {
const SettingPage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('設定'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text(
'各種設定ページです',
),
],
),
),
);
}
}
import 'package:fixed_bottom_tab/routes.dart';
import 'package:flutter/material.dart';
class TaskListPage extends StatelessWidget {
const TaskListPage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('タスク一覧'),
),
body: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) {
return Card(
child: ListTile(
title: Text(index.toString()),
onTap: () {
pushNamed(context, taskDetailRoute);
},
),
);
},
),
);
}
}
import 'package:flutter/material.dart';
class TaskDetailPage extends StatelessWidget {
const TaskDetailPage({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('タスク詳細'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text(
'タスク詳細ページです',
),
],
),
),
);
}
}