Flutter x go_router_builder で type-safe に NavigationBar を実装する

はじめまして！wattah（わったー）です！
[DeNA 24 新卒 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/dena-24-newgrad) の 9日目の記事です🎄

この記事では、Flutter でアプリ開発を始める際に使える [NavigationBar](https://m3.material.io/components/navigation-bar/overview) の実装レシピを紹介します！
では、コーヒーでも飲みながらサクッといきましょう！☕️

## はじめに

前提として、ルーティングには [GoRouter](https://pub.dev/packages/go_router) を使用しています。
また、[go_router_builder](https://pub.dev/packages/go_router_builder) で型をつけています。
go_router_builder は GoRotuer のクラスにアノテーションをつけて `build_runner` を実行することで、type-safe に GoRouter の API を呼べるようにコードを自動生成してくれます。

各パッケージの詳しい使い方は、記事が冗長になってしまうので、ここでの説明は割愛します。
ドキュメントを見ていただくのが早いと思います。

https://pub.dev/documentation/go_router/latest/

https://pub.dev/documentation/go_router_builder/latest/

### ソースコード

https://github.com/wattah1002/type-safe-navigationbar

### 環境

- Flutter version: 3.16.0
- Dart version: 3.2.0
- go_router version: 12.1.1
- go_router_builder version: 2.3.4

<details><summary>flutter doctor -v</summary>

```shell
[✓] Flutter (Channel stable, 3.16.0, on macOS 13.6.1 22G313 darwin-arm64, locale ja-JP)
    • Flutter version 3.16.0 on channel stable at /Users/yukiwatanabe/.asdf/installs/flutter/3.16.0-stable
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision db7ef5bf9f (3 weeks ago), 2023-11-15 11:25:44 -0800
    • Engine revision 74d16627b9
    • Dart version 3.2.0
    • DevTools version 2.28.2

[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
    • Android SDK at /Users/yukiwatanabe/Library/Android/sdk
    • Platform android-33, build-tools 33.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 14.3.1)
    • Xcode at /Applications/Xcode_14.3.app/Contents/Developer
    • Build 14E300c
    • CocoaPods version 1.13.0

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2021.3)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.13+0-b1751.21-8125866)

[✓] VS Code (version 1.84.2)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.78.0

[✓] Connected device (3 available)
    • iPhone 14 Pro Max (mobile) • D7F01C29-9A81-45D1-AFAD-5B66A0F08E21 • ios            • com.apple.CoreSimulator.SimRuntime.iOS-16-4
      (simulator)
    • macOS (desktop)            • macos                                • darwin-arm64   • macOS 13.6.1 22G313 darwin-arm64
    • Chrome (web)               • chrome                               • web-javascript • Google Chrome 120.0.6099.71

[✓] Network resources
    • All expected network resources are available.

• No issues found!
```
</details>

## ShellRoute と StatefulShellRoute

まず go_router_builder は置いておいて、GoRouter における話をします。
NavigationBar のように、ページ遷移の際に全画面のリビルドをさせずにナビゲーションさせたい場合には、[ShellRoute](https://pub.dev/documentation/go_router/latest/go_router/ShellRoute-class.html) か [StatefulShellRoute](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html) を使うことが有効です。
使い分けとしては、ページ遷移の際に状態を保持するかどうかで決めることができます。

`ShellRoute` : 状態を保持する必要がない
`StatefulShellRoute` : 状態を保持する必要がある

動作の様子とルーティングの階層はこんな感じになります。

### `ShellRoute`

<img width="200" alt="typed_shell_route.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/955415/8ccddb35-525e-8807-54ad-2c17d7b9ecc0.gif">

```
GoRouter
└─ ShellRoute
    ├─ GoRoute('/alpha')
    │  └─ GoRoute('details')
    └─ GoRoute('/beta')
```

### `StatefulShellRoute`

<img width="200" alt="typed_stateful_shell_route.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/955415/12d99340-e97e-7df4-4e5d-52e4cf47d77d.gif">

```
GoRouter
└─ StatefulShellRoute
    ├─ StatefulShellBranch
    │   └─ GoRoute('/alpha')
    │      └─ GoRoute('details')
    └─ StatefulShellBranch
        └─ GoRoute('/beta')
```

`ShellRoute` では、Alpha Details Page に遷移した状態が保持されませんが、`StatefulShellRoute` では保持されていることが分かります。

また、名前から想像できる通り `StatefulShellRoute` は `ShellRoute` よりもできることが多いです。
お馴染みの CODE WITH ANDREA で `StatefulShellRoute` のより詳しい説明がなされているので、もしよければ見てみてください。

https://codewithandrea.com/articles/flutter-bottom-navigation-bar-nested-routes-gorouter/


## `TypedShellRoute`

go_router_builder を使って type-safe に `ShellRoute` を呼ぶ方法を説明します。
GoRouter API を使用した実装部分はこのような感じです。

```dart
@TypedShellRoute<MyShellRouteData>(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<AlphaRouteData>(
      path: '/alpha',
      routes: <TypedRoute<RouteData>>[
        TypedGoRoute<AlphaDetailsRouteData>(path: 'details')
      ],
    ),
    TypedGoRoute<BetaRouteData>(
      path: '/beta',
    ),
  ],
)
class MyShellRouteData extends ShellRouteData {
  const MyShellRouteData();

  @override
  Widget builder(
    BuildContext context,
    GoRouterState state,
    Widget navigator,
  ) {
    return ScaffoldWithNavigation(child: navigator);
  }
}

class AlphaRouteData extends GoRouteData {
  const AlphaRouteData();

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

class AlphaDetailsRouteData extends GoRouteData {
  const AlphaDetailsRouteData();

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

class BetaRouteData extends GoRouteData {
  const BetaRouteData();

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

final router = GoRouter(
  routes: $appRoutes,
  initialLocation: '/alpha',
);
```

まず、`ShellRouteData` を継承して、`@TypedShellRoute` をつけます。
`ShellRouteData` には `ShellRoute` を返す `$route` メソッドが用意されています。
自動生成されたコードを見ると、`$route` が呼ばれていることが分かります。
`$appRotues` は `GoRouter` クラスをインスタンス化する際に、`routes` プロパティに流します。

```dart
List<RouteBase> get $appRoutes => [
      $myShellRouteData,
    ];

RouteBase get $myShellRouteData => ShellRouteData.$route(
      factory: $MyShellRouteDataExtension._fromState,
      routes: [
        GoRouteData.$route(...
```

`TypedGoRoute` についても似たような形です。
継承元の `GoRouteData` には `GoRoute` を返す `$route` が用意されています。
生成されたコードでも `GoRouteData.$route(...` と、 `GoRoute` を呼んでいることが分かります。

次に、`MyShellRouteData` の override された `builder` 関数で返している `ScaffoldWithNavigation` の実装はこのようになります。

```dart
class ScaffoldWithNavigation extends StatelessWidget {
  const ScaffoldWithNavigation({
    required this.child,
    super.key,
  });

  final Widget child;

  int getCurrentIndex(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    if (location == '/beta') {
      return 1;
    }
    return 0;
  }

  @override
  Widget build(BuildContext context) {
    final currentIndex = getCurrentIndex(context);
    return Scaffold(
      body: child,
      bottomNavigationBar: NavigationBar(
        selectedIndex: currentIndex,
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.home),
            label: 'Alpha',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings),
            label: 'Beta',
          ),
        ],
        onDestinationSelected: (int index) {
          switch (index) {
            case 0:
              const AlphaRouteData().go(context);
              break;
            case 1:
              const BetaRouteData().go(context);
              break;
          }
        },
      ),
    );
  }
}
```

現在のインデックスは、`getCurrentIndex` 関数を実装して取得できるようします。
`GoRouterState` からロケーションを取得して、`NavigationBar` のインデックスを返す関数です。
また、`onDestinationSelected` で呼ばれるページ遷移の関数は自前で用意する必要があります。


## TypedStatefulShellRoute

次に、type-safe な `StatefulShellRoute` について説明します。
まず、GoRouter API を使用した実装部分はこのような感じです。

```dart
@TypedStatefulShellRoute<MyStatefulShellRouteData>(
  branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
    TypedStatefulShellBranch<BranchAlphaData>(
      routes: [
        TypedGoRoute<AlphaRouteData>(
          path: '/alpha',
          routes: [
            TypedGoRoute<AlphaDetailsRouteData>(path: 'details'),
          ],
        ),
      ],
    ),
    TypedStatefulShellBranch<BranchBetaData>(
      routes: [
        TypedGoRoute<BetaRouteData>(path: '/beta'),
      ],
    ),
  ],
)
class MyStatefulShellRouteData extends StatefulShellRouteData {
  const MyStatefulShellRouteData();

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

class BranchAlphaData extends StatefulShellBranchData {
  const BranchAlphaData();
}

class BranchBetaData extends StatefulShellBranchData {
  const BranchBetaData();
}

// 以下は TypedShellRoute のときと同じなので省略します
class AlphaRouteData extends GoRouteData {
    ...
}

class AlphaDetailsRouteData extends GoRouteData {
    ...
}

class BetaRouteData extends GoRouteData {
    ...
}

final router = GoRouter(
  initialLocation: '/alpha',
  routes: $appRoutes,
);
```

新しく `StatefulShellRouteData` と `StatefulShellBranchData` が出てきました。
`StatefulShellBranchData` は内部の `$branch` メソッドで `StatefulShellBranch` を返します。
`StatefulShellRouteData` については注意が必要です。

それは、`StatefulShellRouteData` 内部の `$route` メソッドで返されるものが `StatefulShellRoute()` と `StatefulShellRoute.indexedStack()` の2パターンある点です。
どちらも、`StatefulWidget` を継承した `navigationShell` というシェルに関する状態を持つ Widget について、それを含む形で `builder` として `ScaffoldWithNavigation` を返すのは共通しています。
しかし、 `MyStatefulShellRouteData` 内で `$navigationContainerBuilder` 関数を定義しているか否かで、返すインスタンスが異なります。

定義していなければ `StatefulShellRoute.indexedStack()` が返ります。
凝ったことをしない限りは、こちらで問題ないです。

定義していれば `StatefulShellRoute()` の `navigatorContainerBuilder` プロパティに `$navigationContainerBuilder` が渡されて返ります。
`navigatorContainerBuilder` は引数に `List<Widget>` を取ることができるため、ここに子ルートの Widget を渡して、ページ遷移時の挙動をカスタマイズできます。
[go_router_builder の examples](https://github.com/flutter/packages/blob/15584a394d36070b684b58bb65b37cd86aa1f1ea/packages/go_router_builder/example/lib/stateful_shell_route_example.dart#L126) には、間にアニメーションをかませる例が記載されているので、もし良かったら試してみてください。

では次に、`ScaffoldWithNavigation` の実装です。

```dart
class ScaffoldWithNavigation extends StatelessWidget {
  const ScaffoldWithNavigation({
    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: 'Alpha',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings),
            label: 'Beta',
          ),
        ],
        onDestinationSelected: _goBranch,
      ),
    );
  }

  void _goBranch(int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}
```

`TypedShellRoute` のときよりもコードの量が少なくなったのが分かります。
これは先ほどちらっと紹介した `navigationShell` が shell 周りの状態を持つ `StatefulWidget` で、`currentIndex` を取ることができたり、`goBranch` メソッドが用意されていて、`switch` によるページ遷移の自前実装が必要なくなるためです。

以上が `TypedStatefulShellRoute` の説明になります。

## おわりに

`build_runner` を使う場合には、生成されたコードから中身を追わないと、挙動を正しく理解することが難しい場合があります。`go_router_builder` も例外ではありません。
Flutter は比較的コードが追いやすいと思いますが、最初はよく分からず、つらい面も多々ありますよね。
僕は SPAJAM で `TypedStatefulShellRoute` の沼にハマり、抜け出せませんでした😭
少しでも参考になれば幸いです。

明日の担当は shake さんです！
お楽しみに！！！