MVVM構成で動的にタブを追加する

MVVM構成で動的にタブを追加する

はじめに

今回は、Flutter で動的なタブを MVVM 構成で制御する方法について解説します。

サンプルアプリのイメージです。

dynamic-tab.gif

とりあえず今回はriverpodなどの状態管理のライブラリは使用せずに実装しています。

構成

このアプリは以下の主要コンポーネントで構成されています。

  1. データモデル(TabModel: tab_model.dart
  2. ビューモデル(TabViewModel: tab_view_model.dart
  3. プロバイダー(TabViewModelProvider: tab_view_model_provider.dart
  4. UI レイヤー(TabScreen: main.dart

これらのコンポーネント間の関係性とデータフローを視覚化すると以下のようになります。

mvvm-diagram.png

mvvm-diagram2.png

データモデル

まず、タブの基本構造となる TabModel クラスを見ていきます。

"tab_model.dart"
class TabModel {
  final String id;
  final String title;

  TabModel({
    required this.id,
    required this.title,
  });

  TabModel copyWith({
    String? title,
  }) {
    return TabModel(
      id: id,
      title: title ?? this.title,
    );
  }
}

このモデルでは、一意の ID とタイトルを持ちイミュータブル(不変)に実装しています。

copyWithメソッドは既存のオブジェクトのコピーを作成しながら、特定のプロパティを変更するためのメソッドです。

ViewModel

次に、状態管理を行うTabViewModelクラスです。

"tab_view_model.dart"
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import 'tab_model.dart';

class TabViewModel extends ChangeNotifier {
  final List<TabModel> _tabs = [];
  List<TabModel> get tabs => _tabs;

  /// 現在のタブのID
  String? _currentTabId;

  // 現在のタブのインデックスを取得(DefaultTabControllerとの連携用)
  int get currentIndex => _currentTabId != null ? _tabs.indexWhere((tab) => tab.id == _currentTabId) : 0;

  // 新しいタブを追加
  void addTab({String? title}) {
    final newTab = TabModel(
      id: const Uuid().v4(),
      title: title ?? 'タブ ${_tabs.length + 1}',
    );

    _tabs.add(newTab);
    _currentTabId = newTab.id; // 新しいタブのIDを設定
    notifyListeners();
  }

  // タブを削除
  void removeTab(String id) {
    final index = _tabs.indexWhere((tab) => tab.id == id);
    // タブが見つからない場合は何もしない
    if (index == -1) return;

    // タブを削除し、削除したタブの情報を取得
    final removedTab = _tabs.removeAt(index);

    // 削除したタブが現在選択中のタブではない場合は何もしない
    if (_currentTabId != removedTab.id) {
      notifyListeners();
      return;
    }

    // タブが残っていなければnullに設定
    if (_tabs.isEmpty) {
      _currentTabId = null;
      notifyListeners();
      return;
    }

    // 削除位置に別のタブがあれば、その位置のタブを選択
    if (index < _tabs.length) {
      _currentTabId = _tabs[index].id;
      notifyListeners();
      return;
    }

    // 削除位置が最後だった場合、新しい最後のタブを選択
    _currentTabId = _tabs.last.id;
    notifyListeners();
  }

  // タブを更新
  void updateTab(TabModel updatedTab) {
    final index = _tabs.indexWhere((tab) => tab.id == updatedTab.id);
    if (index == -1) return;

    _tabs[index] = updatedTab;
    notifyListeners();
  }

  // スワイプ時に現在のタブを変更(インデックスで)
  void setCurrentIndex(int index) {
    // インデックスが範囲外の場合は何もしない
    if (index < 0 || index >= _tabs.length) return;

    final newTabId = _tabs[index].id;
    // 既に選択中のタブの場合は何もしない
    if (_currentTabId == newTabId) return;

    _currentTabId = newTabId;
    notifyListeners();
  }
}

この ViewModel には基本的な CRUD 処理が実装されています。

  • タブの追加(addTab)
  • タブの削除(removeTab)
  • タブの更新(updateTab)

その他、DefaultTabControllerでスワイプ時にタブが切り替わった際の index の管理などを行っています。

  • 現在のタブ位置の制御(setCurrentIndex)

各操作では必ずnotifyListeners()を呼び出し、UI の更新をトリガーとしています。これにより、状態の変更が即座に画面に反映されます。

また、ChangeNotifierを使用した実装の場合、全てのタブを一つの ViewModel で管理するので、現在選択中のタブを_currentTabIdとして内部状態で保持する必要があります。

Riverpodを使用した実装の場合タブごとに別々の ViewModel インスタンスを作成されるので「現在選択中」という概念は持ちません。そのため、この場合は後に解説するDefaultTabControllerで ViewModel から現在のタブの index を取得する処理もありません。

プロバイダー

次に状態を子ウィジェットに共有するためのTabViewModelProviderを見ていきます。

"tab_view_model_provider.dart"
import 'package:flutter/material.dart';
import 'tab_view_model.dart';

class TabViewModelProvider extends InheritedNotifier<TabViewModel> {
  const TabViewModelProvider({
    Key? key,
    required TabViewModel viewModel,
    required Widget child,
  }) : super(key: key, notifier: viewModel, child: child);

  static TabViewModel of(BuildContext context) {
    final provider = context.dependOnInheritedWidgetOfExactType<TabViewModelProvider>();
    if (provider == null) {
      throw Exception('TabViewModelProviderが見つかりませんでした。');
    }
    return provider.notifier!;
  }
}

TabViewModelProviderInheritedNotifierを継承し、基底クラスにnotifier: viewModelを渡しています。

InheritedNotifierによってウィジェットツリーの上位から下位へデータの変更を監視し、依存するウィジェットを自動的に再構築します。

以下のコードはInheritedNotifierソースコードの一部ですが、InheritedNotifier は内部で notifier オブジェクトにリスナーとして_handleUpdateを登録しています。

viewModelnotifyListeners()が呼び出されると、_handleUpdateメソッドが実行され、依存するウィジェットに再構築が通知されます。

"inherited_notifier.dart"
abstract class InheritedNotifier<T extends Listenable> extends InheritedWidget {
  /// Create an inherited widget that updates its dependents when [notifier]
  /// sends notifications.
  const InheritedNotifier({super.key, this.notifier, required super.child});

  /// The [Listenable] object to which to listen.
  ///
  /// Whenever this object sends change notifications, the dependents of this
  /// widget are triggered.
  ///
  /// By default, whenever the [notifier] is changed (including when changing to
  /// or from null), if the old notifier is not equal to the new notifier (as
  /// determined by the `==` operator), notifications are sent. This behavior
  /// can be overridden by overriding [updateShouldNotify].
  ///
  /// While the [notifier] is null, no notifications are sent, since the null
  /// object cannot itself send notifications.
  final T? notifier;

  
  bool updateShouldNotify(InheritedNotifier<T> oldWidget) {
    return oldWidget.notifier != notifier;
  }

  
  InheritedElement createElement() => _InheritedNotifierElement<T>(this);
}

class _InheritedNotifierElement<T extends Listenable> extends InheritedElement {
  _InheritedNotifierElement(InheritedNotifier<T> widget) : super(widget) {
    widget.notifier?.addListener(_handleUpdate);
  }

  bool _dirty = false;

  
  void update(InheritedNotifier<T> newWidget) {
    final T? oldNotifier = (widget as InheritedNotifier<T>).notifier;
    final T? newNotifier = newWidget.notifier;
    if (oldNotifier != newNotifier) {
      oldNotifier?.removeListener(_handleUpdate);
      newNotifier?.addListener(_handleUpdate);
    }
    super.update(newWidget);
  }

  
  Widget build() {
    if (_dirty) {
      notifyClients(widget as InheritedNotifier<T>);
    }
    return super.build();
  }

  void _handleUpdate() {
    _dirty = true;
    markNeedsBuild();
  }

  
  void notifyClients(InheritedNotifier<T> oldWidget) {
    super.notifyClients(oldWidget);
    _dirty = false;
  }

  
  void unmount() {
    (widget as InheritedNotifier<T>).notifier?.removeListener(_handleUpdate);
    super.unmount();
  }
}

main.dart

最後に UI 部分の実装です。

"main.dart"
import 'package:flutter/material.dart';
import 'package:sample_dynamic_tab/tab_model.dart';
import 'tab_view_model.dart';
import 'tab_view_model_provider.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '動的タブアプリ',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const TabScreen(),
    );
  }
}

class TabScreen extends StatefulWidget {
  const TabScreen({Key? key}) : super(key: key);

  
  State<TabScreen> createState() => _TabScreenState();
}

class _TabScreenState extends State<TabScreen> {
  final TabViewModel _viewModel = TabViewModel();

  
  void initState() {
    super.initState();
    // 初期タブを追加
    _viewModel.addTab(title: 'ホーム');
  }

  
  Widget build(BuildContext context) {
    return TabViewModelProvider(
      viewModel: _viewModel,
      child: Builder(
        builder: (context) {
          final viewModel = TabViewModelProvider.of(context);

          return DefaultTabController(
            length: viewModel.tabs.length,
            initialIndex: viewModel.currentIndex,
            animationDuration: const Duration(milliseconds: 300),
            child: Builder(
              builder: (context) {
                // DefaultTabControllerの変更を監視
                final tabController = DefaultTabController.of(context);
                tabController.addListener(() {
                  // タブが切り替わったら、ViewModelを更新
                  if (tabController.indexIsChanging == false && tabController.index != viewModel.currentIndex) {
                    viewModel.setCurrentIndex(tabController.index);
                  }
                });

                return Scaffold(
                  appBar: AppBar(
                    title: const Text('動的タブアプリ'),
                    actions: [
                      IconButton(
                        icon: const Icon(Icons.add),
                        onPressed: () => _showAddTabDialog(context),
                      ),
                    ],
                    bottom: TabBar(
                      // タブを左寄せに配置する
                      tabAlignment: TabAlignment.start,
                      // タブをスクロール可能にする
                      isScrollable: true,
                      tabs: viewModel.tabs.map((tab) {
                        return Tab(
                          child: Row(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              Text(tab.title),
                              const SizedBox(width: 8),
                              InkWell(
                                onTap: () {
                                  viewModel.removeTab(tab.id);
                                },
                                child: const Icon(
                                  Icons.close,
                                  size: 16,
                                ),
                              ),
                            ],
                          ),
                        );
                      }).toList(),
                    ),
                  ),
                  body: TabBarView(
                    children: viewModel.tabs.map((tab) {
                      return Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              tab.title,
                              style: const TextStyle(
                                fontSize: 24,
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                            const SizedBox(height: 16),
                            ElevatedButton(
                              onPressed: () => _showEditTabDialog(context, tab),
                              child: const Text('このタブを編集'),
                            ),
                          ],
                        ),
                      );
                    }).toList(),
                  ),
                );
              },
            ),
          );
        },
      ),
    );
  }

  void _showAddTabDialog(BuildContext context) {
    final titleController = TextEditingController();
    final viewModel = TabViewModelProvider.of(context);

    showDialog(
      context: context,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('新しいタブを追加'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: titleController,
                decoration: const InputDecoration(labelText: 'タイトル'),
              ),
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('キャンセル'),
            ),
            TextButton(
              onPressed: () {
                final previousLength = viewModel.tabs.length;
                viewModel.addTab(
                  title: titleController.text.isNotEmpty ? titleController.text : 'New Tab',
                );
                Navigator.pop(context);

                // 追加されたタブに切り替える
                if (viewModel.tabs.length > previousLength) {
                  // ビルド後にタブコントローラーを更新
                  WidgetsBinding.instance.addPostFrameCallback((_) {
                    final tabController = DefaultTabController.of(context);
                    tabController.animateTo(viewModel.tabs.length - 1);
                  });
                }
              },
              child: const Text('追加'),
            ),
          ],
        );
      },
    );
  }

  void _showEditTabDialog(BuildContext context, TabModel tab) {
    final titleController = TextEditingController(text: tab.title);
    final viewModel = TabViewModelProvider.of(context);

    showDialog(
      context: context,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('タブを編集'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: titleController,
                decoration: const InputDecoration(labelText: 'タイトル'),
              ),
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('キャンセル'),
            ),
            TextButton(
              onPressed: () {
                viewModel.updateTab(
                  tab.copyWith(
                    title: titleController.text,
                  ),
                );
                Navigator.pop(context);
              },
              child: const Text('保存'),
            ),
          ],
        );
      },
    );
  }
}

ポイントとなる箇所を解説します。

ViewModel の初期化処理

class _TabScreenState extends State<TabScreen> {
  final TabViewModel _viewModel = TabViewModel();

  
  void initState() {
    super.initState();
    // 初期タブを追加
    _viewModel.addTab(title: 'ホーム');
  }

StatefulWidget の状態クラス内で ViewModel を初期化しています。

initState内で初期タブを追加し、アプリ起動時に表示するタブを設定しています。

InheritedNotifier を使った状態共有

return TabViewModelProvider(
  viewModel: _viewModel,
  child: Builder(
    builder: (context) {
      final viewModel = TabViewModelProvider.of(context);

TabViewModelProvider(InheritedNotifier のサブクラス)を使用して、ウィジェットツリー全体で_viewModelを利用可能にしています。

直接の子ウィジェットでcontextを通じてViewModelにアクセスするためにBuilderを使用しています。

また、TabViewModelProvider.of(context)を呼び出すことで、このウィジェットは ViewModel の変更を監視するようになります

DefaultTabController との双方向バインディング

return DefaultTabController(
  length: viewModel.tabs.length,
  initialIndex: viewModel.currentIndex,
  animationDuration: const Duration(milliseconds: 300),
  child: Builder(
    builder: (context) {
      // DefaultTabControllerの変更を監視
      final tabController = DefaultTabController.of(context);
      tabController.addListener(() {
        // タブが切り替わったら、ViewModelを更新
        if (tabController.indexIsChanging == false && tabController.index != viewModel.currentIndex) {
          viewModel.setCurrentIndex(tabController.index);
        }
      });

tabController.addListenerで UI からの変更を監視し、その中で ViewModel で保持する現在選択されているタブの index を更新しています。

これによって、UI からの操作(タブタップ)と ViewModel 操作の両方でタブ選択が同期されます。

最後に

次回は、状態管理の riverpod を使用して作り直してみたい思います。

今回作成したアプリのコードはGitHub で公開しています。

参考記事