[Flutter] Riverpod で State が変わったタイミングで Navigator遷移 や SnackBar表示 を行いたい

## 前提

```
$ 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)
(以下省略)
```

```yaml: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](https://github.com/muttsu-623/provider_listener_counter)

### 構成

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

## 結論

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

## 詳細

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

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

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

[Commit](https://github.com/muttsu-623/provider_listener_counter/commit/225460c4ed6f67e8e90c5bef412b10b1039b2ece)

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

```dart: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!';
    }
    _scaffoldKey.currentState
        .showSnackBar(SnackBar(content: Text(snackBarText)));
  }
}
```

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

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

[Commit](https://github.com/muttsu-623/provider_listener_counter/commit/8d81ad685e29102029f57b8dd72f24ec01a5a105)

そこで、[コチラのissue](https://github.com/flutter/flutter/issues/23195) を参考に `WidgetsBinding.instance.addPostFrameCallback((_) { hoge() });` という処理で書き換えました。

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

[[Flutter] WidgetsBindingとは何か？](https://qiita.com/kurun_pan/items/04f34a47cc8cee0fe542)

また、日本語ですと [コチラの記事](https://qiita.com/Rwf-9DH3/items/b681c68b06e70b02ae8d) を参考にさせていただきました🙇‍♂️

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

```diff: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()` メソッド後に処理を走らせることができます☺️

### （本命）`ProviderListener` で `build()` を走らせなくとも処理を行えるようにする。

[Commit](https://github.com/muttsu-623/provider_listener_counter/commit/be8e5f542c3d291e2bbf2f8e984646babc865d34)

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

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

```diff: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)),
-      ),
-    );
-  }
 }
```

```diff: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` だった `MyHomePage` を `ConsumerWidget` に変更し、 `countState` の変更を監視するように変更しました。

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

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">お、RiverpodにProviderListener(いわゆるBlocListener的なもの)が追加された(　´･‿･｀)<br><br>Add ProviderListener widget · rrousselGit/river_pod@c669cb4 <a href="https://t.co/qc87onGF0g">https://t.co/qc87onGF0g</a></p>&mdash; mono (@_mono) <a href="https://twitter.com/_mono/status/1290076473118744576?ref_src=twsrc%5Etfw">August 3, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


ProviderListener…？

[公式サイト](https://riverpod.dev/docs/concepts/reading) によると、

>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.

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

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

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

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



さっそく、これで書き換えてみると （[Commit](https://github.com/muttsu-623/provider_listener_counter/commit/19ee3374a5dd78e03344c8463a17daacd6dc811b)）

```diff: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))` で、監視したい `state` を `field` に持ち、 `ProviderListener` で `state` の監視を行います。

`ProviderListener` の引数は以下です。

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


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

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

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

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

## 終わりに

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

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

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

<b>そして、ぜひぜひTwitter [@muttsu_623](https://twitter.com/muttsu_623/) をフォローしていただけると嬉しいです 🤗 </b>