【Flutter】BottomNavigationBarの状態管理方法3選

はじめに

こんにちは、エムティーアイ Advent Calendar 2024 12月10日分の執筆を担当させていただく新卒エンジニアの小林です。
今回は、FlutterのBottomNavigationBarの状態管理方法について3種類の方法を紹介します。
公式ドキュメントのBottomNavigationBarをベースに、それぞれの状態管理方法のサンプルコードを載せているため、BottomNavigationBarの状態管理方法のカタログ集のように使っていただけますと幸いです。

BottomNavigationBarとは何か

BottomNavigationBarとは、アプリの画面下部に配置されるナビゲーションバーのことです。 アイテムをタップすることで、アプリ内の異なるページに遷移することができます。 恐らく、ほとんどの方がアプリを触っていて、一度は使ったことがあるUI要素なのではないでしょうか。

BottomNavigationBarの動作

1. StatefulWidgetを使った状態管理

まずは、一番オーソドックスなStatefulWidgetを使った方法について紹介します。 BottomNavigationBarの公式ドキュメントでは、StatefulWidgetを使った方法が紹介されています。 以下は、公式ドキュメントから引用したStatefulWidgetを使ったBottomNavigationBarのサンプルコードです。

import 'package:flutter/material.dart';

/// Flutter code sample for [BottomNavigationBar].

void main() => runApp(const BottomNavigationBarExampleApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: BottomNavigationBarExample(),
    );
  }
}

class BottomNavigationBarExample extends StatefulWidget {
  const BottomNavigationBarExample({super.key});

  @override
  State<BottomNavigationBarExample> createState() =>
      _BottomNavigationBarExampleState();
}

class _BottomNavigationBarExampleState
    extends State<BottomNavigationBarExample> {
  int _selectedIndex = 0;
  static const TextStyle optionStyle =
  TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
  static const List<Widget> _widgetOptions = <Widget>[
    Text(
      'Index 0: Home',
      style: optionStyle,
    ),
    Text(
      'Index 1: Business',
      style: optionStyle,
    ),
    Text(
      'Index 2: School',
      style: optionStyle,
    ),
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('BottomNavigationBar Sample'),
      ),
      body: Center(
        child: _widgetOptions.elementAt(_selectedIndex),
      ),
      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: _selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: _onItemTapped,
      ),
    );
  }
}

引用元: BottomNavigationBar class - material library - Dart API

Home, Business, Schoolの3つの画面のListである_widgetOptionsを作成し、_selectedIndexの値に応じてページを切り替えています。 BottomNavigationBarItemがタップされた際、_onItemTappedが実行され、_selectedIndexの値が変更されます。 StatefulWidgetなので、状態の更新は必ずsetStateメソッドを使って行う必要があります。

2. Hooksを使った状態管理

次に、Hooksを使った状態管理方法について紹介します。 Hooksは、Flutterで状態管理を行うためのpackageで、Hooksを使うとStatefulWidgetと比べて簡潔に状態管理を行うことができます。

Hooksを使ったコードを書く前に、以下のコマンドを実行してHooksを使用するのに必要なパッケージをインストールします。

$ flutter pub add flutter_hooks

Hooksの準備ができたら、以下のコードを書きます。

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

void main() => runApp(const BottomNavigationBarExampleApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: BottomNavigationBarExample(),
    );
  }
}

class BottomNavigationBarExample extends HookWidget {
  const BottomNavigationBarExample({super.key});

  static const TextStyle optionStyle =
      TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
  static const List<Widget> _widgetOptions = <Widget>[
    Text(
      'Index 0: Home',
      style: optionStyle,
    ),
    Text(
      'Index 1: Business',
      style: optionStyle,
    ),
    Text(
      'Index 2: School',
      style: optionStyle,
    ),
  ];

  @override
  Widget build(BuildContext context) {
    // useStateを使ってselectedIndexを管理
    // 初期値を0に設定
    final selectedIndex = useState(0);

    // onItemTappedでselectedIndexを更新
    void onItemTapped(int index) {
      selectedIndex.value = index;
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('BottomNavigationBar Sample'),
      ),
      body: Center(
        child: _widgetOptions.elementAt(selectedIndex.value),
      ),
      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: selectedIndex.value,
        selectedItemColor: Colors.amber[800],
        onTap: onItemTapped,
      ),
    );
  }
}

状態管理の方法は異なりますが、_widgetOptionsをindexの値に応じて切り替えるという考え方自体は先ほどと同じです。

先ほどはBottomNavigationBarExampleクラスがStatefulWidgetを継承していましたが、Hooksで状態管理を行う場合はHookWidgetを継承させます。 selectedIndexonItemTappedはHooksWidgetを継承したクラスのbuildメソッド内に定義します。

BottomNavigationBarItemがタップされた際、onItemTappedが実行され、selectedIndexの値が変更されます。 Hooksを使うと、StatefulWidgetと異なりsetStateが不要になるため、状態の更新の処理をより簡潔に書くことができましたね。

3. Riverpodを使った状態管理

最後に、Riverpodを使った方法について紹介します。 RiverpodもFlutterで状態管理を行うためのpackageで、UIと状態管理ロジックを分割して記述できるなどの利点があります。

Riverpodを使ったコードを書く前に、以下のコマンドを実行してRiverpodを使用するのに必要なパッケージをインストールします。

$ flutter pub add flutter_riverpod
$ flutter pub add riverpod_annotation
$ flutter pub add dev:riverpod_generator
$ flutter pub add dev:build_runner

Riverpodで状態管理を行う際、ProviderScopeを使ってプロジェクトのルートWidget(今回だとBottomNavigationBarExampleAppが該当)を必ずラップしてください。

void main() {
  return runApp(const ProviderScope(child: BottomNavigationBarExampleApp()));
}

Riverpodの導入準備ができたら、状態の更新処理を行うためのNotifierを作成します。 以下のコードを書きます。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

// Riverpod Generatorを用いるためにファイル名に.g.dartを付けたpartを指定
part 'main.g.dart';

// (省略)

@riverpod
class SelectedIndexNotifier extends _$SelectedIndexNotifier {
  @override
  int build() {
    // buildの型をintで指定すると、int型のstateを持つNotifierが生成される
    // returnで初期値を0に指定
    return 0;
  }

  // onItemTappedでstateを更新
  void onItemTapped(int index) {
    state = index;
  }
}

// (省略)

@riverpodアノテーションを付け、_$SelectedIndexNotifierを継承したSelectedIndexNotifierクラスを作成します。 Notifierのbuildメソッドをint型に指定することで、int型のstateを持つNotifierが生成されます。 buildメソッドのreturn文には、stateの初期値を指定することができるため、0を指定します。

onItemTappedメソッドには、state(今回だとbuildで指定した通りint型)の値の更新を行う処理を書きます。

Riverpod Generatorを用いたコード生成を行うため、必ずNotifierを定義したファイルにpart '{ファイル名}.g.dart';の記述を行い、以下のコマンドを忘れないように実行してください。

$ dart run build_runner watch

これでロジック部分を記述したNotifierは完成です。 続いて、UI部分のコードを以下のように書きます。

// (省略)

class BottomNavigationBarExample extends ConsumerWidget {
  const BottomNavigationBarExample({super.key});

  static const TextStyle optionStyle =
      TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
  static const List<Widget> _widgetOptions = <Widget>[
    Text(
      'Index 0: Home',
      style: optionStyle,
    ),
    Text(
      'Index 1: Business',
      style: optionStyle,
    ),
    Text(
      'Index 2: School',
      style: optionStyle,
    ),
  ];

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watchでselectedIndexNotifierProviderを監視し、selectedIndexを取得
    final selectedIndex = ref.watch(selectedIndexNotifierProvider);
    // ref.readでselectedIndexNotifierProvider.notifierを取得
    final notifier = ref.read(selectedIndexNotifierProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: const Text('BottomNavigationBar Sample'),
      ),
      body: Center(
        child: _widgetOptions.elementAt(selectedIndex),
      ),
      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: selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: notifier.onItemTapped,
      ),
    );
  }
}

状態管理の方法は異なりますが、_widgetOptionsをindexの値に応じて切り替えるという考え方自体は先ほどと同じです。

Riverpodで状態管理を行うため、今度はBottomNavigationBarExampleクラスにConsumerWidgetを継承させます。 ConsumerWidgetを継承したクラスのbuildメソッドには、追加でWidgetRef型の引数refが必要になるため、忘れないように追加してください。

ここまでできたら、buildメソッド内でselectedIndexnotifierを定義します。 ref.watchselectedIndexNotifierProviderのstate(今回だとint型の値)を取得できるため、selectedIndexに代入します。 ref.readselectedIndexNotifierProvider.notifierを取得し、notifierに代入します。ConsumerWidgetを継承したクラス内でnotifier.onItemTappedのようにNotifierで定義したメソッドが使用することができます。

BottomNavigationBarItemがタップされた際、notifier.onItemTappedが実行され、selectedIndexの値が変更されます。

以下がRiverpodを使ったBottomNavigationBarのコードの全文です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

// Riverpod Generatorを用いるためにファイル名に.g.dartを付けたpartを指定
part 'main.g.dart';

void main() {
  return runApp(const ProviderScope(child: BottomNavigationBarExampleApp()));
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: BottomNavigationBarExample(),
    );
  }
}

@riverpod
class SelectedIndexNotifier extends _$SelectedIndexNotifier {
  @override
  int build() {
    // buildの型をintで指定すると、int型のstateを持つNotifierが生成される
    // returnで初期値を0に指定
    return 0;
  }

  // onItemTappedでstateを更新
  void onItemTapped(int index) {
    state = index;
  }
}

class BottomNavigationBarExample extends ConsumerWidget {
  const BottomNavigationBarExample({super.key});

  static const TextStyle optionStyle =
      TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
  static const List<Widget> _widgetOptions = <Widget>[
    Text(
      'Index 0: Home',
      style: optionStyle,
    ),
    Text(
      'Index 1: Business',
      style: optionStyle,
    ),
    Text(
      'Index 2: School',
      style: optionStyle,
    ),
  ];

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watchでselectedIndexNotifierProviderを監視し、selectedIndexを取得
    final selectedIndex = ref.watch(selectedIndexNotifierProvider);
    // ref.readでselectedIndexNotifierProvider.notifierを取得
    final notifier = ref.read(selectedIndexNotifierProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: const Text('BottomNavigationBar Sample'),
      ),
      body: Center(
        child: _widgetOptions.elementAt(selectedIndex),
      ),
      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: selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: notifier.onItemTapped,
      ),
    );
  }
}

Riverpodを使うと、コード量は増えるものの、UIと状態管理ロジックを分割できましたね。

最後に

FlutterのBottomNavigationBarの状態管理方法について、StatefulWidget、Hooks、Riverpodの3つの方法を紹介しました。
これらの方法を全て丸暗記する必要ありません。
大事なのは、各々のプロジェクトや個人の技術スタックに応じて柔軟に使い分けることだと思います。

参考