跨平台框架都会面对和原生平台沟通的问题,Flutter 也不例外,在实际工程落地的过程中经常会碰到手势识别交互的问题。本文介绍了西瓜视频解决 Flutter 和 iOS 手势冲突的方案,详细内容如下。
Flutter 进阶:处理 iOS 手势冲突
背景
客户端日常开发中,手势识别是交互设计中不可或缺的功能,为此 Flutter 和 iOS 都提供了一套手势系统,同时,为了让 Flutter 页面融入进 iOS 原生 UI 中,Flutter 提供了一个 UIView 的子类(这里简称 FlutterView),所有的屏幕点击信息都会通过 UIView 定义的几个方法(touchBegin/Move/Cancel/End)传入 FlutterView,从而被 Flutter 手势系统处理。
问题
西瓜视频在实际使用过程中发现了一个问题,场景是这样:西瓜 iOS 客户端所有页面都有全屏右划退出功能,这个功能的实现是将一个 PanGestureRecognizer 添加到 NavigationController 的 View 上,只要识别到右划手势,就退出当前页面。
在测试的时候我们发现 Flutter 页面的列表都不能划动了,怎么回事?
了解 iOS 手势的同学应该知道一个知识:处理屏幕触摸事件时,GestureRecognizer 拥有比 touchXXX 方法更高的优先级,默认情况下 GestureRecognizer 处理不了的触摸事件才会流转到 touchXXX 方法处理。
问题就是由于这个机制引起的:NavigationController 上的 PanGestureRecognizer 消费了所有的触摸事件,并没有把这些事件流转到 FlutterView,所以 Flutter 页面的所有手势都失效了。
第一次尝试
既然原因是 FlutterView 没有处理触摸事件的机会,那我们尝试的目标也明确了:让 FlutterView 有处理的机会就好了,这个也很容易实现,iOS GestureRecognizer 有一个属性 cancelsTouchesInView,这个属性会控制 GestureRecognizer 要不要将触摸事件流转给 UIView 的 touchXXX 方法处理。
When this property is YES (the default) and the receiver recognizes its gesture, the touches of that gesture that are pending are not delivered to the view and previously delivered touches are cancelled through a touchesCancelled:withEvent: message sent to the view. If a gesture recognizer doesn’t recognize its gesture or if the value of this property is NO, the view receives all touches in the multi-touch sequence.
看上去我们只需将 NavigationController 的 PanGestureRecognizer 的 cancelsTouchesInView 设置为 NO 即可。
修改完之后,实际测试发现还是有问题,虽然垂直滚动的列表可以正常滑动了,但是横向滚动的列表的表现是不对的:当有横划列表时,不仅列表在滚动,整个页面也在向右滑动做退出动画。
我们期望的交互效果是:当用户在划动横向列表时,全屏手势后退效果应该是不生效的才对。
问题的根本原因是全屏右划后退手势和 FlutterView 都在处理右划触摸事件,而绝大多数交互场景,我们都应该遵循这样的原则:父控件和子控件都能处理某个手势时,应该优先让子控件处理,而不是父子都处理。
继续尝试
经过上次尝试,我们发现单单让 FlutterView 得到处理触摸事件的机会是不够的,我们还需要让 FlutterView 获得和 iOS GestureRecognizer 『平等竞争』的机会,因为有很多场景我们需要 FlutterView 独自处理触摸事件。
对于 iOS 的 UI 世界来说, FlutterView 是一个试图融入这个世界的『外人』,『外人』想在一个新环境『平等竞争』只有一条安全的路:熟悉并利用新环境的『游戏规则』。那么什么是 iOS 的『游戏规则』呢?
这里针对手势场景列几条规则:
- GestureRecognizer 比 UIView 的 touchXXX 方法有更高的优先级。
- 所有的 GestureRecognizer 都可以平等竞争触摸事件的处理权。
- GestureRecognizer 可以依赖公开的机制(requireGestureRecognizerToFail,GestureRecognizerDelegate 等)精细化配置优先级和具体的行为。
FlutterView 就是一个 UIView 而已,由于规则 1 的限制,他天生低人一等,无法平等竞争,而破坏规则不现实,投机取巧又不长久,如果你就是这个 FlutterView 该怎么办呢?
答案是:找个代理人,替你竞争。
代理人是什么?
代理人就是一个自定义的 GestureRecognizer(后续称为 ProxyGestureRecognizer),他主要负责以下事情:
- 接收 iOS 触摸事件,并传递给 FlutterView(cancelsTouchesInView 设置为 NO 即可)。
- 将 FlutterView 内部手势处理的状态映射成 GestureRecognizer 定义的状态。
- 根据状态去和其他 iOS GestureRecognizer 竞争后续触摸事件的处理权。
主体逻辑和普通的 GestureRecognizer 一样,只有第二项比较特殊,普通的 GestureRecognizer 会根据自己内部逻辑来计算状态,而 ProxyGestureRecognizer 是根据 FlutterView 的手势处理情况来计算状态。
这里大部分状态转移的逻辑和实现一个普通 GestureRecognizer 很相似,只有 possible -> began 以及 possible -> failed 比较特殊,这两个转移的意思是:如果 FlutterView 内部没有任何手势能够处理 possible 状态时传入的触摸事件,则状态变为 failed,即 FlutterView 放弃对后续触摸事件的处理权,反之,则状态变为 began,即 FlutterView 可以处理后续的触摸事件。
如果能够实现这样一个 ProxyGestureRecognizer,我们就可以通过 requireGestureRecognizerToFail 方法让 ProxyGestureRecognizer 优先处理触摸事件,ProxyGestureRecognizer 处理不了再交给全屏后退手势。更进一步的,为了更好的用户体验,我们可以通过 GestureRecognizerDelegate 设置屏幕最左侧 30 像素依然优先交给全屏后退手势,这样能避免全屏都是横划列表的情况下无法用手势后退的问题。所有这些精细化的配置都得益于前面说的基本方法:
熟悉并利用 iOS 世界的『游戏规则』。
关键的状态转移代码如下:
复制代码
- (void)flutterHandleTouch:(BOOL)isWorking{ if (isWorking && self.state == UIGestureRecognizerStatePossible) { self.state = UIGestureRecognizerStateBegan; return; } if (!isWorking && self.state == UIGestureRecognizerStatePossible) { self.state = UIGestureRecognizerStateFailed; return; }}
复制代码
获得 FlutterView 内部手势状态
上一节说了 ProxyGestureRecognizer 的状态转移比较特殊,它需要知道 FlutterView 内部有没有手势能处理触摸事件,以及何时开始处理。如果拿不到这些信息,就无法实现这个 ProxyGestureRecognizer,那我们能不能知道这些信息呢?
结论是我们可以通过自定义 Flutter GestureRecognizer 来获得这些信息。 (接下来进入 Flutter 的手势世界,由于 Flutter 手势名字也叫 GestureRecognizer,所以不要和 iOS 搞混哦~)
Flutter 的手势系统有一个『手势竞技场』的概念,它负责解决手势冲突,手势冲突的胜者会被调用 acceptGesture,败者会被调用 rejectGesture。有了这个机制在,我们只需要把一个自定义的 GestureRecognizer 『送进』每一次手势冲突的竞技场,如果 acceptGesture 被调用了,则说明没有任何其他 GestureRecognizer 能够处理触摸事件,反之如果 rejectGesture 被调用了,则说明至少有一个其他 GestureRecognizer 能够处理触摸事件。
实现这样的自定义手势需要满足两个条件:
- 要能持续接收触摸事件,因为有些手势判断自己是否能处理需要花费一定时间(比如长按手势),如果自定义手势很快的就确定了自己能或不能接收触摸事件,则可能忽略了长按类的手势。
- 要能与所有手势发生冲突。
经过测试发现 PanGestureRecognizer 就能满足第一个条件,我们的自定义 GestureRecognizer 继承 PanGestureRecognizer 就可以了。
第二个条件也很容易达成:将自定义 GestureRecognizer 添加到根 Widget 外层,这样它就能够与所有的手势发生冲突。
获得了 FlutterView 内部手势是否在处理触摸事件的信息后,通过 Platform Channel 传递给 iOS 层的 ProxyGestureRecognizer,再由它实现上述的状态转移逻辑即可。
自定义手势代码如下:
复制代码
class _PointerTracker extends PanGestureRecognizer { bool _flutterGestureIsWorking = false; @override void rejectGesture(int pointer) { super.rejectGesture(pointer); _flutterGestureIsWorking = true; _notify(); } @override void acceptGesture(int pointer) { super.acceptGesture(pointer); _flutterGestureIsWorking = false; _notify(); } void _notify() { GestureConflict.flutterGestureStateChanged(_flutterGestureIsWorking); }}
复制代码
完成这个 GestureRecognizer 之后,只需要将他简单封装成一个 FlutterGestureTracker Widget,套在 Flutter 根 Widget 上即可工作。
关键代码如下:
复制代码
class FlutterGestureTracker extends StatelessWidget { FlutterGestureTracker({Key key,this.child}) : super(key: key); final Widget child; @override Widget build(BuildContext context) { return RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { _PointerTracker: GestureRecognizerFactoryWithHandlers< _PointerTracker>( () => _PointerTracker(), //constructor (_PointerTracker instance) { //initializer }, ) }, child: child, ); }}
复制代码
继续探索
我们使用了代理机制来解决这个问题,看上去已经没事儿了,但是我们的解决方案在本质上是将 Flutter 的内部状态映射成 iOS 的状态,由于两边的设计理念不一致,所以必然有些情况是难以一一映射的,比如 Flutter 里不止有 GestureRecognizer 能够处理触摸事件,Listener 也可以,由于 Listener 不会进入手势竞技场竞争,我们的方案实际上是忽略了 Listener 的。
目前有个思路是依赖 Dart Dill Transform 做 AOP,给 Listener 的回调方法注入一些逻辑来记录 Listener 是否在工作。这个方法我们也在调研中,还不成熟,并且大部分情况下我们都不推荐直接通过 Listener 监听触摸事件,官方也推荐使用 GestureDetector : /// Rather than listening for raw pointer events, consider listening for /// higher-level gestures using [GestureDetector]. 如果你的项目一定要依赖 Listener,希望你谨慎考虑本文的方案,如果有其他兼容 Listener 的思路也欢迎大家一起讨论。
总结
跨平台框架都会面对和原生平台沟通的问题,这是跨平台的本质决定的,Flutter 也不例外,我们在实际工程落地的过程中踩的坑多数都是这类问题,本质上手势冲突的问题也属于这一类,后续碰到类似问题,大家可以尝试使用代理机制来处理。