🌀

【Flutter】画面全体の Loading 表示を、パッケージも Navigator も使わない、一番シンプルな実装の提案

2024/05/22に公開2

環境

# Flutter
3.22.0

# Dart
3.4

ゴール

Riverpod などのパッケージに頼らず、これだけで実装します。

Future<void> onPressed() async {
  await LoadingIndicator.show(() async {
    // some Future implements
  });
}

https://github.com/Zudah228/flutter_app_loading

Navigator(or showDialog) を使う課題感

  1. どっかで間違えて Navigator.pop をしてしまうと、消えてしまう。
    • そもそも、色んなダイアログが同じライフサイクルで使われていることに違和感がある。
  2. RouteAware が無駄にトリガーしてしまう。
  3. BuildContext が必要になってしまう。
  4. Android でのバックボタンをケアする必要がある。
  5. Web では大体 boolean で管理されているので、そっちで使いたくなる。
  6. Router API(go_router など)を使っている場合、こちらの命令形を導入するかどうかの兼ね合いを考えるのがめんどくさい。

実装

1. LoadingIndicator の作成

ValueNotifier を利用して表示切り替えをします。
一番パフォーマンスよく作りやすいからそうしてるだけなので、setState なり hooks なりここはなんでも大丈夫です。

loading_indicator.dart
class LoadingIndicator extends StatefulWidget {
  const LoadingIndicator({super.key});

  
  State<LoadingIndicator> createState() => _LoadingIndicatorState();
}

class _LoadingIndicatorState extends State<LoadingIndicator> {
  final _isLoadingNotifier = ValueNotifier(false);

  
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: _isLoadingNotifier,
      builder: (context, value, child) {
        return Visibility(
          visible: value,
          child: child!,
        );
      },
      child: const SizedBox.expand(
        child: ColoredBox(
          color: Colors.black45,
          child: Center(child: CircularProgressIndicator()),
        ),
      ),
    );
  }
}

2. MaterialApp#builderStack を設置

MaterialApp#builderStack を設置し、LoadingIndicator を上に被せるようにします。

app.dart
return MatrialApp(
  builder: (context, child) {
    return Stack(
      children: [
        Positioned.fill(child: child!),
        const Positioned.fill(child: LoadingIndicator()),
      ]
    );
  }
)

3. LoadingIndicator.show 関数を作成

外部から利用するための関数を static で定義(完全なグローバルな関数でも問題ないのでお好みで)。

loading_indicator.dart
class LoadingIndicator extends StatefulWidget {
  const LoadingIndicator({super.key});

+ static Future<void> show(Future<void> Function() cb) async {}
}

4. _LoadingIndicatorState をグローバルな変数に格納する

static な関数から、_LoadingIndicatorState の状態を変えないといけないので、_LoadingIndicatorStatestatic な関数からアクセスする必要があります。

そのために、まず、再代入可能な変数として、 LoadingIndicator._state_LoadingIndicatorState を持たせます。

loading_indicator.dart
class LoadingIndicator extends StatefulWidget {
  const LoadingIndicator({super.key});
  
  static Future<void> show(Future<void> Function() cb) async {}

+ static late _LoadingIndicatorState? _state;

  
  State<LoadingIndicator> createState() => _LoadingIndicatorState();
}

そして、LoadingIndicator._state_LoadingIndicatorState を格納する処理を書いていきます。

  • 代入 ... didChangeDependencies
    • State が変更されると毎回実行される関数(activateinitState でも問題なさそうだが、一応)
  • null の再代入 ... dispose
    • ウィジェットがツリーから永久に外れる時に実行される関数
loading_indicator.dart
class _LoadingIndicatorState extends State<LoadingIndicator> {
  // 中略

+ 
+ void didChangeDependencies() {
+   LoadingIndicator._state = this;
+   super.didChangeDependencies();
+ }

+ 
+ void dispose() {
+   LoadingIndicator._state = null;
+   super.dispose();
+ }

余談ですが、static に State を持たせる方法は、flutter_hooks を参考にしました。
https://github.com/rrousselGit/flutter_hooks/blob/d53783b18fd2cb0833a810720a7c75a96e8a4814/packages/flutter_hooks/lib/src/framework.dart#L373

mixin HookElement on ComponentElement {
  static HookElement? _currentHookElement;

5. ローディング表示切り替え処理

「ローディングの表示 → Future が完了したら、ローディング非表示」の関数を _LoadingIndicatorState に実装して、LoadingIndicator.show でそれを呼び出す処理を追加します。

Future がエラーで終了してもちゃんと非表示になるように、whenComplete でローディングを非表示にします。

loading_indicator.dart
class LoadingIndicator extends StatefulWidget {
  const LoadingIndicator({super.key});

  static Future<void> show(Future<void> Function() cb) async {
+   await _state!._show(cb);
  }

  static late _LoadingIndicatorState? _state;

  
  State<LoadingIndicator> createState() => _LoadingIndicatorState();
}
class _LoadingIndicatorState extends State<LoadingIndicator> {
  final _isLoadingNotifier = ValueNotifier(false);

+ Future<void> _show(Future<void> Function() cb) async {
+   _isLoadingNotifier.value = true;

+   await cb().whenComplete(() {
+     _isLoadingNotifier.value = false;
+   });
+ }

6. LoadingIndicator._state が null の時のエラーを実装

LoadingIndicator.show() は、LoadingIndicator がビルドされている前提で実行されるものなので、そうなっていない場合はちゃんとエラーを出すようにしましょう。
(エラーメッセージは好きに変えてください)

loading_indicator.dart
class LoadingIndicator extends StatefulWidget {
  const LoadingIndicator({super.key});

  static Future<void> show(Future<void> Function() cb) async {
+   if (_state == null || !_state!.mounted) {
+     throw FlutterError(
+       'LoadingIndicator.show() was called \n'
+       'No State of LoadingIndicator widget has created.',
+     );
+   }

    await _state!._show(cb);
  }

完成 🎉

ボタンを設置して試してみましょう。

Future<void> _onPressed() async {
  await LoadingIndicator.show(() async {
    await Future.delayed(const Duration(seconds: 3));
  });
}

https://github.com/Zudah228/flutter_app_loading

改善案

今回は、極力シンプルに実装したので、以下のような改善案が考えられます。

  • LoadingIndicator._state が気持ち悪い場合の改善
    • signals パッケージを利用する。
    • GlobalKey をグローバル変数には位置して利用する。
      • LoadingIndicator を複数配置できないようにもできる。
  • CompleterAsyncCache.ephemeral で、LoadingIndicator.show() の複数実行対策を行う。
  • indicator に value を渡せるようにする。

割とさっき思いついて(保険)、テストとかは何も書いてないので、改善案はなんでもウェルカムです。

Discussion

ケニーケニー

そう言う経験についての投稿を書いてくれてありがとうございます!便利ですよ!