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源码,我们发现问题的核心在于 ModalRoutepopGestureEnabled 属性判断逻辑。当 PopScope.canPop 设置为false 时,路由的popDisposition 会被设置为 RoutePopDisposition.doNotPop ,这会导致 popGestureEnabled 变为 false ,从而禁用iOS的侧滑手势识别。

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

3 如何解决?

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

我们的目标是:当用户在屏幕边缘左滑时,即使canPopfalse,也要触发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的左滑手势,当PopScopecanPop设置为false时,onPopInvokedWithResult回调都能可靠地被触发,确保了跨平台一致的用户体验,让开发者能够真正无忧地处理路由拦截逻辑。

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

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

相关推荐
500841 小时前
鸿蒙 Flutter 蓝牙与 IoT 开发进阶:BLE 设备连接、数据交互与设备管理
flutter·华为·electron·wpf·开源鸿蒙
子春一1 小时前
Flutter 测试金字塔:从单元测试到端到端验证的完整工程实践
flutter·单元测试
kirk_wang2 小时前
Flutter 图表库 fl_chart 鸿蒙端适配实践
flutter·移动开发·跨平台·arkts·鸿蒙
空中海2 小时前
1.Flutter 简介与架构原理
flutter·架构
晚霞的不甘2 小时前
从单设备到全场景:用 Flutter + OpenHarmony 构建“超级应用”的完整架构指南
flutter·架构
小a彤3 小时前
Flutter 实战教程:构建一个天气应用
flutter
克喵的水银蛇3 小时前
Flutter 通用列表项封装实战:适配多场景的 ListItemWidget
前端·javascript·flutter
Non-existent9873 小时前
Flutter + FastAPI 30天速成计划自用并实践-第7天
flutter·oracle·fastapi
帅气马战的账号3 小时前
开源鸿蒙+Flutter:跨端开发的分布式协同与数据互通实践
flutter