September 30, 2024

【Flutter】Bottom Navigation BarでGo router builderを使った時の共通画面をどうするか

go_router_builder で(Bottom) Navigation Barを扱う際のRoute指定をどのようにするのが良いのかを考えました。
バージョンは現時点での最新2.7.1で試しています。
(普段go_routerは使っていないので、もしかしたらもっと簡単にできたり、間違っている可能性もあります)

そもそもなんでこんなことを考える必要があるのかというと、 Flutterは元々Navigatorと呼ばれる、従来のモバイルアプリでよく扱われているスタック形式で画面遷移を実現していたのですが(今でも使えます)、Router(通称Navigator2.0)の登場によりパスをあらかじめ設定して、意図したパスに遷移させる方式に変わりました。 そのRouterが、モバイルアプリでよく使われているNavigation Bar の挙動と相性が悪いことが原因です。
Routerの登場は、Flutter WEBの登場により挙動を揃えるための進化だと思っていますが、個人的にはモバイルアプリだけなら以前のNavigatorでも問題ないと思っています。 ただ、時代の流れだったりDeepLinkの遷移が楽になるなどのメリットもあるため、今回はgo_router_builderでNavigation Barを扱う方法を考えます。

画面構成

StatefulNavigationShell を取って、Bottom Navigation Barを持つページを便宜上 root_page と置いています。 タブは homesettings の2つを用意して、画面に依らないページを detail_page, loop_page とします。

Bottom nav example
.
├── detail
│   └── detail_page.dart
├── loop
│   └── loop_page.dart
└── root
    ├── home
    │   └── home_page.dart
    ├── root_page.dart
    └── settings
        └── settings_page.dart

実装

前述の通りRouterとの相性問題もあってか、元々はgo_routerでのNavigation Barの実装は面倒でしたが、2023年5月くらいにリリースされた7.1.0 でNavigation BarのState管理が楽になりました。今回は StatefulShellRouteTypedStatefulShellBranch を使って実装します。

  • root_page

root_pageはStatefulNavigationShellを渡して、タブを切り替えています。

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

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: navigationShell.currentIndex,
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings),
            label: 'Settings',
          ),
        ],
        onDestinationSelected: _goBranch,
      ),
    );
  }

  void _goBranch(int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}
  • home_page
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  static const String pagePath = '/home';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('home'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text(
            'to detail',
          ),
          onPressed: () {
          },
        ),
      ),
    );
  }
}
  • settings_page
class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  static const String pagePath = '/settings';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('settings'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text(
            'to detail',
          ),
          onPressed: () {
          },
        ),
      ),
    );
  }
}
  • router

タブ内の遷移は一旦省略しています。

part 'router.g.dart';

final router = GoRouter(
  routes: $appRoutes,
  initialLocation: HomePage.pagePath,
  debugLogDiagnostics: true,
);

@TypedStatefulShellRoute<RootRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<_HomePageData>(
      routes: [
        TypedGoRoute<HomeRouteData>(
          path: HomePage.pagePath,
        ),
      ],
    ),
    TypedStatefulShellBranch<_SettingsPageData>(
      routes: [
        TypedGoRoute<SettingsRouteData>(
          path: SettingsPage.pagePath,
        ),
      ],
    ),
  ],
)
class RootRouteData extends StatefulShellRouteData {
  const RootRouteData();

  @override
  Widget builder(
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigationShell,
  ) {
    return RootPage(navigationShell: navigationShell);
  }
}

class _HomePageData extends StatefulShellBranchData {
  const _HomePageData();
}

class HomeRouteData extends GoRouteData {
  const HomeRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) => const HomePage();
}

class _SettingsPageData extends StatefulShellBranchData {
  const _SettingsPageData();
}

class SettingsRouteData extends GoRouteData {
  const SettingsRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      const SettingsPage();
}
  • main

生成したrouterをMaterialAppにセットします。

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerDelegate: router.routerDelegate,
      routeInformationParser: router.routeInformationParser,
      routeInformationProvider: router.routeInformationProvider,
    );
  }
}

State管理

上記のコードはroot_pageに用意したタブを押して、TypedStatefulShellRoute を使って定義したRouteの HomeRouteDataSettingsRouteData によって画面を切り替えています。

Statefulの名の通りhome_pageとsettings_pageは状態が保持されます。
そのためBottom Navigation Barのボタンを押してタブを切り替えてもrebuildはされず同じインスタンスが利用されます。

タブから遷移した画面はどうなるでしょうか?

detail_pageという画面を用意します。状態遷移をわかりやすくするため、中身はFlutterデフォルトのCounter pageにします。

  • detail_page
class DetailPage extends StatefulWidget {
  const DetailPage({super.key});

  static const String pagePath = 'detail';

  @override
  State<DetailPage> createState() => _DetailPageState();
}

class _DetailPageState extends State<DetailPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Detail page'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

パターン1

パターン1は、HomeRouteのみにDetailRouteを定義します。

  • router

routerを書き換えます。 DetailRouteDataを作成してHomeのroutesに追加します。
Settingsのroutesには追加しません。(正確には自動生成されるコードで名前被りになってしまうため追加できません)

@TypedStatefulShellRoute<RootRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<_HomePageData>(
      routes: [
        TypedGoRoute<HomeRouteData>(
          path: HomePage.pagePath,
          routes: [
            TypedGoRoute<DetailRouteData>(path: DetailPage.pagePath),
          ],
        ),
      ],
    ),
    TypedStatefulShellBranch<_SettingsPageData>(
      routes: [
        TypedGoRoute<SettingsRouteData>(
          path: SettingsPage.pagePath,
          routes: [
          ],
        ),
      ],
    ),
  ],
)
class RootRouteData extends StatefulShellRouteData {
  const RootRouteData();

  @override
  Widget builder(
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigationShell,
  ) {
    return RootPage(navigationShell: navigationShell);
  }
}


class DetailRouteData extends GoRouteData {
  const DetailRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
  • home_page

ボタンを押したらDetailRouteDataを使って詳細画面に遷移します。

...
    onPressed: () {
      const DetailRouteData().go(context);
    },
...
  • settings_page

同様にこちらもDetailRouteDataから詳細画面に遷移します。

...
    onPressed: () {
      const DetailRouteData().go(context);
    },
...

Homeタブからdetail_pageに遷移してインクリメントします。その後、Settingsタブからdetail_pageに遷移します。
そうすると、Homeからの遷移でクリックした状態がSettingsから遷移した時にも保持されています。

pattern1.gif

からくりとしては、RouterにHomeからのパスのみを宣言しているので、Settingsから遷移した場合も実際には /home/detail に遷移していることになります。

パターン2

パターン1は意図している動作ではありません。 詳細画面は画面に依らないページになるので、別でルートを定義したらどうなるでしょうか?

  • router

今度はTypedStatefulShellBranch 内に定義せずに、TypedGoRoute を使って別で定義します。


@TypedStatefulShellRoute<RootRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<_HomePageData>(
      routes: [
        TypedGoRoute<HomeRouteData>(
          path: HomePage.pagePath,
          routes: [
          ],
        ),
      ],
    ),
    TypedStatefulShellBranch<_SettingsPageData>(
      routes: [
        TypedGoRoute<SettingsRouteData>(
          path: SettingsPage.pagePath,
          routes: [
          ],
        ),
      ],
    ),
  ],
)
class RootRouteData extends StatefulShellRouteData {
  const RootRouteData();

  @override
  Widget builder(
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigationShell,
  ) {
    return RootPage(navigationShell: navigationShell);
  }
}

@TypedGoRoute<DetailRouteData>(
  path: DetailPage.pagePath,
)
class DetailRouteData extends GoRouteData {
  const DetailRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
  • detail_page

この場合はパスを / から始める必要があるので注意してください。

...
  static const String pagePath = '/detail';
...
  • home_page

pushに書き換えます。

...
    onPressed: () {
      const DetailRouteData().push<void>(context);
    },
...
  • settings_page
...
    onPressed: () {
      const DetailRouteData().push<void>(context);
    },
...

今度は状態は保存されなくなったもののタブが消えました。 からくりとしては、Bottom Navigation Barの表示されているルートは / から始まる /home, /settings になっていますが、今回の定義では /detail から始まる別のルートになっています。 そのため、root_pageに定義されているBottom Navigation barは表示されず、毎回 /detail にアクセスし直すため状態も保持されずアクセスの度に生成し直されています。

pattern2.gif

パターン3

場合によっては詳細画面などはタブを覆い隠す形で表示する時もありますが、パターン2はタブを使う旨味が消えてしまいます。
(今回settingsという名前でタブを作ってしまったのですが、どちらかというと設定画面などがタブに依らない画面表示をすることが多いかなと思います)

そこで、タブのRoute上で開きながら共通の画面を表示するパターンを考えます。
結論から言うとタブごとにrouterに別々のルート定義をします。
冗長ですが、それぞれの遷移方法のパスを定義するのは仕方ないのかなと思います。 (もしかしたらベストプラクティスがあるかもしれません 🙇)

  • router

@TypedStatefulShellRoute<RootRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<_HomePageData>(
      routes: [
        TypedGoRoute<HomeRouteData>(
          path: HomePage.pagePath,
          routes: [
            TypedGoRoute<HomeDetailRouteData>(path: DetailPage.pagePath),
          ],
        ),
      ],
    ),
    TypedStatefulShellBranch<_SettingsPageData>(
      routes: [
        TypedGoRoute<SettingsRouteData>(
          path: SettingsPage.pagePath,
          routes: [
            TypedGoRoute<SettingsDetailRoute>(path: DetailPage.pagePath),
          ],
        ),
      ],
    ),
  ],
)
class RootRouteData extends StatefulShellRouteData {
  const RootRouteData();

  @override
  Widget builder(
    BuildContext context,
    GoRouterState state,
    StatefulNavigationShell navigationShell,
  ) {
    return RootPage(navigationShell: navigationShell);
  }
}

class _HomePageData extends StatefulShellBranchData {
  const _HomePageData();
}

class HomeRouteData extends GoRouteData {
  const HomeRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) => const HomePage();
}

class _SettingsPageData extends StatefulShellBranchData {
  const _SettingsPageData();
}

class SettingsRouteData extends GoRouteData {
  const SettingsRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      const SettingsPage();
}

class HomeDetailRouteData extends GoRouteData {
  const HomeDetailRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}

class SettingsDetailRoute extends GoRouteData {
  const SettingsDetailRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) => const DetailPage();
}
  • home_page

pushに書き換えます。

...
    onPressed: () {
      const HomeDetailRouteData().push<void>(context);
    },
...
  • settings_page
...
    onPressed: () {
      const SettingsDetailRouteData().push<void>(context);
    },
...

routeを別々で定義したため、それぞれのタブ事に状態が保存されるようになりました。
同じ画面に遷移することは多々ありますが、状態を共有したいことはあまりないと思うので、今回のようにタブ内で詳細画面のような共通の遷移先を定義する場合はこのようにすると良さそうです。

pattern3.gif

おまけ

loop_page を追加して、settings -> detail -> loop -> detail -> … と遷移した場合の挙動を見てみます。

  • router

@TypedStatefulShellRoute<RootRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<_HomePageData>(
      routes: [
        TypedGoRoute<HomeRouteData>(
          path: HomePage.pagePath,
          routes: [
            TypedGoRoute<HomeDetailRouteData>(path: DetailPage.pagePath),
          ],
        ),
      ],
    ),
    TypedStatefulShellBranch<_SettingsPageData>(
      routes: [
        TypedGoRoute<SettingsRouteData>(
          path: SettingsPage.pagePath,
          routes: [
            TypedGoRoute<SettingsDetailRouteData>(path: DetailPage.pagePath),
            TypedGoRoute<SettingsLoopRouteData>(path: LoopPage.pagePath),
          ],
        ),
      ],
    ),
  ],
)

...

class SettingsLoopRouteData extends GoRouteData {
  const SettingsLoopRouteData();

  @override
  Widget build(BuildContext context, GoRouterState state) => const LoopPage();
}
  • loop_page
class LoopPage extends StatelessWidget {
  const LoopPage({super.key});

  static const String pagePath = 'loop';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('loop page'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text(
            'to detail',
          ),
          onPressed: () {
            const SettingsDetailRouteData().go(context);
          },
        ),
      ),
    );
  }
}
  • detail_page
...
            ElevatedButton(
              child: const Text(
                'to root',
              ),
              onPressed: () {
                const SettingsRouteData().go(context);
              },
            ),
            ElevatedButton(
              child: const Text(
                'to loop',
              ),
              onPressed: () {
                const SettingsLoopRouteData().go(context);
              },
            ),
...

タブはsettingsから移動していませんが、遷移ごとに画面の状態は保存されていません。
これは、settingsタブのトップが /settings, 詳細画面が /settings/detail, 今回追加したloop画面が /settings/loop のパスになっていて、同じ階層同士で移動し合っているため遷移の度に破棄され再生成されます。

omake.gif

detail_pageからloop_pageに遷移してdetail_pageに戻った際に状態を保持したい場合はrouteの設定をこのようにします。
画面数が多いアプリはネストが無限に深くなっていくきますが、現状こうするしかなさそうです。

@TypedStatefulShellRoute<RootRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<_HomePageData>(
      routes: [
        TypedGoRoute<HomeRouteData>(
          path: HomePage.pagePath,
          routes: [
            TypedGoRoute<HomeDetailRouteData>(path: DetailPage.pagePath),
          ],
        ),
      ],
    ),
    TypedStatefulShellBranch<_SettingsPageData>(
      routes: [
        TypedGoRoute<SettingsRouteData>(
          path: SettingsPage.pagePath,
          routes: [
            TypedGoRoute<SettingsDetailRouteData>(
              path: DetailPage.pagePath,
              routes: [
                TypedGoRoute<SettingsLoopRouteData>(path: LoopPage.pagePath),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
)

こうすれば、settings -> detail -> loop -> detailに遷移しても状態が保持されますし、ボタンを押しても、バックボタンで戻っても同じ挙動になります。
つまり、Routerで画面遷移を実現する場合はスタックの考えを捨てて、Webページと同じURLの感覚で扱っていく必要があるのかなと思います。

© AAkira 2023