Flutter PopScope:iOS左滑返回失效分析与方案探讨

本文发布于公众号:移动开发那些事:Flutter PopScope:iOS左滑返回失效分析与方案探讨

1 背景

在Flutter 3.10版本中,WillPopScope被弃用并替换为更现代化、功能更强大的PopScope组件。PopScope主要用于拦截用户尝试退出当前路由的各种操作(如Android的返回键、AppBar的返回箭头、以及iOS的屏幕边缘左滑手势)。

PopScope的核心属性有两个:

  • canPop:一个布尔值,设置为 false 时,会阻止路由退出。
  • onPopInvokedWithResult:当尝试退出路由时触发的回调,并返回是否成功退出(即didPop)。

在开发过程中,笔者发现了一个显著的平台差异:

  • Android/其它平台: 当我设置 canPop = false 并尝试使用返回键或返回箭头时,onPopInvokedWithResult 会被调用。
  • iOS: 当我设置 canPop = false 并尝试使用屏幕边缘左滑手势 (Edge Swipe Gesture) 时,onPopInvokedWithResult 不会被调用,并且左滑手势会被系统完全忽略,导致应用看起来"卡住"了,没有提供任何用户反馈。

这无疑是一个严重的问题,它导致我们在iOS上无法通过PopScope来捕获左滑手势并执行自定义逻辑(如弹出"是否保存"对话框)。

一个典型的使用示例为:

less 复制代码
PopScope(
    canPop: false,
    onPopInvokedWithResult: (value, _) {
    	// 在iOS平台这里永远不会被回调
        if (!value) {
          // do custom action here
          }
          return;
     },
     child: Container())

2 失效原因分析

2.1 PopScope失效原因

PopScope组件是通过 Platform Navigator 与原生平台的返回事件机制进行通信的, 主要负责监听和阻止 Navigator.pop() 调用或系统触发的 返回操作(Android 返回键,或者 iOS 的左滑返回)。

但iOS 左滑有特殊性: iOS 的左滑手势(Interactive Pop Gesture)是独立于 Flutter Navigator 运行的原生手势。当这个手势被启动时,如果它发现 PopScope 设置了 canPop: false,它会简单地取消手势并停止,而不会向 Flutter 的 Navigator 发送一个明确的弹出(Pop)请求。

因此,onPopInvokedWithResult 回调自然不会被触发,因为它只在 Flutter 接收到 Navigator.pop() 调用或系统返回键事件时触发。

  • Android: Android的返回键事件(Back Button Event)是一个明确的、可拦截的 系统事件,它会直接传递给Flutter引擎,引擎继而触发PopScope的回调。
  • iOS (左滑手势): iOS的左滑返回手势(Interactive Pop Gesture)是由UINavigationController管理的。

在默认情况下,当一个Flutter View(FlutterViewController)被推入到原生导航栈中时:

  • 如果设置了 canPop = false,Flutter引擎会通知原生层"当前路由不可退出"。
  • 在iOS上,这个通知只阻止 了系统尝试自动执行 Pop 操作 ,但没有 将"用户进行了左滑手势"这一手势输入事件 转化为一个通用的"Pop 尝试"事件并传给PopScope。相反,系统只是简单地禁用了这个手势的默认行为,并未向上层报告任何事件。

简而言之,Android传递的是一个"意图" (返回键被按下),而iOS传递的是一个"手势",且当手势被禁用时,这个"意图"就从未产生。

2.2 源码分析

通过分析Flutter源码,我们发现问题的核心在于 ModalRoute 的 popGestureEnabled 属性判断逻辑。当 PopScope.canPop 设置为false 时,路由的popDisposition 会被设置为 RoutePopDisposition.doNotPop ,这会导致 popGestureEnabled 变为 false ,从而禁用iOS的侧滑手势识别。

具体来说,在 CupertinoBackGestureDetector 中,有一个 enabledCallback 回调用于控制手势检测器的启用状态。当 enabledCallback 返回 false 时,手势检测器不会记录触摸起点,后续的一系列手势监听方法都不会被触发。

3 如何解决?

要解决这个问题,我们不能等待Flutter引擎去捕获一个它根本没收到的事件。我们必须在原生iOS层 工作,主动拦截 这个左滑手势,并在拦截成功后,手动地将其作为一个事件发送给Flutter。

我们的目标是:当用户在屏幕边缘左滑时,即使canPop为false,也要触发onPopInvokedWithResult。这里采用了在原生层面拦截左滑的手势,并传递给Flutter层的方案。

这里在原生的核心代码为:

swift 复制代码
  /// 如果需要,设置左滑返回手势拦截(在这个plugin初始化时调用)
  ///
  /// 该方法会检查 rootViewController 的类型:
  /// - 如果是 UINavigationController,直接使用
  /// - 如果是 FlutterViewController,会创建或使用现有的 NavigationController
  private func setupInteractivePopGestureIfNeeded() {
  guard let window = UIApplication.shared.windows.first,
         let rootViewController = window.rootViewController else {
                return
            }

        if let navController = rootViewController as? UINavigationController {
                // 直接使用现有的 NavigationController
                self.navigationController = navController
         } else if let flutterVC = rootViewController as? FlutterViewController {
                // 如果 FlutterViewController 已经有 navigationController,直接使用
                if let existingNavController = flutterVC.navigationController {
                    self.navigationController = existingNavController
                } else {
                    // 否则创建新的 NavigationController 并封装 FlutterViewController
                    // 先将 window.rootViewController 设置为 nil,避免视图层次冲突
                    window.rootViewController = nil
                    let newNavController = UINavigationController(rootViewController: flutterVC)
                    self.navigationController = newNavController
                    // 再设置新的 NavigationController 为 rootViewController
                    window.rootViewController = newNavController
                }
         }
    setupInteractivePopGesture()
  }


  /// 设置左滑返回手势的拦截
  ///
  /// 保存原始的手势识别器代理,然后将自己设置为新的代理,以便拦截手势事件
  private func setupInteractivePopGesture() {
    // 保存原始的代理
    self.originalDelegate = self.navigationController?.interactivePopGestureRecognizer?.delegate
    
    // 设置自己为代理
    self.navigationController?.interactivePopGestureRecognizer?.delegate = self
  }

    /// 控制手势识别器是否应该开始识别手势
  ///
  /// 当检测到左滑返回手势时,会通知 Flutter 层处理,并返回 false 阻止系统默认行为
  public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    // 检测到系统左滑手势,发送事件给 Flutter  关键点:当发现是对应事件时,发送给Flutter层
    if gestureRecognizer == self.navigationController?.interactivePopGestureRecognizer {
      channel?.invokeMethod("onSystemBackGesture", arguments: nil)
      // 返回 false 阻止系统默认的返回行为,由 Flutter 层处理
      return false
    }
    
    // 其他手势交由原来的代理处理
    if let originalDelegate = self.originalDelegate {
      return originalDelegate.gestureRecognizerShouldBegin?(gestureRecognizer) ?? true
    }
    
    return true
  }

而在Flutter层,则监听对应的方法回调就可以:

dart 复制代码
  /// 处理来自原生端的方法调用
  Future<dynamic> _handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'onSystemBackGesture':
        // 当接收到系统返回手势时
        debugPrint('PopscopeIos: onSystemBackGesture pop');
        // 1. 如果设置了自动处理导航,尝试调用 maybePop()
        if (_autoHandleNavigation) {
          final navigator = _navigatorKey?.currentState;
          if (navigator != null) {
          	// 关键点2: 这里其实就是调用导航的api: Navigator.maybePop(context);
            await navigator.maybePop();
          } else {
            debugPrint('PopscopeIos: NavigatorState is null, cannot pop');
          }
        }
        
        // 2. 调用用户自定义回调(无论是否自动处理)
        _onSystemBackGesture?.call();
        break;
      default:
        throw MissingPluginException('未实现的方法: ${call.method}');
    }
  }

当调用了Navigator.maybePop(context)的方法后,不管你PopScope里的canPop的值设置为什么,对应的回调方法onPopInvokedWithResult都会被调用了;

这个库对应的代码放在github:github.com/WoodJim/pop...

4 总结

通过在原生iOS层面上介入,我们成功地将iOS的左滑手势事件 手动转化为一个Flutter可识别的 Pop 事件,从而弥补了原生和框架之间的差异。

现在,无论是Android的返回键还是iOS的左滑手势,当PopScope的canPop设置为false时,onPopInvokedWithResult回调都能可靠地被触发,确保了跨平台一致的用户体验,让开发者能够真正无忧地处理路由拦截逻辑。

但这个方案有个致命的缺点:只能保证有回调,无法做到跟手的左滑效果 ,如果想做到很丝滑的跟手的处理,还是需要去研究CupertinoPageTransitionsBuilder 相关的代码来看如何实现,但当项目使用GetX来做路由时,好像不管怎样设置这个CupertinoPageTransitionsBuilder都无法生效。

如果大家有其他更好的方案的话,欢迎一起探讨。

相关推荐
梦想的颜色3 小时前
【编程实战】AI 时代 APP 开发全栈硬核指南:技术选型 + AI 架构 + 模型落地全维度决策
python·flutter·react native·react.js·ai·桌面应用·milvus
yuanlaile1 天前
2026年全新Flutter 跨平台开发全栈学习资料整理
flutter·getx·鸿蒙 next·flutter开发鸿蒙·flutter入门实战·flutter全栈
恋猫de小郭1 天前
Genkit Dart 1.0 发布,Flutter 原生的 AI Agent 终于完整了
android·前端·flutter
第10086个小白1 天前
flutter桌面端打包方式(Inno Setup Compiler)版本
前端·flutter
陆断枫2 天前
Flutter 3.47 动画引擎盘点:Impeller 全平台,和那些"新特性"
flutter
程序员老刘2 天前
Flet 1.0 思路很好,可惜来晚了
flutter·ai编程·客户端
m0_738185822 天前
Flutter 鸿蒙化实战:qr_code_scanner_plus 适配 OpenHarmony,二维码扫描
flutter·华为·harmonyos·鸿蒙
恋猫de小郭2 天前
Flutter + EmbeddingGemma 2,谷歌发布完全端侧的 AI Edge Foresight
android·前端·flutter
君赏3 天前
基于 FlutterPatch 快速接入 Flutter 热更新
flutter
律宏阔3 天前
Dart FFI 内存管理:用 using + Arena 替代嵌套 try-finally
前端·flutter