[Flutter] go_routerでStatefulShellRouteを使い、上タブ (TabView) と下タブ (BottomNavigationBar) を使いたい

[Flutter] go_routerでStatefulShellRouteを使い、上タブ (TabView) と下タブ (BottomNavigationBar) を使いたい

Clock Icon2025.07.01

こんにちは。きんくまです。
今回は「go_routerでStatefulShellRouteを使い、上タブ (TabView) と下タブ (BottomNavigationBar) を使いたい」です。

作ったもの(動画)

https://www.youtube.com/shorts/3loHzEkD0f8

StatefulShellRouteを使っているので、下タブや上タブを移動したとしても、状態が残っていることがわかります。

(普通のShellRouteだとタブを移動すると状態がリセットされます)

参考にしたもの

go_router公式のサンプルコード。この中のStatefulShellRoute関連を手で書いて写して試していました。

https://github.com/flutter/packages/tree/main/packages/go_router/example/lib

特に以下のcustom_stateful_shell_route.dartです

https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/others/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)については前回の記事を参考にしてください

https://dev.classmethod.jp/articles/creating-top-tabs-with-tabbar-in-flutter/

これに公式のサンプルコードをみながら、navigationShellのメソッドを追加していきました。

下タブ(BottomNavigationBar)

これはほぼ公式のサンプル通りです。

一番苦労したところ

(Stateful)ShellRoute系は、移動したときにShell部分が残った形で画面が遷移します。
つまり、下タブだったら、下タブが見えてて隠れないまま、中身のコンテンツが切り替わる
上タブだったら、ヘッダタイトルと上タブが見えてて隠れないまま、中身のコンテンツが切り替わる

となります。

下タブだったら問題ないのですが、上タブだと何もしないとこうなります。

250701_go_router1 250701_go_router2

これをきれいにするために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が一番単純化されててわかりやすいです。

https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/shell_route.dart

うまくいくとこのようになります。

250701_go_router3 250701_go_router4

parentNavigatorKeyを使えばBottomNavigationBarも下タブを隠して遷移することが可能です。さきほどの公式のShellRouteのサンプルがそうなっています。

感想

BottomNavigationBarのところは大丈夫だったのですが、TabBarをからめたりするところと、parentNavigatorKeyまわりがよくわからず、いろいろと試しながらやったので大変でした。
もし参考になったらうれしいです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.