🐠

【Flutter】AsyncValue を使ってローディング表示、ダイアログ表示、スナックバー表示の共通化をしてみた

2022/12/14に公開

https://qiita.com/advent-calendar/2022/flutteruniv

はじめに

非同期処理中にローディングを表示したり、エラーが起きたらダイアログを表示したり、処理が終わったらスナックバーを表示したり画面遷移をする実装方法について紹介します。Riverpod の AsyncValue を使うことで良い感じに処理の共通化ができました!

本記事では 「ログインボタン押すと2秒後にホーム画面に遷移する」 という題材を使いサンプルコードを交えて紹介します。ログイン、ログアウト、よく実装しますよね!何かを送信するときにも応用できます!

本記事で扱う題材の要件

要件は次の通りです。

  • ログインボタンを押すと非同期処理を 2 秒間行う
  • ログイン処理中はローディング表示する
  • ログイン処理が成功したらスナックバーでメッセージを表示してホーム画面に遷移する
  • ログイン処理中にエラーが起きたらダイアログを表示する ( 擬似的に 1 / 2 の確率でエラーを起こしています)

サンプルコードを公開しています

本記事で紹介するサンプルコードを公開しています!是非参考にしてください!

https://github.com/susatthi/flutter-sample-async-value

環境

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 便利ですよね〜

https://pub.dev/packages/flutter_riverpod

概要というか結論

忙しい人向けに概要というか結論を 3 行でお伝えします。

  • ログイン処理状態を AsyncValue で表し、処理状況に応じて更新する
  • AsyncValueref.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 が用意しているクラスです。FutureProviderStreamProvider でよく出てきますね。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}'),
    );
  }
}

https://pub.dev/documentation/riverpod/latest/riverpod/AsyncValue-class.html

次の記事に詳しい説明が書かれていますので是非ご覧下さい!

https://zenn.dev/tsuruo/articles/52f62fc78df6d5

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 {
      // ここで実際にログイン処理を非同期で行う
    });
  }
}

https://zenn.dev/shintykt/articles/f9948ac00c7296

ログイン処理状態をハンドリングする

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;
            });
          },
        );
      },
    );
    ・・・

ログインボタンが押されたらログイン処理を実行する

最後にログインボタンが押されたら UserServicelogin() メソッドを呼ぶようにします。try-cathref.listen() 内で行っているので消すことが出来ました!

  ElevatedButton(
    onPressed: () async {
      // ログインを実行する
      await ref.read(userServiceProvider).login();
    },
    child: const Text('ログイン'),
  ),

これでエラーハンドリングが簡単になったかと思います。しかしまだまだコードが冗長なのでさらに改良していきます。

ローディング表示をグローバルに扱う

ローディングを表示したり非表示にする処理を個々の画面でやるのは冗長です。そこで、アプリ内でグローバルに扱う方法について紹介します。

ローディングの表示/非表示の状態をもつプロバイダーを定義する

ローディングを表示するか非表示にするかの状態をもつプロバイダーを定義します。Riverpod v2 で追加された Notifier を使っていますが、v1 をお使いの場合は StateProviderStateNotifierProvider でも同じような実装ができます。

/// ローディングの表示有無
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 にアクセスするためのキーを作成して、MaterialAppscaffoldMessengerKey に登録するだけです。

そしてそのキーをプロバイダーでラップすることで、どこからでもスナックバーを呼び出せるようになります。

/// スナックバー表示用の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 にアクセスするためのキーを作成して、MaterialAppnavigatorKey に登録するだけです。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() を共通化して使い回せるようにしてみましょう。WidgetRefAsyncValue をハンドリングする拡張メソッド handleAsyncValue<T>() を定義してみました。

ref.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 箇所にまとめて書くことが出来ます。

MaterilAppのbuild()にまとめて書いた例
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(
      ・・・
    );
  }
}

応用

今回の AsyncValueref.listen() でハンドリングする考え方を使って、url_launcher のエラーハンドリングや connectivity_plus のネットワーク状態の監視を簡単に実装することができます。

本記事を通じて、Riverpod の魅力、とりわけ AsyncValueref.listen() の使い勝手の良さを感じて頂ければうれしいです!

最後に

Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!

https://flutteruniv.com?invite_id=9hsdZHg0qtaMIr6RPRulAaRJfA83

Flutter大学

Discussion