アプリ全体で利用できるローディング機能を作ろう(Riverpod)
はじめに
データベースからデータを取得したり、APIを叩いたり、ログインやサインインなど、時間のかかる処理をアプリ内で行う際、ローディング表示を出したいと思うことは結構あると思います。
「flutter_progress_hud」や「modal_progress_hud」のようなローディング表示するためのパッケージはいくつか存在しますが、実装がパッケージに依存してしまうため、メンテナンスやカスタマイズが難しかったり、他のものに乗り換えたくなった時などは少し手間がかかったりします。
ここでは、状態管理パッケージの「Riverpod」と組み合わせて、よりアプリで扱いやすいローディング機能を実装します。
具体的には、ローディング表示の呼び出しをProvider経由で行うようにし、BuildContext無しにProviderのRefから参照できるようにします。
準備
ここではローディングの表示/非表示の状態管理にRiverpodを用いますので追加が必要です。Riverpodの追加方法などは既に多くの文献がありますので、ここでは割愛します。
実装の流れ
ローディング表示の実装の流れは以下の通りです。
- 「何らかの処理が進行中かどうか」という状態(state)を管理するクラスを作成
- Providerを利用して、アプリ全体でそのstateを変更・監視できるようにする
- ローディング時に表示するウィジェットを作成
- アプリのルートレベルに、作成したローディングウィジェットを定義
- ウィジェット側で時間がかかる処理を行う際に、Provider経由で呼び出せば意識せずにローディング表示が行われる
実装
では、順番にやっていきましょう。
処理中のstateを管理するクラスを作成
アプリで何かの処理が行われているかどうかはbool型
で管理します。ここでは、true
の時に「処理中」、false
の時に「処理していない」とします。
まずはboolのstateを持ったNotifierクラスを作成してください。最初は何も処理されていないはずなので、stateの初期値はfalse
とします。
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
)します。
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
(クルクル表示)を配置します。
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
など)で定義します。
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
)を作成して、ボタンを押すことで発火するようにしてみました。
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