本文发布于公众号:移动开发那些事: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都无法生效。
如果大家有其他更好的方案的话,欢迎一起探讨。