torikatsu.dev

Flutterとかプログラミングとかガジェットとか書きます

【riverpod】画面遷移時にStateNotifierへ値を渡す方法

こんにちは、とりかつ(@torikatsu923)です。

普段私はFlutterで開発しており、riveropd+StateNotifier+freezedでMVVMライクに状態管理をしています。 先日、画面遷移を実装する際、画面遷移時にStateNotifierで作ったViewModelのコンストラクタへ渡そうとしてうまく渡せずとても苦戦しました。 そこで、今回の記事では画面遷移時にViewModelへ値を渡す方法について紹介していこうと思います。

リポジトリ

実装はこちらのリポジトリになります。 github.com

作ろうとしているもの

今回は以下のようなアプリを作ろうとしています。

f:id:torikatsu923:20210424165822p:plain
作ろうとしているアプリ

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を使った初期化処理をコンストラクタで行うことができない

一応動くものを作るときはこれでもいいかもしれません。 しかし、それなりの規模感のものを作ろうとしたとき、このような実装はいつか負の遺産になりえます。

問題点の解決方法

前述の問題点を回避するためには、textPopUpViewModelのコンストラクタで受け取って初期化する必要があります。

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の作者のツイートを見つけました。

どうやらScopeedProviderを使うことで動的にProviderを初期化できるようです。 さらに、providerへアクセスできる範囲を制限できるので、見せたくないViewからtextを参照することができなくなります。

ScopedProviderを使うように修正

修正の流れは以下のようになります。

  1. StateNotiferをfamily修飾子を使用して作成する
  2. 未実装のScopedProviderを作成する
  3. PopUpPageNavigator.pushするとき、ProviderScopePopUpPageを包む
  4. 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.

pub.dev

3. PopUpPageNavigator.pushするとき、ProviderScopePopUpPageを包む

次に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側で以下のようにPopUpPageNavigator.pushすれば期待通りに動作します。

final text = context.read(homeViewModelProvider).text;
Navigator.push(context, PopUpPage.createPageRoute(text));

おわりに

今回の記事では、riverpodを使ってMVVMな実装をしているときに、画面遷移時にViewModelへ値をいい感じに渡す方法について紹介しました。 最初は少しトリッキーな実装のように感じていましたが、いろいろな課題点をクリアして、スッキリ実装できるriverpodの柔軟さに改めて驚きました。

それではよい開発ライフを!