WidgetをViewとした場合、Viewに対する状態(State
)と処理(Presentation logic
)を別々のクラスに分けたい、というニーズはあると思います。
MVC
, MVP
, MVVM
などがありますが、StateNotifier
を使うことで MVVM に近い構成にできるのではないでしょうか?
MVVMで例える Widget と StateNotifier(Notifier + State) の役割
Model: StateNotifierのState部分
View: StateNotifierを利用するWidget(Page)
ViewModel: StateNotifierのNotifier部分
- WidgetがNotifierにユーザーアクションを伝える
- Notifierは適宜別クラスとも連携して処理を行い、Stateを更新する
- Stateを
watch
しているWidgetが再構築される
という処理の流れになります。
試しに書いてみる
実際に書いた方がわかりやすいと思うので、一つずつ定義していきます。
サンプル画面の機能
- 数字を0からカウントアップできる(メイン・サブの2つを用意)
- アプリ次回起動時も前回のカウントから再開できる
非常にシンプルな画面なのでStateProviderでも作ることができそうですが、
実際のアプリではもっと複雑になるのが常なので、その構成の一端と捉えてもらえればと思います。
まず、ファイル及びクラス構成を考えましょう。
-
home_page.dart
- HomePage (View = 画面)
-
home_page_state.dart
- HomePageState (Model = 状態)
-
home_page_notifier.dart
- HomePageNotifier (ViewModel = 操作)
ここでは、画面、状態、操作にファイル・クラスを分けていきます。
なお、各命名はあくまで一例で、完全に好みで分かれることになるかと思います。
ここでは分かりやすいように、1画面1VMという前提のもと、全てのクラスに Page
という文字を入れています。
「HomePage」を HomeScreen
にする人もいれば、 HomeView
とする人もいます。
「HomePageNotifier」は、 HomePageController
と命名しても分かりやすいと思います。
Stateクラスの実装
まずは、ViewにもNotifierにも依存しないStateから定義していきましょう。
Freezed
[1] を使用して状態クラスを生成しています。
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
// 生成されるファイル名を指定する( `生成元ファイル名.freezed.dart` )
part 'home_page_state.freezed.dart';
class HomePageState with _$HomePageState {
const factory HomePageState({
/// Main Count
(0) int mainCount,
/// Sub Count
(0) int subCount,
}) = _HomePageState;
}
StateNotifierクラスの定義
StateNotifierの持つ状態クラスには、前項で作成した HomePageState
を指定します。
必須ではありませんが、ViewModelではサービスクラスやリポジトリなど他のクラスとやりとりすることも少なくないと思います。
他のProviderにアクセスするために必要な Reader
を引数で渡しています。
これは、後ほどProviderをグローバルに定義するときに渡します。
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'home_page_state.dart';
class HomePageNotifier extends StateNotifier<HomePageState> {
HomePageNotifier(this._read) : super(const HomePageState());
// Reader 型をフィールドに持っておくことで、HomePageNotifierから他のProviderを読み取ることができるようになります
final Reader _read;
// メインカウントを+1する
void increaseMainCount() {
state = state.copyWith(mainCount: state.mainCount + 1);
}
// サブカウントを+1する
void increaseSubCount() {
state = state.copyWith(subCount: state.subCount + 1);
}
// すべてのカウントを0に戻す
void resetAllCount() {
state = state.copyWith(
mainCount: 0,
subCount: 0,
);
}
}
メイン・サブのカウントを増やすメソッドと、リセットするメソッドを定義しました。
StateNotifierを使うと、このようにView関連のロジックを任すことができます。
Providerの定義
ViewModelとして使うStateNotifierの場合、画面の破棄によってProviderも破棄させたいので autoDispose
修飾子を付けています。
そうすることで、一度 homePageNotifierProvider
を参照している画面を閉じてからもう一度その画面を開くと、Stateも正しくリセットされます。
final homePageNotifierProvider =
StateNotifierProvider.autoDispose<HomePageNotifier, HomePageState>((ref) {
// Reader を渡しています(不要なら省略可能)
return HomePageNotifier(ref.read);
});
Widgetの定義
CounsumerWidget
や HookWidget
等を使って StateNotifierProvider
を読み取りましょう。
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context, ScopedReader watch) {
// HomePageState
final pageState = watch(homePageNotifierProvider);
// HomePageNotifier
final pageNotifier = watch(homePageNotifierProvider.notifier);
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: pageNotifier.resetAllCount,
child: Icon(Icons.exposure_zero),
),
body: ListView(
children: [
ListTile(
title: Text('Main Count ${pageState.mainCount}'),
onTap: pageNotifier.increaseMainCount,
),
ListTile(
title: Text('Sub Count ${pageState.subCount}'),
onTap: pageNotifier.increaseSubCount,
),
],
),
);
}
}
- Stateのメイン・サブのカウント数を表示
- タップでそれぞれをカウントアップ
- FloatingActionButton押下でカウントリセット
が行えるWidgetとStateNotifierProviderを作成できました🎉