🔨

アプリ全体で利用できるローディング機能を作ろう(Riverpod)

2023/12/25に公開

はじめに

データベースからデータを取得したり、APIを叩いたり、ログインやサインインなど、時間のかかる処理をアプリ内で行う際、ローディング表示を出したいと思うことは結構あると思います。

「flutter_progress_hud」や「modal_progress_hud」のようなローディング表示するためのパッケージはいくつか存在しますが、実装がパッケージに依存してしまうため、メンテナンスやカスタマイズが難しかったり、他のものに乗り換えたくなった時などは少し手間がかかったりします。
https://pub.dev/packages/flutter_progress_hud
https://pub.dev/packages/modal_progress_hud

ここでは、状態管理パッケージの「Riverpod」と組み合わせて、よりアプリで扱いやすいローディング機能を実装します。
具体的には、ローディング表示の呼び出しをProvider経由で行うようにし、BuildContext無しにProviderのRefから参照できるようにします。

準備

ここではローディングの表示/非表示の状態管理にRiverpodを用いますので追加が必要です。Riverpodの追加方法などは既に多くの文献がありますので、ここでは割愛します。
https://riverpod.dev/docs/introduction/getting_started
https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction/viewer/how-to-choose-a-riverpod

実装の流れ

ローディング表示の実装の流れは以下の通りです。

  1. 「何らかの処理が進行中かどうか」という状態(state)を管理するクラスを作成
  2. Providerを利用して、アプリ全体でそのstateを変更・監視できるようにする
  3. ローディング時に表示するウィジェットを作成
  4. アプリのルートレベルに、作成したローディングウィジェットを定義
  5. ウィジェット側で時間がかかる処理を行う際に、Provider経由で呼び出せば意識せずにローディング表示が行われる

実装

では、順番にやっていきましょう。

処理中のstateを管理するクラスを作成

アプリで何かの処理が行われているかどうかはbool型で管理します。ここでは、trueの時に「処理中」、falseの時に「処理していない」とします。

まずはboolのstateを持ったNotifierクラスを作成してください。最初は何も処理されていないはずなので、stateの初期値はfalseとします。

progress_controller.dart
class ProgressController extends Notifier<bool> {
  
  bool build() => false;
}

次に、作成したNotifierクラスの中に、外から処理に時間がかかる関数(Future関数)を引数として受け取り、その処理状況に合わせてstateを変更するメソッド(excuteWithProgress)を作成します。

class ProgressController extends Notifier<bool> {
  
  bool build() => false;

  Future<T> excuteWithProgress<T>(Future<T> Function() f) async {
    try {
      state = true; // ステータスを「処理中」に
      return await f();
    } finally {
      state = false; // ステータスを「処理していない」に
    }
  }
}

excuteWithProgressメソッドに関して、
まず引数では関数Function() fを受け取るようにします。さらに、この関数fの戻り値には、Futureであればどのような型でも受け付けられるように、ジェネリック型Future<T>を指定します。
また、excuteWithProgressメソッド自体でも、引数で受け取った関数の戻り値を、自身の戻り値として返すようにするため、同じくFutureのジェネリック型Future<T>を指定します。

次に、excuteWithProgressメソッドの内部ロジックに関して、
stateの変更処理はtry-finallyブロックを用いて実装します。try-finallyブロックは、まず初めにtryブロック内のコードが実行され、その次にfinallyブロックが実行されるというものです。

tryブロックの中では、受けとった関数f()を実行する前に、state=trueを代入して実行中の状態に変更します。その後、await f()で、f()の実行が完了するまで待つようにします。
finallyブロックの中では、単純にtryブロックの処理が終わった後に処理中のステータスを「処理していない」状態に戻すため、state=falseに戻します。

Providerでアプリ全体でstateを変更・監視できるようにする

先ほど作成したProgressControllerのインスタンスを、アプリ全体で利用できるように、NotifierProviderを用いてProviderを作成(progressController)します。

progress_contoller.dart
final progressController = NotifierProvider<ProgressController, bool>(
  ProgressController.new,
);

class ProgressController extends Notifier<bool> {
  
  bool build() => false;

  Future<T> excuteWithProgress<T>(Future<T> Function() f) async {
    try {
      state = true; // ステータスを「処理中」に
      return await f();
    } finally {
      state = false; // ステータスを「処理していない」に
    }
  }
}

これで、アプリ全体でProgressControllerのstateを購読したり、メソッドを呼び出して間接的にstateを変更することができるようになりました。

ローディング時に表示するウィジェットを作成

次に、ローディング状態をアプリ上で表現するローディングウィジェットを作成しましょう。ここではUIなどは無視して、最低限の機能を備えたものを作成します。

具体的には、ローディング中はユーザの操作を受け付けないように画面全体を薄暗いContainerで覆い、その上にCircularProgresIndicator(クルクル表示)を配置します。

loading.dart
class Loaging extends StatelessWidget {
  const Loaging({super.key});

  
  Widget build(BuildContext context) {
    return Container(
      color: Colors.black.withOpacity(0.3),
      child: const Center(
        child: CircularProgressIndicator(
          color: Colors.white,
        ),
      ),
    );
  }
}

アプリのルートレベルに、作成したローディングウィジェットを定義

最後に、作成したローディングウィジェットをアプリのルートレベルで管理し、尚且つstateの値によって表示/非表示が切り替わるようにします。

今回のローディングウィジェットのように、特定の画面だけでなく、アプリ全体で利用するウィジェットや共通の機能を定義するには、MaterialAppウィジェットのbuilderプロパティを利用します。アプリ全体に影響を与える目的で利用されるため、基本的にはエントリーポイントから呼ばれる最初のクラス(MainAppなど)で定義します。

main.dart
void main() {
  runApp(
    const ProviderScope(
      child: MainApp(),
    ),
  );
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      builder: (context, child) {
        // Providerからstateを取得
        final progress = ref.watch(progressController);
        return Stack(
          children: [
            child!,
	    // stateの状態に応じて表示/非表示を切り替え
            if (progress) const Center(child: Loading()),
          ],
        );
      },
      home: ...
    );
  }
}

builderの中では、先ほど定義したProviderからstate(処理状態)を監視するようにします。そしてStackを返却値として設定し、stateの状態に応じてローディングウィジェットが表示されるようにします。Stackの中に定義されたchildは、MaterialAppのウィジェットツリー、ここではhomeで定義される以降の全てのウィジェットを指します。

これでローディング機能の実装は完了です!

実際に挙動を確認してみる

ローティング機能の確認用ページを作成して実際に挙動を確認してみます。ローディング機能を利用するには、Providerを読み込んでstateの状態を書き換える必要があるため、ConsumerWidgetを継承します。また、直接ローディングウィジェットを呼び出すのではなく、excuteWithProgressメソッドを呼び出してその引数に長い処理(Future関数)を設定します。

今回は、擬似的に3秒間待つ関数(waitForThreeSeconds)を作成して、ボタンを押すことで発火するようにしてみました。

loading_test_page.dart
class LoadingTestPage extends ConsumerWidget {
  const LoadingTestPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      body: Center(
        child: MaterialButton(
          color: Colors.blue,
          textColor: Colors.white,
          onPressed: () {
            ref
                .read(progressController.notifier)
                .excuteWithProgress(waitForThreeSeconds);
          },
          child: const Text('長い処理を実行'),
        ),
      ),
    );
  }
}

// 長い処理
Future<void> waitForThreeSeconds() async {
  await Future.delayed(const Duration(seconds: 3));
}

こちらが実際の挙動です。ボタンを押して関数が呼び出され、処理中にローディング表示が行われていることがわかります。また、録画では確認できませんが、ローディング表示中は他の画面操作を受け付けないようになっております。

以上のように、アプリ全体で「処理中かどうか」のstateを変更・監視することで、ウィジェット側では長い処理を呼び出す場合にexcuteWithProgressの引数として設定するだけで、自動的にローディング表示を行うことができます。

Discussion