FlutterでPopScopeを用いたswipe back機能を実装する

ENSPORTS
  • 開発
Yuto Mori Yuto Mori

こんにちは。エンスポーツでエンジニアをしている森です。

今回エンスポーツで、あらためてスワイプバックの仕様を見直しました。UXにおいて「戻る」処理は意外と大切です。とくに男性ユーザの多いアプリケーションでは、片手で操作する方が多くなる関係上、「スワイプバックで戻れない」という体験が静かなストレスになってアンインストールされてしまう可能性すらあります。

今回FlutterのPopScopeを用いたswipe back機能について、調べて試行錯誤する機会がありましたので、情報をシェアさせていただきます。

※今回はiOsライクなページ遷移を実現するため、CupertinoPageRouteを使用しています。

FlutterのPopScopeとは

Flutterのv3.12.0-1.0.pre.以降から非推奨となったWillPopScopeに代わる、戻るジェスチャ(swipeやバックキーの押下、Navigatorのpopメソッド実行)をカスタマイズしたい場合に使用されるwidget classです。

FlutterでPopScopeを用いたswipe back機能を実装する

まずはごく基本的な実装から。一般的には以下のようにcanPopとonPopInvokedWithResultの2つのパラメータを記述することで制御します。

class ExampleScreen extends StatelessWidget {
  const ExampleScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      onPopInvokedWithResult: (bool didPop, Object? result) async {
        if (didPop) {
          return;
        }
        Navigator.of(context).pop();
      },
      child: Scaffold(
        appBar: AppBar(),
        body: Align(
          child: Text('ExampleScreen'),
        ),
      ),
    );
  }
}

canPopの役割

pop操作が可能かどうかを返します。

  • true : 実行されたpop操作を有効にする
  • false : 実行されたpop操作を無効にする

onPopInvokedWithResultの役割

canPopの値に関係なく、pop操作が実行された際に呼び出されます。ただし引数のdidPopは場合によって値が異なります。具体的には以下の通りです。

ユーザ操作canPop : true時canPop : false時
swipe or backキー押下時didPop : truedidPop : false
Navigatorのpopメソッド実行時didPop : truedidPop : true

つまり先ほどのコードの例では、swipeを検知すると下記の流れで進みます。

  1. onPopInvokedWithResultがdidPop:falseで実行
  2. onPopInvokedWithResult内のNavigator.of(context).pop()が実行
  3. onPopInvokedWithResultがdidPop:trueで再度実行
  4. returnで処理が完了して前画面に戻る

一方でcanPopがtrueの場合、onPopInvokedWithResultの処理自体は実行されますが、その結果に関係なく前画面に戻ります

以上が基本的なPopScopeの基本的な処理となります。

swipe back時に特定の処理を挟む場合の実装

前画面に戻るときに特定の処理を実行したい場合もありますよね。エンスポーツにも「編集中だけど本当に戻っていいんですか?」と確認ダイアログを挟むシーンがたくさんあります。

例えばテキストを編集した後に前画面に戻ろうとした際、編集したテキストを保存するか否かを確認するダイアログを表示させたい場合は、以下のように記述します。

class ExampleScreen extends StatelessWidget {
  const ExampleScreen({super.key});

  @override
  Widget build(BuildContext context) {
    late bool isEditedText = false;
    
    Future<void> showDialogFunction() async {
      await showDialog(
        context: context,
        barrierDismissible: false,
        builder: (_) {
          return AlertDialog(
            title: Text("確認"),
            content: Text("変更内容を破棄しますか?"),
            actions: [
              ElevatedButton(
                child: Text("キャンセル"),
                onPressed: () {
                  Navigator.of(context).pop();
                },
              ),
              ElevatedButton(
                child: Text("破棄する"),
                onPressed: () {
                  int count = 0;
                  Navigator.popUntil(context, (_) => count++ >= 2);
                },
              ),
            ],
          );
        },
      );
    }
    return PopScope(
      canPop: false,
      onPopInvokedWithResult: (bool didPop, Object? result) async {
        if (didPop) {
          return;
        }
        if (isEditedText) {
          await showDialogFunction();
        }else{
          Navigator.of(context).pop();
        }
      },
      child: Scaffold(
        appBar: AppBar(
         leading: IconButton(
            icon: const Icon(Icons.arrow_back),
            onPressed: () async {
              if (isEditedText) {
                await showDialogFunction();
              }else{
                Navigator.of(context).pop();
              }
            },
          ),
        ),
        body: 
        //text編集画面
      ),
    );
  }
}

これらの処理を利用して「戻る」ジェスチャを制御するのがPopScopeになります。

ただし、このPopScopeには欠点があります。

iOSでcanPop:falseの場合、swipe時にonPopInvokedWithResultが実行されません。落とし穴になりがちな部分かと思います。

これはFlutterの公式ドキュメントにも書いてあるように、AndroidとiOSではswipe動作を認識する方法に違いがあることに起因します。

canPopをfalseにしてしまうとpop操作が無効化されるだけになってしまうため、iOSのswipeでは前の画面に戻ることができません。そして残念ながら、現在(2024/12)有効な解決方法が見つかりませんでした。

(GestureDetectorを用いたswipe検知で特定の処理を実行する方法もありますが、既存のswipe操作との違和感が発生する可能性があるため、今回は採用しませんでした)

今回採用したswipe backの仕様

今回、前画面に戻るときに特定の処理を実行したいシーンでは、iOS側のswipe back処理は断念しました。

つまりAndroidではスワイプバックができるものの、iOSでは明示的に戻るボタンを押下しなければ戻れない画面が存在する仕様となっています。

Platform毎に処理を分けて、下記の方法で実装する形となりました。

class ExampleScreen extends StatelessWidget {
  const ExampleScreen({super.key});

  @override
  Widget build(BuildContext context) {
    late bool isEditedText = false;

    Future<void> showDialogFunction() async {
      await showDialog(
        context: context,
        barrierDismissible: false,
        builder: (_) {
          return AlertDialog(
            title: Text("確認"),
            content: Text("変更内容を破棄しますか?"),
            actions: [
              ElevatedButton(
                child: Text("キャンセル"),
                onPressed: () {
                  Navigator.of(context).pop();
                },
              ),
              ElevatedButton(
                child: Text("破棄する"),
                onPressed: () {
                  int count = 0;
                  Navigator.popUntil(context, (_) => count++ >= 2);
                },
              ),
            ],
          );
        },
      );
    }
    return PopScope(
      canPop: Platform.isIOS ? !isEditedText : false,
      onPopInvokedWithResult: (bool didPop, Object? result) async {
        if (didPop) {
          return;
        }
        if (isEditedText) 
          await showDialogFunction();
        }else{
          Navigator.of(context).pop();
        }
      },
      child: Scaffold(
        appBar: AppBar(
         leading: IconButton(
            icon: const Icon(Icons.arrow_back),
            onPressed: () async {
              if (isEditedText) {
                await showDialogFunction();
              }else{
                Navigator.of(context).pop();
              }
            },
          ),
        ),
        body: 
        //text編集画面
      ),
    );
  }
}

FlutterのPopScopeは意外と奥が深い

以上がFlutterのPopScopeを用いたswipe back機能に関する解説でした。

他社さまのアプリの挙動も確認してみたところ、iOS側のswipe backはみなさん苦労されていそうでした。swipe backの仕様については、よりユーザフレンドリーな操作感になるよう、今後も定期的に見直していきたいと思います。

この記事が皆さまの開発に少しでも役立つ情報を提供できたのであれば幸いです。

カジュアルにお話しませんか?

エンスポーツでは、一緒にはたらく仲間を募集しています。

アプリ開発に携わっていただけるエンジニアはもちろん、広告集客・ライティングが得意なディレクターやマーケターの方も歓迎しています。

ご興味をもっていただけましたら、ぜひ気軽にご連絡ください。

Yuto Mori

記事を書いた人

Yuto Mori

Engineer

エンスポーツでフルスタックエンジニアとして働いています。趣味は3Dモデリング。学生時代はスポーツチャンバラで日本3位になりました。