![[Flutter] go_routerでStatefulShellRouteを使い、上タブ (TabView) と下タブ (BottomNavigationBar) を使いたい](https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-97bd004eb227348cf028ece41fd4689e/b36c0bd625924c92c33ad88396cb5f71/flutter.png)
[Flutter] go_routerでStatefulShellRouteを使い、上タブ (TabView) と下タブ (BottomNavigationBar) を使いたい
こんにちは。きんくまです。
今回は「go_routerでStatefulShellRouteを使い、上タブ (TabView) と下タブ (BottomNavigationBar) を使いたい」です。
作ったもの(動画)
StatefulShellRouteを使っているので、下タブや上タブを移動したとしても、状態が残っていることがわかります。
(普通のShellRouteだとタブを移動すると状態がリセットされます)
参考にしたもの
go_router公式のサンプルコード。この中のStatefulShellRoute関連を手で書いて写して試していました。
特に以下のcustom_stateful_shell_route.dartです
あとはひたすらAIさんに質問したり相談しながら、いろいろと試して作りました。
作ったもの(ソースコード全文)
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MyApp());
}
final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(
debugLabel: "root",
);
final GlobalKey<NavigatorState> _globalTabANavigatorKey =
GlobalKey<NavigatorState>(debugLabel: "globalTabANavigator");
final GlobalKey<NavigatorState> _globalTabBNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: "globalTabBNavigator");
final GlobalKey<NavigatorState> _globalTabBScreenKey =
GlobalKey<NavigatorState>(debugLabel: "globalTabBScreen");
final GlobalKey<NavigatorState> _globalTabCNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: "globalTabCNavigator");
GoRouter _router = GoRouter(
navigatorKey: _rootNavigatorKey,
debugLogDiagnostics: kDebugMode,
initialLocation: '/a',
routes: [
StatefulShellRoute.indexedStack(
builder:
(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return ScaffoldWithNavBar(navigationShell: navigationShell);
},
branches: [
_globalTabARouteBranch,
_globalTabBRouteBranch,
_globalTabCRouteBranch,
],
),
],
);
StatefulShellBranch _globalTabARouteBranch = StatefulShellBranch(
navigatorKey: _globalTabANavigatorKey,
routes: [
GoRoute(
path: '/a',
name: 'globalTabA',
builder: (BuildContext context, GoRouterState state) {
return GlobalTabRootScreen();
},
routes: [
GoRoute(
path: 'details',
name: 'A details',
builder: (BuildContext context, GoRouterState state) {
return DetailsScreen();
},
),
],
),
],
);
StatefulShellBranch _globalTabBRouteBranch = StatefulShellBranch(
navigatorKey: _globalTabBNavigatorKey,
preload: true,
routes: [
StatefulShellRoute(
builder:
(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return navigationShell;
},
navigatorContainerBuilder:
(
BuildContext context,
StatefulNavigationShell navigationShell,
List<Widget> children,
) {
return GlobalTabBScreen(
navigationShell: navigationShell,
key: _globalTabBScreenKey,
children: children,
);
},
branches: [
StatefulShellBranch(
preload: true,
routes: [
GoRoute(
path: '/b/home',
name: 'globalTabBHome',
builder: (BuildContext context, GoRouterState state) {
return GlobalTabBHomeScreen();
},
),
],
),
_globalTabBSearchBranch,
StatefulShellBranch(
preload: true,
routes: [
GoRoute(
path: '/b/profile',
name: 'globalTabBProfile',
builder: (BuildContext context, GoRouterState state) {
return GlobalTabBProfileScreen();
},
),
],
),
],
),
],
);
StatefulShellBranch _globalTabBSearchBranch = StatefulShellBranch(
preload: true,
routes: [
GoRoute(
path: '/b/search',
name: 'globalTabBSearch',
builder: (BuildContext context, GoRouterState state) {
return GlobalTabBSearchScreen();
},
routes: [
GoRoute(
path: 'details',
name: 'globalTabBSearchDetails',
parentNavigatorKey: _globalTabBNavigatorKey,
builder: (BuildContext context, GoRouterState state) {
return DetailsScreen();
},
routes: [
GoRoute(
path: 'blog',
name: 'globalTabBBlogFromSearch',
parentNavigatorKey: _globalTabBNavigatorKey,
builder: (BuildContext context, GoRouterState state) {
return GlobalTabBBlogScreen();
},
),
],
),
],
),
],
);
StatefulShellBranch _globalTabCRouteBranch = StatefulShellBranch(
navigatorKey: _globalTabCNavigatorKey,
routes: [
GoRoute(
path: '/c',
name: 'globalTabC',
builder: (BuildContext context, GoRouterState state) {
return GlobalTabRootScreen();
},
),
],
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.light(),
scaffoldBackgroundColor: Colors.white,
appBarTheme: AppBarTheme(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
tabBarTheme: TabBarThemeData(
labelColor: Colors.blue, // 選択されたタブの色
unselectedLabelColor: Colors.grey, // 選択されていないタブの色
indicatorSize: TabBarIndicatorSize.tab,
indicator: UnderlineTabIndicator(
borderSide: BorderSide(color: Colors.blue, width: 2.0),
insets: EdgeInsets.symmetric(horizontal: 8.0),
),
dividerColor: const Color.fromRGBO(231, 231, 231, 1),
dividerHeight: 1,
),
),
routerConfig: _router,
debugShowCheckedModeBanner: false,
);
}
}
class ScaffoldWithNavBar extends StatelessWidget {
const ScaffoldWithNavBar({
required StatefulNavigationShell navigationShell,
Key? key,
}) : _navigationShell = navigationShell,
super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
final StatefulNavigationShell _navigationShell;
Widget build(BuildContext context) {
return Scaffold(
body: _navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _navigationShell.currentIndex,
onTap: (int index) => _onTap(context, index),
items: [
BottomNavigationBarItem(icon: Icon(Icons.stars), label: 'Tab A'),
BottomNavigationBarItem(
icon: Icon(Icons.flight_takeoff),
label: 'Tab B',
),
BottomNavigationBarItem(icon: Icon(Icons.face), label: 'Tab C'),
],
),
);
}
void _onTap(BuildContext context, int index) {
_navigationShell.goBranch(index);
}
}
class GlobalTabRootScreen extends StatelessWidget {
const GlobalTabRootScreen({super.key});
Widget build(BuildContext context) {
final routeName = GoRouterState.of(context).name;
final uriPath = GoRouterState.of(context).uri.path;
final path = GoRouterState.of(context).uri.path;
return Scaffold(
appBar: AppBar(title: Text('$uriPath')),
body: SizedBox(
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 20),
Text('This is the global tab root screen: $routeName'),
SizedBox(height: 20),
if (path.startsWith('/a') || path.startsWith('/b'))
ElevatedButton(
onPressed: () {
context.go('./details');
},
child: Text('Go to Details'),
),
],
),
),
);
}
}
enum GlobalTabBTopTabType {
home,
search,
profile;
static GlobalTabBTopTabType fromTabIndex(int index) {
if (index < 0 || index > values.length - 1) {
throw ArgumentError('Invalid tab index: $index');
}
return values[index];
}
String get title {
switch (this) {
case GlobalTabBTopTabType.home:
return 'ホーム';
case GlobalTabBTopTabType.search:
return '検索';
case GlobalTabBTopTabType.profile:
return 'プロフィール';
}
}
}
class GlobalTabBScreen extends StatefulWidget {
const GlobalTabBScreen({
required StatefulNavigationShell navigationShell,
required List<Widget> children,
super.key,
}) : _navigationShell = navigationShell,
_children = children;
final StatefulNavigationShell _navigationShell;
final List<Widget> _children;
State<GlobalTabBScreen> createState() => _GlobalTabBScreenState();
}
class _GlobalTabBScreenState extends State<GlobalTabBScreen>
with TickerProviderStateMixin {
late TabController _tabController;
void initState() {
super.initState();
_setUpTabController();
}
void _setUpTabController() {
_tabController = TabController(
initialIndex: widget._navigationShell.currentIndex,
length: GlobalTabBTopTabType.values.length,
vsync: this,
);
_tabController.addListener(_tabControllerDidChange);
}
void _tabControllerDidChange() {
if (_tabController.indexIsChanging) {
// タブの切り替えが始まったとき
// タブをタップしたときのみ呼ばれる
print(
'タブがこれから切り替わります: index ${_tabController.previousIndex} -> ${_tabController.index}',
);
//widget._navigationShell.goBranch(_tabController.index);
} else {
// タブの切り替え完了。
// タブをタップしたときも、スワイプで切り替えた場合も呼ばれる
print(
'タブが切り替わりました: ${GlobalTabBTopTabType.fromTabIndex(_tabController.index)}, 現在のindex: ${_tabController.index}. 前のindex ${_tabController.previousIndex}',
);
}
if (_tabController.index != widget._navigationShell.currentIndex) {
widget._navigationShell.goBranch(_tabController.index);
}
}
void dispose() {
_tabController.removeListener(_tabControllerDidChange);
_tabController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: TabBarView(controller: _tabController, children: widget._children),
);
}
AppBar _buildAppBar(BuildContext context) {
final uriPath = GoRouterState.of(context).uri.path;
return AppBar(
title: Text('タブ画面 $uriPath'),
toolbarHeight: 60,
bottom: PreferredSize(
preferredSize: Size.fromHeight(44),
child: Container(
color: Colors.white, // タブの背景色
child: TabBar(
controller: _tabController,
onTap: (index) {
print('タブがタップされたよ: $index');
widget._navigationShell.goBranch(index);
},
tabs: [
// アイコンバージョン
// Tab(icon: Icon(Icons.home)),
// Tab(icon: Icon(Icons.search)),
// Tab(icon: Icon(Icons.person)),
// タイトルバージョン
Tab(text: GlobalTabBTopTabType.home.title),
Tab(text: GlobalTabBTopTabType.search.title),
Tab(text: GlobalTabBTopTabType.profile.title),
],
),
),
),
);
}
void didUpdateWidget(covariant GlobalTabBScreen oldWidget) {
super.didUpdateWidget(oldWidget);
print('didupdateWidget called');
if (_tabController.index != widget._navigationShell.currentIndex) {
print(
'タブのインデックスが変更されました: ${_tabController.index} -> ${widget._navigationShell.currentIndex}',
);
_tabController.index = widget._navigationShell.currentIndex;
}
}
}
class GlobalTabBHomeScreen extends StatelessWidget {
const GlobalTabBHomeScreen({super.key});
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.home, size: 64, color: Colors.blue),
SizedBox(height: 16),
Text(
'ホーム画面',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
);
}
}
class GlobalTabBSearchScreen extends StatelessWidget {
const GlobalTabBSearchScreen({super.key});
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: Colors.green),
SizedBox(height: 16),
Text(
'検索画面',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.go('./details');
},
child: Text('go to details'),
),
],
),
);
}
}
class GlobalTabBProfileScreen extends StatelessWidget {
const GlobalTabBProfileScreen({super.key});
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person, size: 64, color: Colors.orange),
SizedBox(height: 16),
Text(
'プロフィール画面',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
);
}
}
class GlobalTabBBlogScreen extends StatelessWidget {
const GlobalTabBBlogScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('ブログ画面')),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.article, size: 64, color: Colors.purple),
SizedBox(height: 16),
Text(
'ブログ画面',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
);
}
}
class DetailsScreen extends StatefulWidget {
const DetailsScreen({super.key});
State<DetailsScreen> createState() => _DetailsScreenState();
}
class _DetailsScreenState extends State<DetailsScreen> {
int _counter = 0;
Widget build(BuildContext context) {
final routePath = GoRouterState.of(context).uri.path;
return Scaffold(
appBar: AppBar(title: Text('Details $routePath')),
body: SizedBox(
width: double.infinity,
child: Column(
children: [
SizedBox(height: 20),
Text('Counter: $_counter'),
ElevatedButton(
onPressed: () {
setState(() {
_counter++;
});
},
child: Text('Increment'),
),
SizedBox(height: 20),
if (routePath == '/b/search/details')
ElevatedButton(
onPressed: () {
context.go('/b/search/details/blog');
},
child: Text('Go to Blog from Search'),
),
],
),
),
);
}
}
解説
上タブ(TabView)
上タブ(TabView)については前回の記事を参考にしてください
これに公式のサンプルコードをみながら、navigationShellのメソッドを追加していきました。
下タブ(BottomNavigationBar)
これはほぼ公式のサンプル通りです。
一番苦労したところ
(Stateful)ShellRoute系は、移動したときにShell部分が残った形で画面が遷移します。
つまり、下タブだったら、下タブが見えてて隠れないまま、中身のコンテンツが切り替わる
上タブだったら、ヘッダタイトルと上タブが見えてて隠れないまま、中身のコンテンツが切り替わる
となります。
下タブだったら問題ないのですが、上タブだと何もしないとこうなります。
![]() |
![]() |
これをきれいにするためにparentNavigatorKeyを設定します
routes: [
GoRoute(
path: 'details',
name: 'globalTabBSearchDetails',
parentNavigatorKey: _globalTabBNavigatorKey, //これ
builder: (BuildContext context, GoRouterState state) {
return DetailsScreen();
},
routes: [
GoRoute(
path: 'blog',
name: 'globalTabBBlogFromSearch',
parentNavigatorKey: _globalTabBNavigatorKey, //これ
builder: (BuildContext context, GoRouterState state) {
return GlobalTabBBlogScreen();
},
),
],
),
],
ポイントはこれに割り当てているGlobalKeyです。自分の親のさらに親ぐらいのものを割り当ててます。
公式サンプルのShelRouteが一番単純化されててわかりやすいです。
うまくいくとこのようになります。
![]() |
![]() |
parentNavigatorKeyを使えばBottomNavigationBarも下タブを隠して遷移することが可能です。さきほどの公式のShellRouteのサンプルがそうなっています。
感想
BottomNavigationBarのところは大丈夫だったのですが、TabBarをからめたりするところと、parentNavigatorKeyまわりがよくわからず、いろいろと試しながらやったので大変でした。
もし参考になったらうれしいです。