🔘

【Flutter】多重タップ禁止ボタンの実装

に公開

要約

多重タップ禁止をRiverpodのfamilyを使って実現する方法の紹介です。
フォーム送信やAPI呼び出し中に同じボタンが連打されて二重実行されるのを防ぐには、ロード中フラグでonPressedをnullにして無効化するのが基本です。FlutterのボタンはonPressedがnullだと自動で非活性へと変化します。
Riverpodならproviderのfamilyで「ボタン単体/ボタングループ」ごとにローディング状態を分離・共有できます。
そのような基本的な多重タップ禁止ボタンの実装方法の紹介をしていきます。

対象

  • Flutterを扱う初級者〜中級者
  • Riverpodで状態管理しており、ボタン単体/複数ボタンの同時制御をしたい人

問題設定

「送信」や「購入」などの通信で時間を要する処理はUI応答が遅れがちです。その瞬間にユーザーがもう一度タップすると、同じ非同期処理が重複して走り、二重APIコール・二重画面遷移につながります。
さらに、「画面内の2つ以上のボタンのうち、どれかタップされたら全部非活性にしたい」という要件も中にはあります。(例:全体更新ボタンがタップされたら、部分更新ボタンは非活性にしたいなど)

ーーーーーーーーーーーーーーーー

解決アプローチ 〜フロントでの制御〜

ボタンの活性・非活性の実装

ロード中フラグ(isLoading)を立て、ボタンWidgetのonPressedにフラグがtrueの時はnullを渡すように実装するとボタンが無効化されます。
フラグの更新について、処理の最後に反転し忘れがあるので注意です。API呼び出し等でException吐く可能性がある時は、finally句でフラグの値を反転させるようにしましょう。

画面毎に異なる状態を保持したいというケースがあると思うので、それを実現するのに、Riverpodのfamilyを使います。
画面内で、ボタンAのタップ後、ボタンAとボタンBの両方ともユーザーにタップさせたくない状況もあると思います。その際は同一のfamilyを参照するようにすることで実現できます。

サンプルコード

StateとNotifierの実装です。
Notifierはfamilyを使い、同じ仕組みを使いまわせるようにしてます。

// Stateの実装

abstract class SubmitButtonState with _$SubmitButtonState {
  const factory SubmitButtonState({required bool isLoading}) =
      _SubmitButtonState;
}

// Notifierの実装
class SubmitButtonStateNotifier extends Notifier<SubmitButtonState> {
  SubmitButtonStateNotifier({required this.name});

  final String name;

  
  SubmitButtonState build() {
    return SubmitButtonState(isLoading: false);
  }

  void setLoading(bool isLoading) {
    state = state.copyWith(isLoading: isLoading);
  }
}

画面側の実装です。
ボタン1は単体で制御したいケース、ボタン2はグループで制御したいケースの実装となってます。

  
  Widget build(BuildContext context, WidgetRef ref) {
    // ボタン1用のState/Notifier
    final button1State = ref.watch(submitButtonStateProvider('ボタン1'));
    final button1Notifier = ref.watch(
      submitButtonStateProvider('ボタン1').notifier,
    );
    // ボタン2用のState/Notifier
    final button2State = ref.watch(submitButtonStateProvider('ボタン2'));
    final button2Notifier = ref.watch(
      submitButtonStateProvider('ボタン2').notifier,
    );

    return Scaffold(
      appBar: AppBar(title: Text('Submit Button')),
      body: Column(
        children: [
          // *****************************
          // 単体のボタン
          // *****************************
          Text('単体のボタン'),
          ElevatedButton(
            onPressed: button1State.isLoading
                ? null
                : () async {
                    print('【ボタン1】タップ処理開始');
                    button1Notifier.setLoading(true);
                    await Future.delayed(Duration(seconds: 2));
                    button1Notifier.setLoading(false);
                    print('【ボタン1】タップ処理終了');
                  },
            child: button1State.isLoading ? Text('Loading...') : Text('ボタン1'),
          ),
          SizedBox(height: 30),
          // *****************************
          // 一緒に制御したいボタングループ
          // *****************************
          Text('一緒に制御したいボタングループ'),
          Row(
            children: [
              ElevatedButton(
                onPressed: button2State.isLoading
                    ? null
                    : () async {
                        print('【ボタン2-1】タップ処理開始');
                        button2Notifier.setLoading(true);
                        await Future.delayed(Duration(seconds: 2));
                        button2Notifier.setLoading(false);
                        print('【ボタン2-1】タップ処理終了');
                      },
                child: button2State.isLoading
                    ? Text('Loading...')
                    : Text('ボタン2-1'),
              ),
              ElevatedButton(
                onPressed: button2State.isLoading
                    ? null
                    : () async {
                        print('【ボタン2-2】タップ処理開始');
                        button2Notifier.setLoading(true);
                        await Future.delayed(Duration(seconds: 2));
                        button2Notifier.setLoading(false);
                        print('【ボタン2-2】タップ処理終了');
                      },
                child: button2State.isLoading
                    ? Text('Loading...')
                    : Text('ボタン2-2'),
              ),
              ElevatedButton(
                onPressed: button2State.isLoading
                    ? null
                    : () async {
                        print('【ボタン2-3】タップ処理開始');
                        button2Notifier.setLoading(true);
                        await Future.delayed(Duration(seconds: 2));
                        button2Notifier.setLoading(false);
                        print('【ボタン2-3】タップ処理終了');
                      },
                child: button2State.isLoading
                    ? Text('Loading...')
                    : Text('ボタン2-3'),
              ),
            ],
          ),
        ],
      ),
    );

まとめ

ボタンの制御方法に関して、いかがだったでしょうか。この方法は一例として、参考にしていただけたら幸いです。別の方法の方が良いとか、この記事に対する質問でも良いので、何かコメントいただけると幸いです。
制御方法は他にもあったり、より堅牢にする方法もあるようです。この記事を書きながらAsyncCache.ephemeral()を使用する方法であったり、IgnorePointer / AbsorbPointerを使用する方法に遭遇しました。どのような要件で実装したいのかにより、何が一番最適なのかが決まると思います。各プロジェクトにあった実装を探して実装していきましょう。

Discussion