[Flutter] Riverpod で State が変わったタイミングでSnackBarを表示する
※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)
(以下省略)
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で表示するものを作成しました。
構成
-
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()メソッドを追加する。
Widget
で State
の変更を検知するために、 StateNotifier(or ChangeNotifier)
を利用し、final GlobalKey<ScaffoldState> _scaffoldKey;
を持っている ConsumerWidget
で、 State
が変更を検知して SnackBar
を表示しようとします。
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(() {...})
を利用し、画面描画後に処理を行う。
そこで、コチラのissue を参考に WidgetsBinding.instance.addPostFrameCallback((_) { hoge() });
という処理で書き換えました。
これにより、画面が描画されたのちに処理を行うことができます。
また、日本語ですと コチラの記事 を参考にさせていただきました🙇♂️
WidgetsBinding.instance.addPostFrameCallback((_) { hoge() });
で書き換えた場合、以下のようになります。
(簡単のため showSnackBar()
の内容のみ記載します。)
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()
を走らせなくとも処理を行えるようにする。
(本命)
上記の例で問題ない場合も多いと思います。
ただ私の場合は、わかりやすさのため showSnackBar()
を Scaffold
と同じクラス内に処理を移動したいと考えました。
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)),
- ),
- );
- }
}
-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的なもの)が追加された( ´・‿・`)
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)
-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()
で画面を閉じるなどの処理も行うことができるようになりますので、ぜひ試してみてください。
コチラの記事やサンプルコードで変な部分があったらぜひコメントにてフィードバックお願いします 🙇♂️
そして、ぜひぜひTwitter @muttsu_623 をフォローしていただけると嬉しいです 🤗
Discussion