はじめに
今回は、Flutter で動的なタブを MVVM 構成で制御する方法について解説します。
サンプルアプリのイメージです。
とりあえず今回はriverpodなどの状態管理のライブラリは使用せずに実装しています。
構成
このアプリは以下の主要コンポーネントで構成されています。
- データモデル(TabModel:
tab_model.dart
) - ビューモデル(TabViewModel:
tab_view_model.dart
) - プロバイダー(TabViewModelProvider:
tab_view_model_provider.dart
) - UI レイヤー(TabScreen:
main.dart
)
これらのコンポーネント間の関係性とデータフローを視覚化すると以下のようになります。
データモデル
まず、タブの基本構造となる TabModel クラスを見ていきます。
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
クラスです。
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
を見ていきます。
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!;
}
}
TabViewModelProvider
はInheritedNotifier
を継承し、基底クラスにnotifier: viewModel
を渡しています。
InheritedNotifier
によってウィジェットツリーの上位から下位へデータの変更を監視し、依存するウィジェットを自動的に再構築します。
以下のコードはInheritedNotifier
のソースコードの一部ですが、InheritedNotifier は内部で notifier オブジェクトにリスナーとして_handleUpdate
を登録しています。
viewModel
でnotifyListeners()
が呼び出されると、_handleUpdate
メソッドが実行され、依存するウィジェットに再構築が通知されます。
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 部分の実装です。
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 で公開しています。