LoginSignup
11
2

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

Last updated at Posted at 2023-12-08

はじめまして!wattah(わったー)です!
DeNA 24 新卒 Advent Calendar 2023 の 9日目の記事です🎄

この記事では、Flutter でアプリ開発を始める際に使える NavigationBar の実装レシピを紹介します!
では、コーヒーでも飲みながらサクッといきましょう!☕️

はじめに

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

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

ソースコード

環境

  • Flutter version: 3.16.0
  • Dart version: 3.2.0
  • go_router version: 12.1.1
  • go_router_builder version: 2.3.4
flutter doctor -v
[✓] 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!

ShellRoute と StatefulShellRoute

まず go_router_builder は置いておいて、GoRouter における話をします。
NavigationBar のように、ページ遷移の際に全画面のリビルドをさせずにナビゲーションさせたい場合には、ShellRouteStatefulShellRoute を使うことが有効です。
使い分けとしては、ページ遷移の際に状態を保持するかどうかで決めることができます。

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

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

ShellRoute

typed_shell_route.gif
GoRouter
└─ ShellRoute
    ├─ GoRoute('/alpha')
    │  └─ GoRoute('details')
    └─ GoRoute('/beta')

StatefulShellRoute

typed_stateful_shell_route.gif
GoRouter
└─ StatefulShellRoute
    ├─ StatefulShellBranch
    │   └─ GoRoute('/alpha')
    │      └─ GoRoute('details')
    └─ StatefulShellBranch
        └─ GoRoute('/beta')

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

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

TypedShellRoute

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

@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 が呼ばれていることが分かります。
$appRotuesGoRouter クラスをインスタンス化する際に、routes プロパティに流します。

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 の実装はこのようになります。

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 を使用した実装部分はこのような感じです。

@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,
);

新しく StatefulShellRouteDataStatefulShellBranchData が出てきました。
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 には、間にアニメーションをかませる例が記載されているので、もし良かったら試してみてください。

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

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 さんです!
お楽しみに!!!

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2