【Flutter】状態管理不要?GoRouterを使ってBottomNavigationBarの画面切り替えを行う方法

はじめに

こんにちは、エムティーアイ Advent Calendar 2024 の12月18日分の執筆を担当させていただく新卒エンジニアの小林です。

今回は、前回の【Flutter】BottomNavigationBarの状態管理方法3選の番外編として、GoRouterを使ってBottomNavigationBarの画面切り替えを行う方法についてご紹介します。
GoRouter導入済みのFlutterプロジェクトでBottomNavigationBarを追加する際、GoRouterによってBottomNavigationBarによる画面切り替えを実現する方法について検証を行なった内容についてまとめました。

FlutterプロジェクトにGoRouterを導入済み、または導入予定で、GoRouterを使ったBottomNavigationBarの画面切り替えを行う方法を知りたいという方におすすめです。

GoRouterとは

既にご存知の方も多いかもしれませんが、GoRouterについて軽くご紹介しておきます。
GoRouterとは、Flutterの画面遷移を行うためのルーティングpackageです。
GoRouterは、URLベースで画面遷移を行うことができるため、標準の画面遷移で使われるNavigatorと比べてシンプルなコードで画面遷移を実現することができます。

pub.dev

GoRouterのインストール

まず、GoRouterの導入を行います。

以下のようにpubspec.yamlgo_router: ^14.6.2(記事執筆時点の最新バージョン)を追加し、flutter pub getを実行してください。

dependencies:
  go_router: ^14.6.2

これで、GoRouterのインストールは完了です。

表示する画面の作成

次に、BottomNavigationBarで表示切り替えを行う画面を作成します。
BottomNavigationBarの公式サンプルコードをベースに、HomePageBusinessPageSchoolPageの3つの画面を作成します。

const TextStyle optionStyle =
    TextStyle(fontSize: 30, fontWeight: FontWeight.bold);

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(
          'Index 0: Home',
          style: optionStyle,
        ),
      ),
    );
  }
}

// 省略(以下HomePageと同様にBusinessPageとSchoolPageを追加で作成)

これで、ButtomNavigationBarによる切り替えを行うためのシンプルな画面ができました。
本記事では、説明のために1つのDartファイルにまとめて記述していますが、実際のプロジェクトにおいてはそれぞれのクラスをファイルに分割したり、スタイルを共通化したりすることをおすすめします。

BottomNavigationBarの作成

表示する画面ができたので、その画面を切り替えるためのBottomNavigationBarを作成します。
以下のようなLayoutScaffoldクラスを作成します。

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

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.business),
            label: 'Business',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.school),
            label: 'School',
          ),
        ],
        currentIndex: navigationShell.currentIndex,  // 選択中のindex
        selectedItemColor: Colors.amber[800],
        onTap: (index) => navigationShell.goBranch(index),  // indexの切り替え
      ),
    );
  }
}

LayoutScaffoldクラスは、StatefulNavigationShell型の引数navigationShellを受け取ります。
これはGoRouterが提供する機能で、StatefulNavigationShell型の値には、BottomNavigationBarによる画面切り替えを行うために必要な情報が含まれています。

navigationShellには、現在選択中の画面の情報が含まれています。
そのため、ScaffoldのbodyにはnavigationShellをそのまま渡します。

BottomNavigationBarのcurrentIndexには、選択中のindexを表すnavigationShell.currentIndexを指定します。
BottomNavigationBarのonTapには、indexの切り替えを行うnavigationShell.goBranch(index)を指定します。

通常、BottomNavigationBarの画面切り替えは状態管理を用いて行うのが一般的ですが、StatefulNavigationShellを使うと、GoRouterがで内部的に状態管理を行ってくれるため、StatelessWidgetでBottomNavigationBarの画面切り替えを行うことができます。
面白いですね。

これでBottomNavigationBarが作成できました。

ルーティングの設定

続いて、ルーティングの設定を行います。
GoRouterを用いて以下のようにルーティングの設定を記述します。

final router = GoRouter(
  initialLocation: '/home',
  routes: [
    // 複数の画面の状態を保持しつつ画面の切り替えを行う
    StatefulShellRoute.indexedStack(
      // 各画面共通のレイアウト
      builder: (context, state, navigationShell) => LayoutScaffold(
        navigationShell: navigationShell,
      ),
      branches: [
        // HomePageのルーティング設定
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              pageBuilder: (context, state) => MaterialPage(
                key: state.pageKey,
                child: const HomePage(),
              ),
            ),
          ],
        ),
        // BusinessPageのルーティング設定
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/business',
              pageBuilder: (context, state) => MaterialPage(
                key: state.pageKey,
                child: const BusinessPage(),
              ),
            ),
          ],
        ),
        // SchoolPageのルーティング設定
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/school',
              pageBuilder: (context, state) => MaterialPage(
                key: state.pageKey,
                child: const SchoolPage(),
              ),
            ),
          ],
        ),
      ],
    ),
  ],
);

StatefulShellRoute.indexedStackStatefulShellBranchはGoRouterが提供する機能であり、GoRouterによるBottomNavigationBarの切り替えに必要となります。

StatefulShellRoute.indexedStackは、複数の画面の状態を保持しつつ、画面の切り替えを行うための機能です。
builderには、各画面共通のレイアウトを指定することができるため、先ほど作成したLayoutScaffoldを指定することにより、BottomNavigationBarは各画面共通で表示しつつ、画面のみ切り替えることができます。

StatefulShellBranchはGoRouterは、状態を保持したまま切り替えたい画面を指定するための機能です。

routerが作成できたら、MaterialAppを以下のように編集します。

// MaterialAppではなくMaterialApp.routerを使う
return MaterialApp.router(
  // 作成したrouterを指定
  routerConfig: router,
  title: 'Flutter Demo',
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
    useMaterial3: true,
  ),
);

MaterialAppMaterialApp.routerに変更し、routerConfigに先ほど作成したrouterを指定します。
これで、作成したルーティング情報をアプリに反映させることができます。

動作確認

最後に、動作確認を行います。
ここまでできたら、BottomNavigationBarによる画面の切り替えができているか実際にアプリを起動して確かめてみましょう。

実行結果

上手く動作しました!

サンプルコードの全文も載せておきます。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(const MyApp());
}

final router = GoRouter(
  initialLocation: '/home',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) => LayoutScaffold(
        navigationShell: navigationShell,
      ),
      branches: [
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/home',
              pageBuilder: (context, state) => MaterialPage(
                key: state.pageKey,
                child: const HomePage(),
              ),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/business',
              pageBuilder: (context, state) => MaterialPage(
                key: state.pageKey,
                child: const BusinessPage(),
              ),
            ),
          ],
        ),
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/school',
              pageBuilder: (context, state) => MaterialPage(
                key: state.pageKey,
                child: const SchoolPage(),
              ),
            ),
          ],
        ),
      ],
    ),
  ],
);

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
    );
  }
}

const TextStyle optionStyle =
    TextStyle(fontSize: 30, fontWeight: FontWeight.bold);

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(
          'Index 0: Home',
          style: optionStyle,
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(
          'Index 1: Business',
          style: optionStyle,
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(
          'Index 2: School',
          style: optionStyle,
        ),
      ),
    );
  }
}

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

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.business),
            label: 'Business',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.school),
            label: 'School',
          ),
        ],
        currentIndex: navigationShell.currentIndex,
        selectedItemColor: Colors.amber[800],
        onTap: (index) => navigationShell.goBranch(index),
      ),
    );
  }
}

まとめ

GoRouterを使ってBottomNavigationBarの画面切り替えを行う方法についてご紹介いたしました。

GoRouterが提供するStatefulShellRoute.indexedStackStatefulShellBranchを使うと、BottomNavigationBarの画面切り替えを実現できることがわかりました。
その際、GoRouterが内部的に画面切り替えに関する状態管理を行い、BottomNavigationBarをStatelessWidgetで実現できるため、BottomNavigationBarのためにStatefulWidgetやRiverpod等の状態管理を行わなくても済むということもわかりました。

この方法を用いることにより、画面遷移だけでなく、BottomNavigationBarの画面切り替えもGoRouterに統一することができます。
ぜひ、試してみてください。

ここまで読んでくださりありがとうございました。