🛣

【Flutter】 go_routerでTabBarView(+α in BottomNavigationBar)の画面遷移の方法

2023/09/20に公開

はじめに

単にTabBarView内の画面切り替えをするなら、よくある以下サンプルコードみたいなものを使い

class MyDemo extends StatelessWidget {
  const MyDemo({super.key});

  static const List<Tab> myTabs = <Tab>[
    Tab(text: 'LEFT'),
    Tab(text: 'RIGHT'),
  ];

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: myTabs.length,
      child: Scaffold(
        appBar: AppBar(
          bottom: const TabBar(
            tabs: myTabs,
          ),
        ),
        body: TabBarView(
          children: myTabs.map((Tab tab) {
            final String label = tab.text!.toLowerCase();
            return Center(
              child: Text(
                'This is the $label tab',
                style: const TextStyle(fontSize: 36),
              ),
            );
          }).toList(),
        ),
      ),
    );
  }
}

↑ 公式より
https://api.flutter.dev/flutter/material/DefaultTabController-class.html

内部的な仕組みで画面の切り替えをやってくれるため、go_routerを使った遷移コードは特に不要です。

ただ、TabBarViewの外側(別画面、プッシュ通知開封からの遷移やディープリンクなどの外の世界)から遷移して直接特定のタブに遷移(切り替え)する場合はgo_routerで統一して遷移を行うことが可能です。

環境

  • Flutter 3.10.6
  • go_router 10.1.2

方法

※ go_routerの基本的な説明等は割愛します

ネストナビゲーションの維持ができるStatefulShellRouteを使って実現できます

https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html

Similar to ShellRoute, this route class places its sub-route on a different Navigator than the root Navigator.

ShellRoute(ネストナビゲーション内の維持ができない課題があったクラス)と同様に、サブルートの配置ができますが

However, this route class differs in that it creates separate Navigators for each of its nested branches (i.e. parallel navigation trees), making it possible to build an app with stateful nested navigation. This is convenient when for instance implementing a UI with a BottomNavigationBar, with a persistent navigation state for each tab.

ネストされたブランチ(並列のナビゲーションツリー)ごとに別々のNavigatorを作成することができて、タブ内維持(例 BottomNavigationBar)などができる状態を持ったナビゲーションを持つことができます。

サンプル画面遷移図

以下のようなTabBarView in BottomNavigationBar な画面遷移構成で試してます

実現方法

GoRouterの定義は以下のようにしました

import 'package:flutter/material.dart';
import 'package:flutter_playground/sample_pages.dart';
import 'package:flutter_playground/custom_scaffold.dart';
import 'package:go_router/go_router.dart';

final rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'RootNavigator');
final homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'HomeNavigator');
final topNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'TopTabNavigator');
final carTopTabNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'CarTopTabNavigation');
final trainTopTabNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'TrainTopTabNavigation');

final router = GoRouter(
  initialLocation: '/home',
  navigatorKey: rootNavigatorKey,
  routes: [
    StatefulShellRoute(
      builder: (context, state, navigationShell) => navigationShell,
      navigatorContainerBuilder: (context, navigationShell, children) =>
          ScaffoldWithNaviBar(
        navigationShell: navigationShell,
        children: children,
      ),
      branches: [
        StatefulShellBranch(
          navigatorKey: homeNavigatorKey,
          routes: [
            GoRoute(
              path: '/home',
              name: 'home',
              builder: (context, state) => const HomePage(),
              routes: [
                GoRoute(
                  path: 'a',
                  name: 'APage',
                  builder: (context, state) => const APage(),
                  routes: [
                    GoRoute(
                      path: 'b',
                      name: 'BPage',
                      // 途中で親のNavigatorで表示するパターン
                      parentNavigatorKey: rootNavigatorKey,
                      pageBuilder: (context, state) => const MaterialPage(
                        fullscreenDialog: true,
                        child: BPage(),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          navigatorKey: topNavigatorKey,
          routes: [
            StatefulShellRoute(
              builder: (context, state, navigationShell) => navigationShell,
              navigatorContainerBuilder: (context, navigationShell, children) =>
                  ScaffoldWithTabBarView(
                navigationShell: navigationShell,
                children: children,
              ),
              branches: [
                StatefulShellBranch(
                  navigatorKey: carTopTabNavigatorKey,
                  routes: [
                    GoRoute(
                      path: '/top_tab/car',
                      name: 'CarPage',
                      builder: (context, state) => const CarPage(),
                    ),
                  ],
                ),
                StatefulShellBranch(
                  navigatorKey: trainTopTabNavigatorKey,
                  routes: [
                    GoRoute(
                      path: '/top_tab/train',
                      name: 'TrainPage',
                      builder: (context, state) => const TrainPage(),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);

StatefulShellRouteに渡すものとして

  • builder ← 表示したいネストを持つWidget
  • navigatorContainerBuilder ← カスタム用
  • branch ← 各StatefulShellBranch

が必要になります

StatefulShellBranchはStatefulShellRouteの各サブルートとして設定するクラスになります(StatefulShellRouteとセット)

イメージとして

Tab A → Tab A用のStatefulShellBranch
Tab B → Tab B用のStatefulShellBranch

で用意する感じです

A separate Navigator will be built for each StatefulShellBranch in a StatefulShellRoute, and the routes of this branch will be placed onto that Navigator instead of the root Navigator.

また、各StatefulShellBranchごとに個別のNavigatorが構築されます
StatefulShellBranchのroutesにルーティングとして設定したい画面をGoRouteクラスでラップして設定ができます

 StatefulShellBranch(
          navigatorKey: homeNavigatorKey,
          routes: [
            GoRoute(
              path: '/home',
              name: 'home',
              builder: (context, state) => const HomePage(),
              routes: [
                GoRoute(
                  path: 'a',
                  name: 'APage',
                  builder: (context, state) => const APage(),
                  ....

カスタマイズ不要であれば、builder関数にパッケージ側で用意されているStatefulShellRoute.indexedStack(内部的にOffStageで表示したい画面の切り替えてくれる)

https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute/StatefulShellRoute.indexedStack.html

を渡してあげればいいですが、独自の切り替えの仕組みを用意したい場合はnavigatorContainerBuilderで設定できます

サンプル↓
https://github.com/flutter/packages/blob/0023d01996576e494094793a6552463f01c5627a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart#L35-L55

そして、TabBarView in BottomNavigationの場合は以下のような階層で実現できます

GoRouter
 |_ StatefulShellRoute ← BottomNavigationBar用
    |_ StatefulShellBranch(home)
    |_ StatefulShellBranch(topTab)
       |_ StatefulShellRoute ← TabBarView用
          |_ StatefulShellBranch(car)
          |_ StatefulShellBranch(train page)

TabBarViewを外側からの仕組みで変更やStatefulShellBranchで定義したものに適切に切り替えれるように以下のようなカスタムクラスを用意します

class ScaffoldWithTabBarView extends StatelessWidget {
  const ScaffoldWithTabBarView({
    super.key,
    required this.navigationShell,
    required this.children,
  });

  final StatefulNavigationShell navigationShell;
  final List<Widget> children;

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      initialIndex: navigationShell.currentIndex,
      child: Builder(
        builder: (context) {
          final tabController = DefaultTabController.of(context);
          tabController.addListener(() {
            if (tabController.indexIsChanging) {
              navigationShell.goBranch(
                tabController.index,
                initialLocation:
                    tabController.index == navigationShell.currentIndex,
              );
            }
          });

          return Scaffold(
            appBar: AppBar(
              bottom: const TabBar(
                tabs: [
                  Tab(icon: Icon(Icons.directions_car)),
                  Tab(icon: Icon(Icons.directions_transit)),
                ],
              ),
              backgroundColor: Theme.of(context).colorScheme.inversePrimary,
              title: const Text('上タブページ'),
            ),
            body: TabBarView(children: children),
          );
        },
      ),
    );
  }
}

自作クラス内部のコンストラクタで用意しているStatefulNavigationShellクラスはStatefulShellRouteの状態を管理するWidgetです

直接このクラスを作るというより、StatefulShellRouteのbuilderやnavigatorContainerBuilder関数内で渡ってきます

    StatefulShellBranch(
          navigatorKey: topNavigatorKey,
          routes: [
            StatefulShellRoute(
              builder: (context, state, navigationShell) => navigationShell,
              navigatorContainerBuilder: (context, navigationShell, children) => // ←ここ
                  ScaffoldWithTabBarView(
                navigationShell: navigationShell,
                children: children,
              ),

StatefulNavigationShellにgoBranchというメソッドがあり、

https://pub.dev/documentation/go_router/latest/go_router/StatefulNavigationShell/goBranch.html

index指定で遷移するコードがあるため、DefaultTabControllerと組み合わせると以下のような感じでタブ切り替え時の遷移ができます↓

// ScaffoldWithTabBarView classより
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      initialIndex: navigationShell.currentIndex,
      child: Builder(
        builder: (context) {
          final tabController = DefaultTabController.of(context);
          tabController.addListener(() {
            if (tabController.indexIsChanging) {
              navigationShell.goBranch(
                tabController.index,
                initialLocation:
                    tabController.index == navigationShell.currentIndex,
              );
            }
          });

また、遷移したい画面に適切にpathやnameが指定されていればTabBarViewの外側からも以下のように遷移が可能です ↓

// Home画面から別タブ内の特定のTabBarViewへ遷移する例
import 'package:flutter/material.dart';
import 'package:flutter_playground/non_generated_router.dart';
import 'package:go_router/go_router.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('home'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: () => context.goNamed('APage'),
              child: const Text('Aページ遷移'),
            ),
            ElevatedButton(
              onPressed: () => context.goNamed('BPage'),
              child: const Text('Bページ遷移'),
            ),
            ElevatedButton(
              onPressed: () => context.goNamed('TrainPage'), // ←ここで遷移
              child: const Text('topTabのトレインへ遷移'),
            ),
          ],
        ),
      ),
    );
  }
}

ちょっとゆっくりめのgif画像ですが、

TabBarView内の状態をCarタブにする
↓
BottomBarのタブをHomeに切り替え
↓
Home画面内の一番下にあるボタンタップ
↓
BottomBarタブがtopTabに切り替わりつつ、Trainタブが表示されている

という感じが実現可能です

一連のファイルはgistにまとめてます

おわりに

他にもHome画面内でフルスクリーン表示やネストナビゲーションから脱してrootから画面遷移する例も記載しているので参考になればです

何か不適切な内容の記載や誤字脱字等あれば気軽にご指摘ください

参考にしたもの

公式サンプル
https://pub.dev/documentation/go_router/latest/go_router/go_router-library.html
https://github.com/flutter/packages/tree/main/packages/go_router/example

https://www.memory-lovers.blog/entry/2023/07/13/140754

GitHubで編集を提案

Discussion