【Flutter】AsyncValue を使ってローディング表示、ダイアログ表示、スナックバー表示の共通化をしてみた
はじめに
非同期処理中にローディングを表示したり、エラーが起きたらダイアログを表示したり、処理が終わったらスナックバーを表示したり画面遷移をする実装方法について紹介します。Riverpod の AsyncValue
を使うことで良い感じに処理の共通化ができました!
本記事では 「ログインボタン押すと2秒後にホーム画面に遷移する」 という題材を使いサンプルコードを交えて紹介します。ログイン、ログアウト、よく実装しますよね!何かを送信するときにも応用できます!
本記事で扱う題材の要件
要件は次の通りです。
- ログインボタンを押すと非同期処理を 2 秒間行う
- ログイン処理中はローディング表示する
- ログイン処理が成功したらスナックバーでメッセージを表示してホーム画面に遷移する
- ログイン処理中にエラーが起きたらダイアログを表示する ( 擬似的に 1 / 2 の確率でエラーを起こしています)
サンプルコードを公開しています
本記事で紹介するサンプルコードを公開しています!是非参考にしてください!
環境
Flutter 3.3.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision b8f7f1f986 (3 weeks ago) • 2022-11-23 06:43:51 +0900
Engine • revision 8f2221fbef
Tools • Dart 2.18.5 • DevTools 2.15.0
flutter_riverpod 2.1.1 を使っています! Riverpod 便利ですよね〜
概要というか結論
忙しい人向けに概要というか結論を 3 行でお伝えします。
- ログイン処理状態を
AsyncValue
で表し、処理状況に応じて更新する -
AsyncValue
をref.listen()
で適切にハンドリングする -
ref.listen()
の処理を共通化して 1 箇所にまとめる
それでは詳細について紹介していきます。
まずは普通に実装してみる
ログインボタンが押されたときの処理を普通に実装すると、次のような手続き的な実装になると思います。
ElevatedButton(
onPressed: () async {
// ローディングを表示する
setState(() {
isLoading = true;
});
try {
// ログインを実行する
await ref.read(userServiceProvider).login();
// ログインできたらスナックバーでメッセージを表示してホーム画面に遷移する
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ログインしました!'),
),
);
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => const HomePage(),
),
);
} catch (e) {
// エラーが発生したらエラーダイアログを表示する
await showDialog<void>(
context: context,
builder: (context) => ErrorDialog(error: e),
);
} finally {
// ローディングを非表示にする
setState(() {
isLoading = false;
});
}
},
child: const Text('ログイン'),
),
これでももちろんちゃんと動きます。しかし、同じような非同期処理(例えばログアウト)を実装する度に同じコードを量産してしまうことになりますので処理を共通化したいですよね。
過去に次のような共通化を試してみましたが、いまいちしっくりしませんでした。
-
Widget
クラスの extension を作ってtry-catch
処理を共通メソッド化 -
bool isLoading
を持つベースの状態クラスを用意して、各画面でその状態クラスを持つ
しかし、処理状態を Riverpod の AsyncValue
で表現して非同期処理をハンドリングすると、良い感じに処理を共通化することが出来ました!
この方法を思いついたときは興奮で夜しか眠れませんでした・・・
AsyncValue とは
本題に入る前にまず AsyncValue
についておさらいしましょう。
AsyncValue
とは非同期処理で変化する状態を表現する Riverpod が用意しているクラスです。FutureProvider
や StreamProvider
でよく出てきますね。AsyncValue
自体は Abstract クラスで、実際には AsyncValue
クラスを継承した次の 3 つのクラスを扱います。
クラス名 | 説明 |
---|---|
AsyncData<T> |
データを保持した状態(正常状態)を表すクラス |
AsyncLoading |
処理中の状態を表すクラス |
AsyncError |
エラーの状態を表すクラス |
具体的なハンドリング方法は次の公式サンプルのとおり when()
を使います。他にも whenData()
や maybeWhen()
など様々なハンドリング方法が用意されています。
/// A provider that asynchronously exposes the current user
final userProvider = StreamProvider<User>((_) async* {
// fetch the user
});
class Example extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<User> user = ref.watch(userProvider);
return user.when(
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Oops, something unexpected happened'),
data: (user) => Text('Hello ${user.name}'),
);
}
}
次の記事に詳しい説明が書かれていますので是非ご覧下さい!
AsyncValue を使って処理を共通化してみる
処理を共通化するために次のことを行います。
- 処理状態を AsyncValue で表現してハンドリングできるようにする
- ローディング表示をグローバルに扱う
- どこからでもスナックバーを表示する
- どこからでもダイアログを表示する
処理状態を AsyncValue で表現してハンドリングできるようにする
ここでのポイントは次の 2 点です。先ほどの手続き的な実装を変更していきます。
- 処理状態(処理中/処理完了/エラー)を
AsyncValue
で表す - Widget 側で
ref.listen()
を利用して処理状態をハンドリングする
ログイン処理状態のプロバイダーを用意する
まず、ログイン処理状態を AsyncValue
で表すプロバイダーを用意します。データの型は不要なので void
にしておきます。
/// ログイン処理状態
final loginStateProvider = StateProvider<AsyncValue<void>>(
(_) => const AsyncValue.data(null),
);
ログイン処理内でログイン処理状態を更新していく
ログイン処理自体は UserService
クラスの login()
メソッドで行います。Ref
を使いたいので Provider
でラップしておきます。
ログイン処理前に AsyncLoding
にして、処理が正常に完了したら AsyncData(void)
にして、エラーが起きたら AsyncError
に更新していきます。これでログイン処理状態の変化が loginStateProvider
で表現できるようになります。
/// ユーザーサービスプロバイダー
final userServiceProvider = Provider(
UserService.new,
);
class UserService {
UserService(this.ref);
final Ref ref;
/// ログインする
Future<void> login() async {
final notifier = ref.read(loginStateProvider.notifier);
// ログイン結果をローディング中にする
notifier.state = const AsyncValue.loading();
// ログイン処理を実行する
notifier.state = await AsyncValue.guard(() async {
// ここで実際にログイン処理を非同期で行う
});
}
}
ログイン処理状態をハンドリングする
ref.listen()
を使うと、プロバイダー値の変化を監視してハンドリングすることができます。loginStateProvider
が更新されると、next
に最新の値が入ってくるので、next.when()
とすることで、それぞれの状態に分岐させて処理を行うことが出来ます。
Widget build(BuildContext context) {
// ログイン処理状態をハンドリングする
ref.listen<AsyncValue<void>>(
loginStateProvider,
(_, next) async {
if (next.isLoading) {
// ローディングを表示する
setState(() {
isLoading = true;
});
return;
}
await next.when(
data: (_) async {
// ローディングを非表示にする
setState(() {
isLoading = false;
});
// ログインできたらスナックバーでメッセージを表示してホーム画面に遷移する
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ログインしました!'),
),
);
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => const HomePage(),
),
);
},
error: (e, s) async {
// ローディングを非表示にする
setState(() {
isLoading = false;
});
// エラーが発生したらエラーダイアログを表示する
await showDialog<void>(
context: context,
builder: (context) => ErrorDialog(error: e),
);
},
loading: () {
// ローディングを表示する
setState(() {
isLoading = true;
});
},
);
},
);
・・・
ログインボタンが押されたらログイン処理を実行する
最後にログインボタンが押されたら UserService
の login()
メソッドを呼ぶようにします。try-cath
は ref.listen()
内で行っているので消すことが出来ました!
ElevatedButton(
onPressed: () async {
// ログインを実行する
await ref.read(userServiceProvider).login();
},
child: const Text('ログイン'),
),
これでエラーハンドリングが簡単になったかと思います。しかしまだまだコードが冗長なのでさらに改良していきます。
ローディング表示をグローバルに扱う
ローディングを表示したり非表示にする処理を個々の画面でやるのは冗長です。そこで、アプリ内でグローバルに扱う方法について紹介します。
ローディングの表示/非表示の状態をもつプロバイダーを定義する
ローディングを表示するか非表示にするかの状態をもつプロバイダーを定義します。Riverpod v2 で追加された Notifier
を使っていますが、v1 をお使いの場合は StateProvider
や StateNotifierProvider
でも同じような実装ができます。
/// ローディングの表示有無
final loadingProvider = NotifierProvider<LoadingNotifier, bool>(
LoadingNotifier.new,
);
class LoadingNotifier extends Notifier<bool> {
bool build() => false;
/// ローディングを表示する
void show() => state = true;
/// ローディングを非表示にする
void hide() => state = false;
}
すべての画面共通でローディング表示をする
loadingProvider
を監視してローディングを表示する処理を MaterialApp
で実装することで、すべての画面共通の処理を書くことが出来ます。
WidgetRef
を使えるように Consumer
でラップして、Stack
を使ってローディングを重ねて表示しています。
return MaterialApp(
・・・
builder: (context, child) => Consumer(
builder: (context, ref, _) {
final isLoading = ref.watch(loadingProvider);
return Stack(
children: [
child!,
// ローディングを表示する
if (isLoading)
const ColoredBox(
color: Colors.black26,
child: Center(
child: CircularProgressIndicator(),
),
),
],
);
},
),
);
個々の画面の isLoading は不要になった
MaterialApp
で共通的にローディング表示できるようになったので、ConsumerStatefulWidget
内で保持していた bool isLoading
が無くなり、ConsumerWidget
にすることができました。
setState()
の代わりに LoadingNotifier
で表示/非表示を切り替えるように変えました。
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// ログイン処理状態をハンドリングする
ref.listen<AsyncValue<void>>(
loginStateProvider,
(_, next) async {
final loadingNotifier = ref.read(loadingProvider.notifier);
if (next.isLoading) {
// ローディングを表示する
loadingNotifier.show();
return;
}
await next.when(
data: (_) async {
// ローディングを非表示にする
loadingNotifier.hide();
・・・
},
error: (e, s) async {
// ローディングを非表示にする
loadingNotifier.hide();
・・・
},
loading: () {
// ローディングを表示する
loadingNotifier.show();
},
);
},
);
これでローディング表示がグローバルに扱えるようになりました 🎉
BuildContext への依存をなくしたい
しかし、ref.listen()
内でスナックバーやダイアログを表示したり画面遷移をしています。これらは BuildContext
に依存していますので、共通化したときに BuildContext
を引数でもらう必要がでてきます。いちいち渡すのは面倒なので BuildContext
への依存を排除していきましょう。
どこからでもスナックバーを表示する
BuildContext
がなくても、どこからでもスナックバーを表示できるようにしてみます。
といってもやることは簡単で、任意の場所から ScaffoldMessengerState
にアクセスするためのキーを作成して、MaterialApp
の scaffoldMessengerKey
に登録するだけです。
そしてそのキーをプロバイダーでラップすることで、どこからでもスナックバーを呼び出せるようになります。
/// スナックバー表示用のGlobalKey
final scaffoldMessengerKeyProvider = Provider(
(_) => GlobalKey<ScaffoldMessengerState>(),
);
return MaterialApp(
scaffoldMessengerKey: ref.watch(scaffoldMessengerKeyProvider),
・・・
);
final messengerState = ref.read(scaffoldMessengerKeyProvider).currentState;
messengerState?.showSnackBar(
const SnackBar(
content: Text('ログインしました!'),
),
);
どこからでもダイアログを表示する
スナックバーとやることはほぼ同じです。ついでに、どこからでも画面遷移ができるようにもなります(多分)。
任意の場所から NavigatorState
にアクセスするためのキーを作成して、MaterialApp
の navigatorKey
に登録するだけです。NavigatorState
があれば、現在の BuildContext
を取り出すことができます。
そしてそのキーをプロバイダーでラップすることで、どこからでもダイアログを呼び出せるようになります。
/// ダイアログ表示用のGlobalKey
final navigatorKeyProvider = Provider(
(_) => GlobalKey<NavigatorState>(),
);
return MaterialApp(
navigatorKey: ref.watch(navigatorKeyProvider),
・・・
);
// navigatorStateから現在のcontextが取れる
await showDialog<void>(
context: ref.read(navigatorKeyProvider).currentContext!,
builder: (context) => ErrorDialog(error: e),
);
Navigator 2.0 ( go_router? )を使っている場合はナビゲーションまわりの実装が少し違うらしく、navigatorKey
に登録する代わりに、次のように Navigator
でラップする必要がありました。このあたり深く追えていません。。。
return MaterialApp(
・・・
builder: (context, child) => Consumer(
builder: (context, ref, _) {
final isLoading = ref.watch(loadingProvider);
// Navigatorでラップしてあげる
return Navigator(
key: ref.watch(navigatorKeyProvider),
onPopPage: (_, dynamic __) => false,
pages: [
MaterialPage<Widget>(
child: Stack(
・・・
),
),
],
);
},
),
);
共通化!
共通化の準備が整いました。
最後に ref.listen()
を共通化して使い回せるようにしてみましょう。WidgetRef
に AsyncValue
をハンドリングする拡張メソッド handleAsyncValue<T>()
を定義してみました。
extension WidgetRefEx on WidgetRef {
/// AsyncValueを良い感じにハンドリングする
void handleAsyncValue<T>(
ProviderListenable<AsyncValue<T>> asyncValueProvider, {
void Function(BuildContext context, T data)? complete,
String? completeMessage,
}) =>
listen<AsyncValue<T>>(
asyncValueProvider,
(_, next) async {
final loadingNotifier = read(loadingProvider.notifier);
if (next.isLoading) {
loadingNotifier.show();
return;
}
next.when(
data: (data) async {
loadingNotifier.hide();
// 完了メッセージがあればスナックバーを表示する
if (completeMessage != null) {
final messengerState =
read(scaffoldMessengerKeyProvider).currentState;
messengerState?.showSnackBar(
SnackBar(
content: Text(completeMessage),
),
);
}
complete?.call(read(navigatorKeyProvider).currentContext!, data);
},
error: (e, s) async {
loadingNotifier.hide();
// エラーが発生したらエラーダイアログを表示する
await showDialog<void>(
context: read(navigatorKeyProvider).currentContext!,
builder: (context) => ErrorDialog(error: e),
);
},
loading: loadingNotifier.show,
);
},
);
}
ref.handleAsyncValue<T>()
の使い方は次のとおりです。以下は一例なので、要件に応じていろいろカスタマイズしてみてください!
ref.handleAsyncValue<void>(
loginStateProvider,
);
ref.handleAsyncValue<void>(
loginStateProvider,
completeMessage: 'ログインしました!',
);
ref.handleAsyncValue<void>(
loginStateProvider,
complete: (context, _) async {
// ログインできたらホーム画面に遷移する
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (context) => const HomePage()),
);
},
);
ref.handleAsyncValue<T>()
を各画面の Widget の build()
メソッド内に書いてもよいですが、次のように MaterialApp
にまとめて書くこともできるので、アプリ全体的な非同期処理は 1 箇所にまとめて書くことが出来ます。
class App extends ConsumerWidget {
const App({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// ログイン結果をハンドリングする
ref.handleAsyncValue<void>(
loginStateProvider,
completeMessage: 'ログインしました!',
complete: (context, _) async {
// ログインできたらホーム画面に遷移する
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (context) => const HomePage()),
);
},
);
// ログアウト結果をハンドリングする
ref.handleAsyncValue<void>(
logoutStateProvider,
completeMessage: 'ログアウトしました!',
complete: (context, _) {
// ログアウトしたらログイン画面に戻る
Navigator.of(context).pop();
},
);
return MaterialApp(
・・・
);
}
}
応用
今回の AsyncValue
を ref.listen()
でハンドリングする考え方を使って、url_launcher のエラーハンドリングや connectivity_plus のネットワーク状態の監視を簡単に実装することができます。
本記事を通じて、Riverpod の魅力、とりわけ AsyncValue
と ref.listen()
の使い勝手の良さを感じて頂ければうれしいです!
最後に
Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!
Discussion