【riverpod】画面遷移時にStateNotifierへ値を渡す方法
こんにちは、とりかつ(@torikatsu923)です。
普段私はFlutterで開発しており、riveropd+StateNotifier+freezedでMVVMライクに状態管理をしています。 先日、画面遷移を実装する際、画面遷移時にStateNotifierで作ったViewModelのコンストラクタへ渡そうとしてうまく渡せずとても苦戦しました。 そこで、今回の記事では画面遷移時にViewModelへ値を渡す方法について紹介していこうと思います。
リポジトリ
実装はこちらのリポジトリになります。 github.com
作ろうとしているもの
今回は以下のようなアプリを作ろうとしています。
HomePage
で入力したテキストが、PopUpPage
で表示されるというシンプルなアプリです。
最初の実装の概要
初め、PopUpPageのViewModelと状態クラスの実装は以下のようにしていました。
class PopUpViewModel extends StateNotifier<PopUpState> { PopUpViewModel(): super(PopUpState()); void setText(String text) { state = state.copyWith(text: text); } String get text => state.text; } @freezed abstract class PopUpState with _$PopUpState { factory PopUpState({ @Default('') String text, }) = _PopUpState; PopUpState._(); }
実装はとてもシンプルでsetText
で入力された文字列を受け取って、state
へセットします。
一方viewからは以下のように文字列をセットします。
class PopUpPage extends ConsumerWidget { const PopUpPage(this.text, {Key? key}) : super(key: key); final String text; @override Widget build(BuildContext context, ScopedReader watch) { context.read(popUpViewModelProvider.notifier).setText(text); return Scaffold(......
最初の実装の問題点
これで一応動作はします。 しかし、この実装には以下のような問題があります。
- ビルド後に
setText
をするので、余分なビルドが走ってしまう setText
が公開されているので、いつでもtext
を変更できてしまう- viewModel側で、
text
を使った初期化処理をコンストラクタで行うことができない
一応動くものを作るときはこれでもいいかもしれません。 しかし、それなりの規模感のものを作ろうとしたとき、このような実装はいつか負の遺産になりえます。
問題点の解決方法
前述の問題点を回避するためには、text
をPopUpViewModel
のコンストラクタで受け取って初期化する必要があります。
class PopUpViewModel extends StateNotifier<PopUpState> { PopUpViewModel({ required String text }): super(PopUpState(text: text)); String get text => state.text; } @freezed abstract class PopUpState with _$PopUpState { factory PopUpState({ required String text, }) = _PopUpState; PopUpState._(); }
こうすることで、ViewModelの初期化時にtext
をセットできるので、先ほどの問題点は解消されます。
新たな問題点
しかし、ここで新たな問題が発生しました。 それはいかにしてProviderを初期化するかということです。 riverpodのProviderはグローバル変数として定義されます。それゆえViewやViewModelよりもはるかに長いライフサイクルを持ちます。 そのため、以下のような実装ができません。
final popUpViewModelProvider = StateNotifierProvider<PopUpViewModel, PopUpState>( (ref) => PopUpViewModel()); // コンストラクタへtextを渡す必要があるのでコンパイルエラーが出る
また、グローバルなProviderにtext
をセットしたとき、他のViewからもtext
へアクセスすることが可能になります。
値を他のViewに見せたくないときにこれだと困ってしまいます。
解決策
困って色々ググっていたら、以下のriverpodの作者のツイートを見つけました。
If this is about "I don't want to pass the Family key all around the widget tree", a solution is to create an extra provider that exposes the combination of the family + the key
— Remi Rousselet (@remi_rousselet) 2020年7月2日
This can be combined with `ProviderScope.overrides` for fancy stuffhttps://t.co/Dkq7YForRB pic.twitter.com/cqi1HdCLPK
どうやらScopeedProviderを使うことで動的にProviderを初期化できるようです。
さらに、providerへアクセスできる範囲を制限できるので、見せたくないViewからtext
を参照することができなくなります。
ScopedProviderを使うように修正
修正の流れは以下のようになります。
- StateNotiferをfamily修飾子を使用して作成する
- 未実装の
ScopedProvider
を作成する PopUpPage
をNavigator.push
するとき、ProviderScope
でPopUpPage
を包むProviderScope
内でScopedProvider
の実装をオーバーライドする
1. StateNotiferをfamily修飾子を使用して作成する
family
を使って以下のようにstateNotifierProvider
を作成します。
こうすることでStateNotifierProvider作成時にtext
をコンストラクタに渡すことができるようになります。
final popUpViewModelFamily = StateNotifierProvider.family<PopUpViewModel, PopUpState, String>( (ref, text) => PopUpViewModel(text: text));
2. 未実装のScopedProvider
を作成する
以下のように未実装のScopedProvider
を作成します。
final popUpViewModelProvider = ScopedProvider<StateNotifierProvider<PopUpViewModel, PopUpState>>( (ref) => throw Error());
ここでエラーを投げるようにするのは、オーバーライドされていないpopUpViewModelProvider
が思わぬ使い方をされないようにするためです。
APIリファレンスでもエラーを投げるよう実装することが推奨されていました。
Note: We made our ScopedProvider throw by default, as our list items by design requires an index to be specified. Another possibility would be to return null and have the item handle the null scenario.
3. PopUpPage
をNavigator.push
するとき、ProviderScope
でPopUpPage
を包む
次にPopUpPage
にstaticなMaterialPageRoute
を作成するメソッドを生やしていきます。
static MaterialPageRoute<PopUpPage> createPageRoute(String text) { return MaterialPageRoute(builder: (BuildContext context) { return ProviderScope( child: PopUpPage(), ); }); }
4. ProviderScope
内でScopedProvider
の実装をオーバーライドする
3で生やしたメソッド内で、overrideAs
を使ってpopUpViewModelProvider
をオーバーライドします。
static MaterialPageRoute<PopUpPage> createPageRoute(String text) { return MaterialPageRoute(builder: (BuildContext context) { return ProviderScope( overrides: [ popUpViewModelProvider .overrideAs((watch) => popUpViewModelFamily(text)), ], child: PopUpPage(), ); }); }
以上で実装は完了です。
あとは、HomePage
側で以下のようにPopUpPage
をNavigator.push
すれば期待通りに動作します。
final text = context.read(homeViewModelProvider).text; Navigator.push(context, PopUpPage.createPageRoute(text));
おわりに
今回の記事では、riverpodを使ってMVVMな実装をしているときに、画面遷移時にViewModelへ値をいい感じに渡す方法について紹介しました。 最初は少しトリッキーな実装のように感じていましたが、いろいろな課題点をクリアして、スッキリ実装できるriverpodの柔軟さに改めて驚きました。
それではよい開発ライフを!