[Flutter] Riverpod で State が変わったタイミングでSnackBarを表示する

公開:2020/11/24
更新:2020/11/24
12 min読了の目安(約11200字TECH技術記事

※2020年11月10日にQiitaへ書いたものの転載です。より多くの方のためになればと思い、zennにも再掲いたします。

前提

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 1.22.2, on Mac OS X 10.15.7 19H2, locale ja-JP)
(以下省略)
pubspec.yaml
hooks_riverpod: ^0.10.1
state_notifier: ^0.6.0

やりたいこと

Stateや何かしらの値が変わったときに以下の用件を満たしたいケースが多々あるかと思います。

  • Stateが変わったタイミングでScaffoldを利用してSnackBarを表示したい
  • Stateが変わったタイミングでNavigatorで画面遷移を行いたい

例えば、通信が成功したタイミングで遷移したい。エラーが起きたらSnackBarを表示したいなど。

Future? いえ、知らない子ですね(知ってます)

個人的にはiOS, Androidの経験からObserverで値を受け取り、Viewを更新したり、ViewControllerで操作するほうが馴染みがあるため、FutureではなくStateの変更によって処理を行うやり方を取っております。
(ここは流派が分かれると思うので、チームで適切に議論して進めてください。)

あくまで State の変更によって何かしら処理を行いたい場合の話です。

つくったアプリ

Flutterの代表的なサンプルアプリのCounterAppにおいて、increment後の数字が奇数か偶数かをScaffoldのSnackBarで表示するものを作成しました。

provider_listener_counter

構成

  • main.dart
    • main()MyApp()Scaffold
  • count_state.dart
    • int count を保有する State クラス
  • count_state_controller.dart
    • State の変更を通知する StateController クラス
  • controller_providers.dart
    • CountStateControllerInject ファイル
  • counter_text.dart
    • state の変更を監視し、count を表示する Text ウィジェット

結論

riverpodの ProviderListener を使って以下のように書くことにより実現できます。
上記のGitHubプロジェクトを確認してください。

詳細

Counterアプリにおいて、CountState の変更を検知して、SnackBar を表示することを考えます。

以下の内容にCommitのリンクも載せているので、もし良ければ合わせてみてください。

CounteText.dart に showSnackBar()メソッドを追加する。

Commit

WidgetState の変更を検知するために、 StateNotifier(or ChangeNotifier) を利用し、final GlobalKey<ScaffoldState> _scaffoldKey; を持っている ConsumerWidget で、 State が変更を検知して SnackBar を表示しようとします。

counter_text.dart
class CounterText extends ConsumerWidget {
  CounterText(this._scaffoldKey);
  final GlobalKey<ScaffoldState> _scaffoldKey;
  
  Widget build(BuildContext context, ScopedReader watch) {
    final countState = watch(countStateControllerProvider.state);
    showSnackBar(countState.count);
    return Text(
      '${countState.count}',
      style: Theme.of(context).textTheme.headline4,
    );
  }

  void showSnackBar(int count) {
    String snackBarText;
    if (count % 2 == 0) {
      snackBarText = '$count is Even!';
    } else {
      snackBarText = '$count is Odd!';
    }
    _scaffoldKey.currentState
        .showSnackBar(SnackBar(content: Text(snackBarText)));
  }
}

このとき、きっと誰しもが(私も例外ではありません…)、build() 内で Scaffold.of(context).showSnackBar()Navigator.of(context).push() でエラーになったことがあるんじゃないでしょうか…。

WidgetsBinding.instance.addPostFrameCallback(() {...})を利用し、画面描画後に処理を行う。

Commit

そこで、コチラのissue を参考に WidgetsBinding.instance.addPostFrameCallback((_) { hoge() }); という処理で書き換えました。

これにより、画面が描画されたのちに処理を行うことができます。

[Flutter] WidgetsBindingとは何か?

また、日本語ですと コチラの記事 を参考にさせていただきました🙇‍♂️

WidgetsBinding.instance.addPostFrameCallback((_) { hoge() }); で書き換えた場合、以下のようになります。
(簡単のため showSnackBar() の内容のみ記載します。)

counter_text.dart
void showSnackBar(int count) {
  String snackBarText;
  if (count % 2 == 0) {
    snackBarText = '$count is Even!';
  } else {
    snackBarText = '$count is Odd!';
  }
-  _scaffoldKey.currentState
-      .showSnackBar(SnackBar(content: Text(snackBarText)));
+  WidgetsBinding.instance.addPostFrameCallback(
+    (timeStamp) => _scaffoldKey.currentState.showSnackBar(
+      SnackBar(content: Text(snackBarText)),
+    ),
+  );
}

このように書くと、 build() メソッド後に処理を走らせることができます☺️

(本命)ProviderListenerbuild() を走らせなくとも処理を行えるようにする。

Commit

上記の例で問題ない場合も多いと思います。

ただ私の場合は、わかりやすさのため showSnackBar()Scaffold と同じクラス内に処理を移動したいと考えました。

counter_text.dart
 class CounterText extends ConsumerWidget {
-  CounterText(this._scaffoldKey);
-  final GlobalKey<ScaffoldState> _scaffoldKey;
   @override
   Widget build(BuildContext context, ScopedReader watch) {
-    final countState = watch(countStateControllerProvider.state);
-    showSnackBar(countState.count);
     return Text(
       '${countState.count}',
       style: Theme.of(context).textTheme.headline4,
     );
   }
-
-  void showSnackBar(int count) {
-    String snackBarText;
-    if (count % 2 == 0) {
-      snackBarText = '$count is Even!';
-    } else {
-      snackBarText = '$count is Odd!';
-    }
-    WidgetsBinding.instance.addPostFrameCallback(
-      (timeStamp) => _scaffoldKey.currentState.showSnackBar(
-        SnackBar(content: Text(snackBarText)),
-      ),
-    );
-  }
 }
main.dart
-class MyHomePage extends StatelessWidget {
+class MyHomePage extends ConsumerWidget {
   MyHomePage({Key key, this.title}) : super(key: key);
   final String title;
+  final Widget _scaffoldBody = Center(
+    child: Column(
+      mainAxisAlignment: MainAxisAlignment.center,
+      children: <Widget>[
+        Text('You have pushed the button this many times:'),
+        CounterText(),
+      ],
+    ),
+  );
   final _scaffoldKey = GlobalKey<ScaffoldState>();

   @override
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, ScopedReader watch) {
+    final countState = watch(countStateControllerProvider.state);
+    showSnackBar(countState.count);
     return Scaffold(
       key: _scaffoldKey,
       appBar: AppBar(
         title: Text(title),
       ),
-      body: Center(
-        child: Column(
-          mainAxisAlignment: MainAxisAlignment.center,
-          children: <Widget>[
-            Text('You have pushed the button this many times:'),
-            CounterText(_scaffoldKey),
-          ],
-        ),
-      ),
+      body: _scaffoldBody,
       floatingActionButton: FloatingActionButton(
         onPressed: () => context.read(countStateControllerProvider).increment(),
         tooltip: 'Increment',
         child: Icon(Icons.add),
       ),
     );
   }
+ 
+  void showSnackBar(int count) {
+    String snackBarText;
+    if (count % 2 == 0) {
+      snackBarText = '$count is Even!';
+    } else {
+      snackBarText = '$count is Odd!';
+    }
+    WidgetsBinding.instance.addPostFrameCallback(
+      (timeStamp) => _scaffoldKey.currentState.showSnackBar(
+        SnackBar(content: Text(snackBarText)),
+      ),
+    );
+  }
 }

このとき、 StatelessWidget だった MyHomePageConsumerWidget に変更し、 countState の変更を監視するように変更しました。

ここでの問題は、数字が変わるたびに MyHomePage もリビルドされるようになってしまったことです…。
Widget のツリーには変更が生じないのに、リビルドされてしまうのはパフォーマンス的な観点でよくなさそう&気持ち悪いなと思っていました。

そんなときに Twitter で @mono0926 さんのコチラのツイートをお見かけしました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">お、RiverpodにProviderListener(いわゆるBlocListener的なもの)が追加された( ´・‿・`)

Add ProviderListener widget · rrousselGit/river_pod@c669cb4 <a href="https://t.co/qc87onGF0g">https://t.co/qc87onGF0g</a></p>— mono (@_mono) <a href="https://twitter.com/_mono/status/1290076473118744576?ref_src=twsrc^tfw">August 3, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ProviderListener…?

公式サイト によると、

In some situations, you may want to your Widget tree to push a route or show a dialog after a change on a provider.

Such behavior would be implemented using the ProviderListener Widget.

まさにほしいものでは?っぽいことが書いてある。

すぐ下のサンプルコードは

Widget build(BuildContext context) {
  return ProviderListener<StateController<int>>(
    provider: counterProvider,
    onChange: (context, counter) {
      if (counter.state == 5) {
        showDialog(...);
      }
    },
    child: Whatever(),
  );
}

と、まさに利用したかったものでした!!!

さっそく、これで書き換えてみると (Commit

main.dart
-class MyHomePage extends ConsumerWidget {
+class MyHomePage extends StatelessWidget {
   MyHomePage({Key key, this.title}) : super(key: key);
   final String title;
   final Widget _scaffoldBody = Center(
     child: Column(
       mainAxisAlignment: MainAxisAlignment.center,
       children: <Widget>[
         Text('You have pushed the button this many times:'),
         CounterText(),
       ],
     ),
   );
   final _scaffoldKey = GlobalKey<ScaffoldState>();
+  final countStateProvider = Provider.autoDispose(
+      (ref) => ref.watch(countStateControllerProvider.state));

   @override
-   Widget build(BuildContext context, ScopedReader watch) {
-    final countState = watch(countStateControllerProvider.state);
-    showSnackBar(countState.count);
-    return Scaffold(
-      key: _scaffoldKey,
-      appBar: AppBar(
-        title: Text(title),
-      ),
-      body: _scaffoldBody,
-      floatingActionButton: FloatingActionButton(
-        onPressed: () => context.read(countStateControllerProvider).increment(),
-        tooltip: 'Increment',
-        child: Icon(Icons.add),
+  Widget build(BuildContext context) {
+    return ProviderListener(
+      onChange: (context, CountState countState) {
+        showSnackBar(countState.count);
+      },
+      provider: countStateProvider,
+      child: Scaffold(
+        key: _scaffoldKey,
+        appBar: AppBar(
+          title: Text(title),
+        ),
+        body: _scaffoldBody,
+        floatingActionButton: FloatingActionButton(
+          onPressed: () =>
+              context.read(countStateControllerProvider).increment(),
+          tooltip: 'Increment',
+          child: Icon(Icons.add),
+        ),
       ),
     );
   }

   void showSnackBar(int count) {
     String snackBarText;
     if (count % 2 == 0) {
       snackBarText = '$count is Even!';
     } else {
       snackBarText = '$count is Odd!';
     }
-    WidgetsBinding.instance.addPostFrameCallback(
-      (timeStamp) => _scaffoldKey.currentState.showSnackBar(
-        SnackBar(content: Text(snackBarText)),
-      ),
-    );
+    SnackBar(content: Text(snackBarText));
   }
 }

Provider.autoDispose((ref) => ref.watch(provider)) で、監視したい statefield に持ち、 ProviderListenerstate の監視を行います。

ProviderListener の引数は以下です。

  • provider: 変更を監視したい stateProvider
  • onChanged: state の変更時に行いたい処理
  • child: 表示したいウィジェット

これにより、 ConsumerWidget ではなくなり、StatelessWidget になりました。
また、WidgetsBinding.instance.addPostFrameCallback((_) {...}) も消すことができました。

ProviderListenerStatefulWidget を継承していますが、 state の変更が行われても build() が再度呼ばれてはいません。(debugして確認してみてください。)

ProviderContainer, ProviderObserver などがここに関わってきそうなので、どなたかぜひ調べてまとめてくださると嬉しいです!

Advent Calendar のネタとしてもありですね…🤔

終わりに

以上のようにして、ProviderListener を用いて State の変更を検知しました。

他にも、 UserDataState のようなものを作成し、 State が変更になったタイミングで Navigator.of(context).pop() で画面を閉じるなどの処理も行うことができるようになりますので、ぜひ試してみてください。

コチラの記事やサンプルコードで変な部分があったらぜひコメントにてフィードバックお願いします 🙇‍♂️

そして、ぜひぜひTwitter @muttsu_623 をフォローしていただけると嬉しいです 🤗