4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フューチャーAdvent Calendar 2024

Day 2

【Flutter】ユーザーが自由に設定可能なボトムナビゲーションバーを実装する

Last updated at Posted at 2024-12-02

この記事は、フューチャー Advent Calendar 2024の2日目です。

TL;DR

  • リスト構造を使ったユーザーが画面上で変更可能な、動的なボトムナビゲーションバーの実装を紹介します
  • 設定の永続化として、Hiveを導入する例を紹介します

はじめに

モバイルアプリケーションでは、画面下部に配置されるボトムナビゲーションバーが多くのアプリで利用されています。
このUIがあることで、一般的かつ直感的な操作が可能になり、アプリ内の主要なページへのアクセスができる便利なものです。

ですが、このボトムナビゲーションバーがどのような構成やレイアウトであるかという設定は多くのアプリでは固定されていて、「このページのアイコンを置かれても使っていない...」「むしろこっちのページにアクセスできるようにしてほしいのに...」といったことを思う人もいるのではないでしょうか?私は思います。

なので本記事では以下の特徴を持つ、ユーザーが設定画面にてボトムナビゲーションバーの構成を自由に設定してアクセス可能にするようなFlutterでの実装を紹介していきます。

  • ユーザーがボトムナビゲーションバーをカスタマイズ可能にする(名前・アイコンの変更・構成の選択)
  • 設定の永続化(Hiveの導入)

1. 基本的なボトムナビゲーションバーの実装

まずは基本的な例として、以下のような固定のボトムナビゲーションバーの実装を紹介します。

image.png

Flutterでは、BottomNavigationBarウィジェットを使用してお手軽にボトムナビゲーションバーを作成することができます。

以下が3つのタブを持つナビゲージョンバーの実装例です。

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Bottom Navigation',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textTheme: GoogleFonts.notoSansJpTextTheme(
          Theme.of(context).textTheme,
        ),
      ),
      home: const BottomNavBarExample(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  _BottomNavBarExampleState createState() => _BottomNavBarExampleState();
}

class _BottomNavBarExampleState extends State<BottomNavBarExample> {
  int _currentIndex = 0;

  final List<Widget> _pages = [
    const HomePage(),
    const HealthDataPage(),
    const SettingsPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ボトムナビゲーションの例'),
      ),
      body: _pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(
              icon: Icon(Icons.health_and_safety), label: '健康データ'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: '設定'),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text('ホームページ', style: TextStyle(fontSize: 18)));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Center(
        child: Text('健康データページ', style: TextStyle(fontSize: 18)));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text('設定ページ', style: TextStyle(fontSize: 18)));
  }
}

以下は上記の実装について抜粋して説明をします。

ページについてはList<Widget>で管理し、選択されたタブのインデックスに応じて表示を行っています。

final List<Widget> _pages = [
    const HomePage(),
    const HealthDataPage(),
    const SettingsPage(),
  ];

以下の部分が特にボトムナビゲーションバーに関わる部分です。

  • BottomNavigationBarのitemsでアイコンとラベルを設定、onTapでタップ時の動作を定義しています。
  • currentIndexを使用して、現在選択されているタブを表示しています。setStateで更新することでページを切り替えています。
  • onTapはタブがタップされたときに呼び出され、インデックスの更新をします。UIの更新をするために、setStateを用いて反映することを忘れないようにします。
appBar: AppBar(title: Text('ボトムナビゲーションの例')),
      body: _pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(icon: Icon(Icons.health_and_safety), label: '健康データ'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: '設定'),
        ],
      ),
    );

固定のタブを持つシンプルな実装であれば、上記で完成です。あとは、それぞれのページの中身を具体的に実装すればアプリとしては一定機能するでしょう。

2. ボトムナビゲーションバーを動的リストで変更できるようにする

ここからが本題です。固定でタブを持つシンプルな実装から発展して、ユーザーが動的にページを設定できるようにしていきます。

全体の構成としては、以下のようにします。

  • いくつかのページ画面が存在
  • 設定画面でタブの選択・解除をチェックボックスで入力可能
  • 設定画面で、タブの名前を変更可能
  • 設定画面で、タブのアイコンを変更可能
  • 設定画面で保存すると、ボトムナビゲーションバーに反映される
  • ホームアイコン、設定アイコンは固定としほかをユーザーが設定可能とする

下の左画像がホーム画面およびボトムナビゲーションバー、右画像が設定画面のイメージです。

image.png

実際の画面操作のイメージとしては、以下のようになります。

ボトムナビゲーションバーから設定画面に遷移、そこでアイコンや名前を設定して保存できるような挙動ですね。

image.png

今回の実装としては、以下の2つで主に構成されます。

  • CustomizableNavBar : 動的リストでボトムナビゲーションバーを表示する
  • TabSettingsPage : タブの編集画面
class _CustomizableNavBarState extends State<CustomizableNavBar> {
  int _currentIndex = 0;

  late List<Map<String, dynamic>> _customizableTabs;
  late List<Map<String, dynamic>> _selectedTabs;

  // 固定タブ(ホーム、設定)
  final Map<String, dynamic> _homeTab = {
    'icon': Icons.home,
    'label': 'ホーム',
    'page': const HomePage(),
  };

  final Map<String, dynamic> _settingsTab = {
    'icon': Icons.settings,
    'label': '設定',
    'page': null,
  };

  // デフォルトのカスタマイズ可能タブ
  final List<Map<String, dynamic>> _defaultCustomizableTabs = [
    {
      'id': 1,
      'icon': Icons.favorite,
      'label': 'お気に入り',
      'page': const FavoritePage()
    },
    {
      'id': 2,
      'icon': Icons.directions_run,
      'label': '運動',
      'page': const ExercisePage()
    },
    {
      'id': 3,
      'icon': Icons.bar_chart,
      'label': '統計',
      'page': const StatsPage()
    },
    {
      'id': 4,
      'icon': Icons.notifications,
      'label': '通知',
      'page': const NotificationsPage()
    },
    {
      'id': 5,
      'icon': Icons.calendar_today,
      'label': 'カレンダー',
      'page': const CalendarPage()
    },
    {
      'id': 6,
      'icon': Icons.person,
      'label': 'プロファイル',
      'page': const ProfilePage()
    },
  ];

  // デフォルト選択タブ
  final List<Map<String, dynamic>> _defaultSelectedTabs = [
    {
      'id': 1,
      'icon': Icons.favorite,
      'label': 'お気に入り',
      'page': const FavoritePage()
    },
    {
      'id': 2,
      'icon': Icons.directions_run,
      'label': '運動',
      'page': const ExercisePage()
    },
  ];

  @override
  void initState() {
    super.initState();
    _customizableTabs = List.from(_defaultCustomizableTabs);
    _selectedTabs = List.from(_defaultSelectedTabs);
  }

  // タブ構成を作成する関数
  List<Map<String, dynamic>> _generateTabs() {
    return [
      _homeTab,
      ..._selectedTabs,
      _settingsTab,
    ];
  }

  @override
  Widget build(BuildContext context) {
    final tabs = _generateTabs();

    return Scaffold(
      appBar: AppBar(
        title: const Flexible(
          child: Text(
            'ボトムナビゲーションを自由にカスタマイズ可能な例',
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(fontSize: 18),
          ),
        ),
      ),
      body: tabs[_currentIndex]['page'],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) async {
          if (tabs[index]['label'] == '設定') {
            // 設定画面に遷移
            final updatedTabs =
                await Navigator.push<List<Map<String, dynamic>>>(
              context,
              MaterialPageRoute(
                builder: (context) => TabSettingsPage(
                  allTabs: _customizableTabs,
                  selectedTabs: _selectedTabs,
                  defaultTabs: _defaultCustomizableTabs,
                  onUpdateSelectedTabs: (tabs) =>
                      setState(() => _selectedTabs = tabs),
                  onUpdateCustomizableTabs: (tabs) =>
                      setState(() => _customizableTabs = tabs),
                ),
              ),
            );

            if (updatedTabs != null) {
              setState(() {
                _customizableTabs = updatedTabs;
              });
            }
          } else {
            // 通常のタブ切り替え
            setState(() {
              _currentIndex = index;
            });
          }
        },
        items: tabs
            .map((tab) => BottomNavigationBarItem(
                  icon: Icon(tab['icon']),
                  label: tab['label'],
                ))
            .toList(),
      ),
    );
  }
}

基本的な実装例からの変化点として、BottomNavigationBaritemsではタブのリストから動的に生成していること。また、onTapで設定アイコンを押下したときには設定ページに飛ぶように、戻る際は更新内容が反映されるようになります。

上記の中で、特に以下の箇所が今回の本題の部分です。動的なリストを用意して、それをBottomNavigationBaritemsで渡すようにしています。

List<Map<String, dynamic>> _generateTabs() {
    return [
      _homeTab,
      ..._selectedTabs,
      _settingsTab,
    ];
  }
items: tabs
            .map((tab) => BottomNavigationBarItem(
                  icon: Icon(tab['icon']),
                  label: tab['label'],
                ))
            .toList(),

設定画面としては、以下のように実装しています。アイコンの変更、名前の変更、デフォルト設定へのリセットなどが行えるようにしています。

class TabSettingsPage extends StatefulWidget {
  final List<Map<String, dynamic>> allTabs;
  final List<Map<String, dynamic>> selectedTabs;
  final List<Map<String, dynamic>> defaultTabs;
  final Function(List<Map<String, dynamic>>) onUpdateSelectedTabs;
  final Function(List<Map<String, dynamic>>) onUpdateCustomizableTabs;

  const TabSettingsPage({
    super.key,
    required this.allTabs,
    required this.selectedTabs,
    required this.defaultTabs,
    required this.onUpdateSelectedTabs,
    required this.onUpdateCustomizableTabs,
  });

  @override
  _TabSettingsPageState createState() => _TabSettingsPageState();
}

class _TabSettingsPageState extends State<TabSettingsPage> {
  late List<Map<String, dynamic>> tempSelectedTabs;
  late List<Map<String, dynamic>> tempCustomizableTabs;
  late List<TextEditingController> _controllers;

  @override
  void initState() {
    super.initState();
    tempSelectedTabs = cloneTabList(widget.selectedTabs);
    tempCustomizableTabs = cloneTabList(widget.allTabs);
    _initControllers(); // 初期化関数の呼び出し
  }

  void _initControllers() {
    _controllers = tempCustomizableTabs
        .map((tab) => TextEditingController(text: tab['label']))
        .toList();
  }

  @override
  void dispose() {
    // コントローラーを解放
    for (var controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  bool _isSelected(int id) {
    return tempSelectedTabs.any((tab) => tab['id'] == id);
  }

  void _toggleTab(Map<String, dynamic> tab) {
    setState(() {
      if (_isSelected(tab['id'])) {
        tempSelectedTabs
            .removeWhere((selectedTab) => selectedTab['id'] == tab['id']);
      } else {
        if (tempSelectedTabs.length < 4) {
          tempSelectedTabs.add(tab);
        } else {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('タブは最大4つまで選択できます。')),
          );
        }
      }
    });
  }

  void _resetTabs() {
    setState(() {
      // デフォルトのタブリストを完全にコピー
      tempCustomizableTabs = cloneTabList(widget.defaultTabs);
      // デフォルトの選択タブもリセット
      tempSelectedTabs = tempCustomizableTabs.take(2).toList();

      // TextEditingControllerの内容をリセット
      for (int i = 0; i < _controllers.length; i++) {
        _controllers[i].text = tempCustomizableTabs[i]['label'] as String;
      }
    });
  }


void _updateTabName(int index, String newName) {
  setState(() {
    // tempCustomizableTabs のラベルを更新
    tempCustomizableTabs[index]['label'] = newName;

    // tempSelectedTabs 内の該当タブを更新
    for (var selectedTab in tempSelectedTabs) {
      if (selectedTab['id'] == tempCustomizableTabs[index]['id']) {
        selectedTab['label'] = newName;
        break;
      }
    }
  });
}

  void _updateTabIcon(int index, IconData newIcon) {
    setState(() {
      tempCustomizableTabs[index]['icon'] = newIcon;

    // tempSelectedTabs 内の該当タブを更新
    for (var selectedTab in tempSelectedTabs) {
      if (selectedTab['id'] == tempCustomizableTabs[index]['id']) {
        selectedTab['icon'] = newIcon;
        break;
      }
    }
    });
  }

  void _showIconPicker(int index) async {
    final IconData? selectedIcon = await showDialog<IconData>(
      context: context,
      builder: (context) => IconPickerDialog(),
    );

    if (selectedIcon != null) {
      _updateTabIcon(index, selectedIcon);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('タブ設定'),
        actions: [
          TextButton(
            onPressed: _resetTabs,
            child: const Text(
              'デフォルトに戻す',
              style: TextStyle(color: Colors.blue),
            ),
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: tempCustomizableTabs.length,
        itemBuilder: (context, index) {
          final tab = tempCustomizableTabs[index];
          return Card(
            child: ListTile(
              leading: IconButton(
                icon: Icon(tab['icon']),
                onPressed: () => _showIconPicker(index), // アイコン変更
              ),
              title: TextFormField(
                controller: _controllers[index],
                decoration: const InputDecoration(labelText: 'タブの名前を入力'),
                onChanged: (value) => _updateTabName(index, value), // 名前変更
              ),
              trailing: Checkbox(
                value: _isSelected(tab['id']),
                onChanged: (_) => _toggleTab(tab),
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          print(tempSelectedTabs);
          print(tempCustomizableTabs);
          widget.onUpdateSelectedTabs(tempSelectedTabs);
          widget.onUpdateCustomizableTabs(tempCustomizableTabs);
          Navigator.pop(context);
        },
        child: const Icon(Icons.save),
      ),
    );
  }
}

ここまでで、動的なリストを用いたユーザーが設定可能なボトムナビゲーションバーは実装できました。

3. Hiveを導入して設定を永続化する

ここまで読んでいただいた聡明な読者の皆様はお気づきでしょうが、上記の動的なリストの実装だと、ユーザーがタブ設定したあと、アプリを閉じたり再起動することで設定内容がリセットされてしまいます。

かえってストレスフルなことになりますね。

対応として、ユーザーの設定内容をどこかで保存・読み込みを行う必要があります。対応方法としては、shared_preferencesを使用する、mysqlを使用するなどの方法もあります。

今回はDart製のキーバリューデータベースであるhiveを使用して永続化をすることにします。

ここからは、実装を修正する形で導入を紹介します。(※ここまで記事を書いてから思い至ったのは秘密です)

3.1 Hiveパッケージの追加

以下のコマンドで関連パッケージを追加します。

flutter pub add hive hive_flutter hive_generator build_runner

3.2 main.dartでHiveの初期化の追加

main.dartに以下のように追加します。

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
+ import 'package:hive_flutter/hive_flutter.dart';

void main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+  await Hive.initFlutter();

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Bottom Navigation',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textTheme: GoogleFonts.notoSansJpTextTheme(
          Theme.of(context).textTheme,
        ),
        bottomNavigationBarTheme: const BottomNavigationBarThemeData(
          selectedItemColor: Colors.blue,
          unselectedItemColor: Colors.grey,
          backgroundColor: Colors.white,
        ),
      ),
      home: const CustomizableNavBar(),
      debugShowCheckedModeBanner: false,
    );
  }
}

3.3 タイプアダプタークラスの追加

Hiveでデータを保持するために、タブデータのタイプアダプターを作成します。

プリミティブ型、リスト、マップなどについてはHiveでタイプアダプターの生成不要で保存・取得を行うことができますが、オリジナルクラスの保存・取得については以下のようなクラスを作成したあとに、タイプアダプターを生成する必要があります。

タイプアダプターを生成したいクラスについて、以下のように@HiveTypeを指定した上で、対象のクラスのフィールド変数には@HiveFieldを指定します。

tab_model.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

import '../pages/favorite_page.dart';
import '../pages/exercise_page.dart';
import '../pages/stats_page.dart';
import '../pages/notification_page.dart';
import '../pages/calendar_page.dart';
import '../pages/profile_page.dart';

part 'tab_model.g.dart';

@HiveType(typeId: 0)
class TabModel {
  @HiveField(0)
  final int id;

  @HiveField(1)
  final int iconCode;

  @HiveField(2)
  final String label;

  @HiveField(3)
  final bool isSelected;

  @HiveField(4)
  final String? pageKey; // 各画面を識別するためのキー

  TabModel({
    required this.id,
    required this.iconCode,
    required this.label,
    required this.isSelected,
    this.pageKey,
  });

  IconData get icon => IconData(iconCode, fontFamily: 'MaterialIcons');

  Widget? get page {
    // pageKeyを基に画面を動的に生成
    switch (pageKey) {
      case 'FavoritePage':
        return const FavoritePage();
      case 'ExercisePage':
        return const ExercisePage();
      case 'StatsPage':
        return const StatsPage();
      case 'NotificationsPage':
        return const NotificationsPage();
      case 'CalendarPage':
        return const CalendarPage();
      case 'ProfilePage':
        return const ProfilePage();
      default:
        return null; // 設定画面などの特殊ケース
    }
  }

  TabModel copyWith({
    int? id,
    int? iconCode,
    String? label,
    bool? isSelected,
    String? pageKey,
  }) {
    return TabModel(
      id: id ?? this.id,
      iconCode: iconCode ?? this.iconCode,
      label: label ?? this.label,
      isSelected: isSelected ?? this.isSelected,
      pageKey: pageKey ?? this.pageKey,
    );
  }
}

上記実装を追加後、以下のコマンドを実行することでタイプアダプタークラスの自動生成が行えます。

flutter pub run build_runner build

実際に生成されたタイプアダプタークラスについては以下のような内容になります。

tab_model.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'tab_model.dart';

// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************

class TabModelAdapter extends TypeAdapter<TabModel> {
  @override
  final int typeId = 0;

  @override
  TabModel read(BinaryReader reader) {
    final numOfFields = reader.readByte();
    final fields = <int, dynamic>{
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
    };
    return TabModel(
      id: fields[0] as int,
      iconCode: fields[1] as int,
      label: fields[2] as String,
      isSelected: fields[3] as bool,
      pageKey: fields[4] as String?,
    );
  }

  @override
  void write(BinaryWriter writer, TabModel obj) {
    writer
      ..writeByte(5)
      ..writeByte(0)
      ..write(obj.id)
      ..writeByte(1)
      ..write(obj.iconCode)
      ..writeByte(2)
      ..write(obj.label)
      ..writeByte(3)
      ..write(obj.isSelected)
      ..writeByte(4)
      ..write(obj.pageKey);
  }

  @override
  int get hashCode => typeId.hashCode;

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is TabModelAdapter &&
          runtimeType == other.runtimeType &&
          typeId == other.typeId;
}

これで実装の展開準備ができたので、コンポーネントなどのアプリ画面に反映していきます。

3.4 main.dartの実装

生成したタイプアダプタークラスのインスタンスについて、Hiveクラスに登録します。

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hive_flutter/hive_flutter.dart';

void main() async {
   WidgetsFlutterBinding.ensureInitialized();
   await Hive.initFlutter();
+  Hive.registerAdapter(TabModelAdapter());
+  await Hive.openBox<TabModel>('tabs');


  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Bottom Navigation',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textTheme: GoogleFonts.notoSansJpTextTheme(
          Theme.of(context).textTheme,
        ),
        bottomNavigationBarTheme: const BottomNavigationBarThemeData(
          selectedItemColor: Colors.blue,
          unselectedItemColor: Colors.grey,
          backgroundColor: Colors.white,
        ),
      ),
      home: const CustomizableNavBar(),
      debugShowCheckedModeBanner: false,
    );
  }
}

3.5 コンポーネントの実装

コンポーネント側の実装について紹介していきます。

以下がCustomizableNavBarの実装です。

custamizable_nav_bar.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../models/tab_model.dart';
import '../pages/tab_settings_page.dart';

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

  @override
  _CustomizableNavBarState createState() => _CustomizableNavBarState();
}

class _CustomizableNavBarState extends State<CustomizableNavBar> {
  int _currentIndex = 0;
  late List<TabModel> _customizableTabs;
  late List<TabModel> _selectedTabs;
  final Box<TabModel> _tabBox = Hive.box<TabModel>('tabs');

  final TabModel _homeTab = TabModel(
    id: 0,
    iconCode: Icons.home.codePoint,
    label: 'ホーム',
    isSelected: true,
    pageKey: null,
  );

  final TabModel _settingsTab = TabModel(
    id: 999,
    iconCode: Icons.settings.codePoint,
    label: '設定',
    isSelected: true,
    pageKey: null,
  );

  @override
  void initState() {
    super.initState();
    _loadTabs();
  }

  void _loadTabs() {
    if (_tabBox.isNotEmpty) {
      setState(() {
        _customizableTabs = _tabBox.values.toList();
        _selectedTabs =
            _customizableTabs.where((tab) => tab.isSelected).toList();
      });
    } else {
      setState(() {
        _customizableTabs = [
          TabModel(
              id: 1,
              iconCode: Icons.favorite.codePoint,
              label: 'お気に入り',
              isSelected: true,
              pageKey: 'FavoritePage'),
          TabModel(
              id: 2,
              iconCode: Icons.directions_run.codePoint,
              label: '運動',
              isSelected: true,
              pageKey: 'ExercisePage'),
          TabModel(
              id: 3,
              iconCode: Icons.bar_chart.codePoint,
              label: '統計',
              isSelected: false,
              pageKey: 'StatsPage'),
          TabModel(
              id: 4,
              iconCode: Icons.notifications.codePoint,
              label: '通知',
              isSelected: false,
              pageKey: 'NotificationsPage'),
          TabModel(
              id: 5,
              iconCode: Icons.calendar_today.codePoint,
              label: 'カレンダー',
              isSelected: false,
              pageKey: 'CalendarPage'),
          TabModel(
              id: 6,
              iconCode: Icons.person.codePoint,
              label: 'プロファイル',
              isSelected: false,
              pageKey: 'ProfilePage'),
        ];
        _selectedTabs =
            _customizableTabs.where((tab) => tab.isSelected).toList();
        _saveTabs();
      });
    }
  }

  void _saveTabs() {
    _tabBox.clear();
    for (var tab in _customizableTabs) {
      _tabBox.add(tab);
    }
  }

  List<TabModel> _generateTabs() {
    return [
      _homeTab,
      ..._selectedTabs,
      _settingsTab,
    ];
  }

  @override
  Widget build(BuildContext context) {
    final tabs = _generateTabs();

    return Scaffold(
      appBar: AppBar(
        title: Text(tabs[_currentIndex].label),
      ),
      body: tabs[_currentIndex].page ?? const Center(child: Text('設定画面')),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) async {
          if (tabs[index].id == _settingsTab.id) {
            final updatedTabs = await Navigator.push<List<TabModel>>(
              context,
              MaterialPageRoute(
                builder: (context) => TabSettingsPage(
                  allTabs: _customizableTabs,
                  onUpdateTabs: (tabs) {
                    setState(() {
                      _customizableTabs = tabs.whereType<TabModel>().toList();
                      _selectedTabs =
                          _customizableTabs.where((tab) => tab.isSelected).toList();
                    });
                    _saveTabs();
                  },
                ),
              ),
            );

            if (updatedTabs != null) {
              setState(() {
                _customizableTabs = updatedTabs;
                _selectedTabs =
                    updatedTabs.where((tab) => tab.isSelected).toList();
              });
            }
          } else {
            setState(() {
              _currentIndex = index;
            });
          }
        },
        items: tabs
            .map((tab) => BottomNavigationBarItem(
                  icon: Icon(IconData(tab.iconCode, fontFamily: 'MaterialIcons')),
                  label: tab.label,
                ))
            .toList(),
      ),
    );
  }
}

以下のような部分で、Hiveからの値の取得を行っています。

final Box<TabModel> _tabBox = Hive.box<TabModel>('tabs');
_customizableTabs = _tabBox.values.toList();

次にタブ設定画面の紹介です。

tab_settings_page.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../models/tab_model.dart';
import '../widgets/icon_picker_dialog.dart';

class TabSettingsPage extends StatefulWidget {
  final List<TabModel> allTabs;
  final Function(List<TabModel>) onUpdateTabs;

  const TabSettingsPage({
    super.key,
    required this.allTabs,
    required this.onUpdateTabs,
  });

  @override
  _TabSettingsPageState createState() => _TabSettingsPageState();
}

class _TabSettingsPageState extends State<TabSettingsPage> {
  late List<TabModel> tempTabs;
  late List<TextEditingController> _controllers;
  final Box<TabModel> _tabBox = Hive.box<TabModel>('tabs');

  @override
  void initState() {
    super.initState();
    tempTabs = List.from(widget.allTabs);
    _initControllers();
  }

  void _initControllers() {
    _controllers = tempTabs
        .map((tab) => TextEditingController(text: tab.label))
        .toList();
  }

  @override
  void dispose() {
    for (var controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  void _toggleTab(TabModel tab) {
    setState(() {
      final index = tempTabs.indexWhere((t) => t.id == tab.id);
      if (index != -1) {
        tempTabs[index] = tempTabs[index].copyWith(isSelected: !tab.isSelected);
      }
    });
  }

  void _updateTabName(int index, String newName) {
    setState(() {
      tempTabs[index] = tempTabs[index].copyWith(label: newName);
    });
  }

  void _updateTabIcon(int index, IconData newIcon) {
    setState(() {
      tempTabs[index] = tempTabs[index].copyWith(iconCode: newIcon.codePoint);
    });
  }

  void _resetTabs() {
    setState(() {
      tempTabs = widget.allTabs.map((tab) => tab.copyWith()).toList();
      for (int i = 0; i < _controllers.length; i++) {
        _controllers[i].text = tempTabs[i].label;
      }
    });
  }

  Future<void> _saveTabs() async {
    await _tabBox.clear();
    for (var tab in tempTabs) {
      await _tabBox.add(tab);
    }
  }

  Future<IconData?> _showIconPicker(BuildContext context) async {
    return await showDialog<IconData>(
      context: context,
      builder: (context) => IconPickerDialog(),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('タブ設定'),
        actions: [
          TextButton(
            onPressed: _resetTabs,
            child: const Text(
              'デフォルトに戻す',
              style: TextStyle(color: Colors.blue),
            ),
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: tempTabs.length,
        itemBuilder: (context, index) {
          final tab = tempTabs[index];
          return Card(
            child: ListTile(
              leading: IconButton(
                icon: Icon(tab.icon),
                onPressed: () async {
                  final selectedIcon = await _showIconPicker(context);
                  if (selectedIcon != null) {
                    _updateTabIcon(index, selectedIcon);
                  }
                },
              ),
              title: TextFormField(
                controller: _controllers[index],
                decoration: const InputDecoration(labelText: 'タブの名前を入力'),
                onChanged: (value) => _updateTabName(index, value),
              ),
              trailing: Checkbox(
                value: tab.isSelected,
                onChanged: (_) => _toggleTab(tab),
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await _saveTabs(); // Hiveに保存
          widget.onUpdateTabs(tempTabs); // 親ウィジェットに更新情報を渡す
          Navigator.pop(context);
        },
        child: const Icon(Icons.save),
      ),
    );
  }
}

保存については、上記のなかで以下のように実装しています。

  Future<void> _saveTabs() async {
    await _tabBox.clear();
    for (var tab in tempTabs) {
      await _tabBox.add(tab);
    }
  }
floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await _saveTabs(); // Hiveに保存
          widget.onUpdateTabs(tempTabs); // 親ウィジェットに更新情報を渡す
          Navigator.pop(context);
        },
        child: const Icon(Icons.save),
      ),

実際に、Andoroidのエミュレーター上では/data/data/com.example.flutter_bottom_navigation_sample/app_flutter/tabs.hiveのパスに保存されていました。

image.png


長くなりましたが、これでHiveの適応・設定の永続化についても完了です。

画面上では以下のような挙動となり、かつ設定も保持されるようになりました。

image.png

意外と実装としてもシンプルな追加で導入することができましたね。

4. まとめ

本記事では、「ユーザーが自由に設定可能なボトムナビゲーションバーの実装」および「Hiveを導入しての設定の永続化」について紹介しました。

スマホを片手で持ったとき、もっとも親指が届きやすいのがボトムナビゲーションバーです。このUI・UXを最適なものとするにあたり、ユーザーが好みに設定できてもよいんじゃないかというノリを推奨したいです。

改善点としては、今回紹介した状態管理の導入・責務の分解などができていないものとして書いていたので、Riverpodの導入などをしてきれいにしていくことで、より実用的なコードとなると思います。

以下実装を貼っています。

実装ファイルの全量は以下です。

.
├── main.dart
├── models
│   ├── tab_model.dart
│   └── tab_model.g.dart
├── pages
│   ├── calendar_page.dart
│   ├── exercise_page.dart
│   ├── favorite_page.dart
│   ├── notification_page.dart
│   ├── profile_page.dart
│   ├── stats_page.dart
│   └── tab_settings_page.dart
└── widgets
    ├── customizable_nav_bar.dart
    └── icon_picker_dialog.dart

main.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hive_flutter/hive_flutter.dart';

import 'models/tab_model.dart';
import 'widgets/customizable_nav_bar.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(TabModelAdapter());
  await Hive.openBox<TabModel>('tabs');
  // await box.clear();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Bottom Navigation',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        textTheme: GoogleFonts.notoSansJpTextTheme(
          Theme.of(context).textTheme,
        ),
        bottomNavigationBarTheme: const BottomNavigationBarThemeData(
          selectedItemColor: Colors.blue,
          unselectedItemColor: Colors.grey,
          backgroundColor: Colors.white,
        ),
      ),
      home: const CustomizableNavBar(),
      debugShowCheckedModeBanner: false,
    );
  }
}

models配下は以下。

tab_model.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

import '../pages/favorite_page.dart';
import '../pages/exercise_page.dart';
import '../pages/stats_page.dart';
import '../pages/notification_page.dart';
import '../pages/calendar_page.dart';
import '../pages/profile_page.dart';

part 'tab_model.g.dart';

@HiveType(typeId: 0)
class TabModel {
  @HiveField(0)
  final int id;

  @HiveField(1)
  final int iconCode;

  @HiveField(2)
  final String label;

  @HiveField(3)
  final bool isSelected;

  @HiveField(4)
  final String? pageKey; // 各画面を識別するためのキー

  TabModel({
    required this.id,
    required this.iconCode,
    required this.label,
    required this.isSelected,
    this.pageKey,
  });

  IconData get icon => IconData(iconCode, fontFamily: 'MaterialIcons');

  Widget? get page {
    // pageKeyを基に画面を動的に生成
    switch (pageKey) {
      case 'FavoritePage':
        return const FavoritePage();
      case 'ExercisePage':
        return const ExercisePage();
      case 'StatsPage':
        return const StatsPage();
      case 'NotificationsPage':
        return const NotificationsPage();
      case 'CalendarPage':
        return const CalendarPage();
      case 'ProfilePage':
        return const ProfilePage();
      default:
        return null; // 設定画面などの特殊ケース
    }
  }

  TabModel copyWith({
    int? id,
    int? iconCode,
    String? label,
    bool? isSelected,
    String? pageKey,
  }) {
    return TabModel(
      id: id ?? this.id,
      iconCode: iconCode ?? this.iconCode,
      label: label ?? this.label,
      isSelected: isSelected ?? this.isSelected,
      pageKey: pageKey ?? this.pageKey,
    );
  }
}

widgets配下の2ファイル。

customizable_nav_bar.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../models/tab_model.dart';
import '../pages/tab_settings_page.dart';

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

  @override
  _CustomizableNavBarState createState() => _CustomizableNavBarState();
}

class _CustomizableNavBarState extends State<CustomizableNavBar> {
  int _currentIndex = 0;
  late List<TabModel> _customizableTabs;
  late List<TabModel> _selectedTabs;
  final Box<TabModel> _tabBox = Hive.box<TabModel>('tabs');

  final TabModel _homeTab = TabModel(
    id: 0,
    iconCode: Icons.home.codePoint,
    label: 'ホーム',
    isSelected: true,
    pageKey: null,
  );

  final TabModel _settingsTab = TabModel(
    id: 999,
    iconCode: Icons.settings.codePoint,
    label: '設定',
    isSelected: true,
    pageKey: null,
  );

  @override
  void initState() {
    super.initState();
    _loadTabs();
  }

  void _loadTabs() {
    if (_tabBox.isNotEmpty) {
      setState(() {
        _customizableTabs = _tabBox.values.toList();
        _selectedTabs =
            _customizableTabs.where((tab) => tab.isSelected).toList();
      });
    } else {
      setState(() {
        _customizableTabs = [
          TabModel(
              id: 1,
              iconCode: Icons.favorite.codePoint,
              label: 'お気に入り',
              isSelected: true,
              pageKey: 'FavoritePage'),
          TabModel(
              id: 2,
              iconCode: Icons.directions_run.codePoint,
              label: '運動',
              isSelected: true,
              pageKey: 'ExercisePage'),
          TabModel(
              id: 3,
              iconCode: Icons.bar_chart.codePoint,
              label: '統計',
              isSelected: false,
              pageKey: 'StatsPage'),
          TabModel(
              id: 4,
              iconCode: Icons.notifications.codePoint,
              label: '通知',
              isSelected: false,
              pageKey: 'NotificationsPage'),
          TabModel(
              id: 5,
              iconCode: Icons.calendar_today.codePoint,
              label: 'カレンダー',
              isSelected: false,
              pageKey: 'CalendarPage'),
          TabModel(
              id: 6,
              iconCode: Icons.person.codePoint,
              label: 'プロファイル',
              isSelected: false,
              pageKey: 'ProfilePage'),
        ];
        _selectedTabs =
            _customizableTabs.where((tab) => tab.isSelected).toList();
        _saveTabs();
      });
    }
  }

  void _saveTabs() {
    _tabBox.clear();
    for (var tab in _customizableTabs) {
      _tabBox.add(tab);
    }
  }

  List<TabModel> _generateTabs() {
    return [
      _homeTab,
      ..._selectedTabs,
      _settingsTab,
    ];
  }

  @override
  Widget build(BuildContext context) {
    final tabs = _generateTabs();

    return Scaffold(
      appBar: AppBar(
        title: Text(tabs[_currentIndex].label),
      ),
      body: tabs[_currentIndex].page ?? const Center(child: Text('設定画面')),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        onTap: (index) async {
          if (tabs[index].id == _settingsTab.id) {
            final updatedTabs = await Navigator.push<List<TabModel>>(
              context,
              MaterialPageRoute(
                builder: (context) => TabSettingsPage(
                  allTabs: _customizableTabs,
                  onUpdateTabs: (tabs) {
                    setState(() {
                      _customizableTabs = tabs.whereType<TabModel>().toList();
                      _selectedTabs =
                          _customizableTabs.where((tab) => tab.isSelected).toList();
                    });
                    _saveTabs();
                  },
                ),
              ),
            );

            if (updatedTabs != null) {
              setState(() {
                _customizableTabs = updatedTabs;
                _selectedTabs =
                    updatedTabs.where((tab) => tab.isSelected).toList();
              });
            }
          } else {
            setState(() {
              _currentIndex = index;
            });
          }
        },
        items: tabs
            .map((tab) => BottomNavigationBarItem(
                  icon: Icon(IconData(tab.iconCode, fontFamily: 'MaterialIcons')),
                  label: tab.label,
                ))
            .toList(),
      ),
    );
  }
}

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

class IconPickerDialog extends StatelessWidget {
  final List<IconData> _iconOptions = [
    Icons.home,
    Icons.favorite,
    Icons.settings,
    Icons.directions_run,
    Icons.star,
    Icons.work,
  ];

  IconPickerDialog({super.key});

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('アイコンを選択'),
      content: SizedBox(
        width: double.maxFinite,
        child: GridView.builder(
          shrinkWrap: true,
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 4,
            crossAxisSpacing: 10,
            mainAxisSpacing: 10,
          ),
          itemCount: _iconOptions.length,
          itemBuilder: (context, index) {
            return IconButton(
              icon: Icon(_iconOptions[index]),
              onPressed: () {
                Navigator.pop(context, _iconOptions[index]);
              },
            );
          },
        ),
      ),
    );
  }
}

pagesは以下です。

tab_settings_page.dart
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import '../models/tab_model.dart';
import '../widgets/icon_picker_dialog.dart';

class TabSettingsPage extends StatefulWidget {
  final List<TabModel> allTabs;
  final Function(List<TabModel>) onUpdateTabs;

  const TabSettingsPage({
    super.key,
    required this.allTabs,
    required this.onUpdateTabs,
  });

  @override
  _TabSettingsPageState createState() => _TabSettingsPageState();
}

class _TabSettingsPageState extends State<TabSettingsPage> {
  late List<TabModel> tempTabs;
  late List<TextEditingController> _controllers;
  final Box<TabModel> _tabBox = Hive.box<TabModel>('tabs');

  @override
  void initState() {
    super.initState();
    tempTabs = List.from(widget.allTabs);
    _initControllers();
  }

  void _initControllers() {
    _controllers = tempTabs
        .map((tab) => TextEditingController(text: tab.label))
        .toList();
  }

  @override
  void dispose() {
    for (var controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  void _toggleTab(TabModel tab) {
    setState(() {
      final index = tempTabs.indexWhere((t) => t.id == tab.id);
      if (index != -1) {
        tempTabs[index] = tempTabs[index].copyWith(isSelected: !tab.isSelected);
      }
    });
  }

  void _updateTabName(int index, String newName) {
    setState(() {
      tempTabs[index] = tempTabs[index].copyWith(label: newName);
    });
  }

  void _updateTabIcon(int index, IconData newIcon) {
    setState(() {
      tempTabs[index] = tempTabs[index].copyWith(iconCode: newIcon.codePoint);
    });
  }

  void _resetTabs() {
    setState(() {
      tempTabs = widget.allTabs.map((tab) => tab.copyWith()).toList();
      for (int i = 0; i < _controllers.length; i++) {
        _controllers[i].text = tempTabs[i].label;
      }
    });
  }

  Future<void> _saveTabs() async {
    await _tabBox.clear();
    for (var tab in tempTabs) {
      await _tabBox.add(tab);
    }
  }

  Future<IconData?> _showIconPicker(BuildContext context) async {
    return await showDialog<IconData>(
      context: context,
      builder: (context) => IconPickerDialog(),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('タブ設定'),
        actions: [
          TextButton(
            onPressed: _resetTabs,
            child: const Text(
              'デフォルトに戻す',
              style: TextStyle(color: Colors.blue),
            ),
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: tempTabs.length,
        itemBuilder: (context, index) {
          final tab = tempTabs[index];
          return Card(
            child: ListTile(
              leading: IconButton(
                icon: Icon(tab.icon),
                onPressed: () async {
                  final selectedIcon = await _showIconPicker(context);
                  if (selectedIcon != null) {
                    _updateTabIcon(index, selectedIcon);
                  }
                },
              ),
              title: TextFormField(
                controller: _controllers[index],
                decoration: const InputDecoration(labelText: 'タブの名前を入力'),
                onChanged: (value) => _updateTabName(index, value),
              ),
              trailing: Checkbox(
                value: tab.isSelected,
                onChanged: (_) => _toggleTab(tab),
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          await _saveTabs(); // Hiveに保存
          widget.onUpdateTabs(tempTabs); // 親ウィジェットに更新情報を渡す
          Navigator.pop(context);
        },
        child: const Icon(Icons.save),
      ),
    );
  }
}

他のページファイルはほぼテンプレート的で、以下のようなものです。

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

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Text(
          'カレンダーページの内容',
          style: TextStyle(fontSize: 18),
        ),
      ),
    );
  }
}

参考文献

4
2
1

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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?