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都无法生效。

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

相关推荐
KKei16382 小时前
Flutter for OpenHarmony 编程技能树APP技术文章
flutter·华为·harmonyos
KKei16382 小时前
Flutter for OpenHarmony 个人财务管理与记账APP
flutter·华为·harmonyos
KKei16383 小时前
Flutter for OpenHarmony 本地音乐播放器APP
flutter·华为·harmonyos
陆业聪3 小时前
两次Flutter全屏白踩坑复盘:Layout的静默失败,以及AI结对编程的认知盲区
flutter·ai编程·大前端·跨端开发
KKei16383 小时前
Flutter for OpenHarmony 外语单词背诵与听力训练APP
flutter·华为·harmonyos
KKei16384 小时前
Flutter for OpenHarmony学习小组组队与打卡APP技术文章
学习·flutter·华为·harmonyos
tangweiguo030519874 小时前
Flutter 集成排查与 APK 瘦身问题解决
flutter
KKei16384 小时前
Flutter for OpenHarmony学术论文管理APP技术文章
flutter·华为·harmonyos
程序员老刘·18 小时前
Perry能取代Flutter吗?跨平台的三种技术路线
flutter·跨平台开发·客户端开发