深入 iOS 触摸事件捕获

1,404 阅读4分钟
触摸事件的处理是移动开发中十分重要的一个环节,之前研究过 Android 的触摸事件处理机制,它与 iOS 的处理机制十分不同,可以简单描述一下:

Android 在驱动层发出触摸事件后经过处理通过 IPC 通知给应用,然后应用在 Framework 层面(Java 层)由 Activity 派发封装好的事件给最上层的 View(称之为 DecorView),一旦这个事件派发到了 View 之后,我们就可以完全掌控这个事件的流向和处理方法了。最主要的两个方法就是 onTouchEventonInterceptTouchEvent,事件首先会沿着视图层次,递归调用 onInterceptTouchEvent 方法,如果这个方法返回 true,那么表示这一层次的 View 已经接管了之后所有的触摸事件(包括移动和抬起);如果返回 false,则继续向下递归,当位于最底层(相对屏幕最靠近用户的一层)时,开始调用 onTouchEvent,然后沿着原路向上冒泡,哪一层的 onTouchEvent 返回了 true,则之后所有的触摸事件交给这层 View。时序图大体是这个样子(用 TE 表示 onTouchEvent,用 ITE 表示 onInterceptTouchEvent):

ITE (A) -> ITE (B) -> TE (C) -> TE (B) -> TE (A)

视图的层次关系是 A { B { C } },显然事件从 A 开始向下传递,但是响应是从 C 冒泡向上,但全程开发者可控。Android 的事件处理机制可以让开发者轻松实现一个重要的机制:捕获机制。

什么是捕获机制?

举个例子,当一个 Scroll View 里放置了一个 UIControl (如 UISlider) 的时候,UISlider 的滑动会阻止 Scroll View 的滚动,这时 UISlider 就捕获了 UIScrollView 的触摸事件了。

iOS 如何处理捕获?

iOS 捕获触摸事件的原理与 Android 完全不同。通过查看 UISlider 处理 touchesMoved 时的函数调用栈能够看出:

栈顶第 4 个函数(我这里用了 Swift 所以函数名经过 Naming Convention 了)是 MySlider(自己写的 UISlider 子类) 的 touchesMoved 函数,但是调用它的函数却是 UIWindow 下的一个私有方法,而并不是通过 traversal 所有视图的方式,逐级将触摸事件传递到子视图,这很有趣。

为什么会这样?这还要追溯到 iOS 事件传递的方式上。大家对 UIResponderResponder Chain 应该都很熟悉,苹果在文档中介绍过,任何事件都会由 Cocoa Framework 封装后传递给 Initial View(对于触摸事件而言)或 First Responder(对于 Remote Control、Action 或实体按键事件),如果响应方法调用了 super 方法,Framework 会接管事件的 forward 过程从而将事件沿着 Responder Chain 传递。

下面这个图片应该更清晰一些:

然后我们讨论捕获的问题。

其实对于普通 UIView 子类而言,并不存在什么捕获的问题,框架也不给你机会,因为当 UIResponder 的处理方法被调用时,那仅仅像是系统通知了你一下:这有个事件,处不处理你看着办吧。如果 Scroll View 里放了一个 UIView 的子类,你根本没有办法通过 UIResponder 的方法捕获触摸事件,当你滑动手指时,一个 cancel 事件一定会传递到你的 UIView 子类中的。唯一可以做到捕获事件作用的就是 iOS 3 引入的 UIGestureRecognizer。系统对它的处理方法也比较特殊,甚至会绕过 UIWindow 和 UIApplication 的 sendEvent 方法,直接由 RunLoop 中的一个 0 号 Source 派发给 Gesture 处理系统。虽然我们看不到 UIKit 的源码,但目前为止可以认为是定理的就是 Gesture 的处理优先级 > Responder 标准事件处理的优先级。

下面我们看看为什么 UIControl 不会被 Scroll View 夺去触摸控制权。

我们看 UIScrollView 的这两个方法(属性):

其中很重要的是 canCancelContentTouches 属性:

如果这个属性设为 YES,当 Scroll View 的 subviews 有一个已经开始处理触摸事件了,在 Scroll View 开始滑动时,正在处理触摸事件的 subview 就会收到 cancel 事件,然后触摸处理权就被 Scroll View 截获了。

UIScrollView 处理触摸的原理很简单,就是一个 UIPanGestureRecognizer 的子类,而其 Delegate 有一个 gestureRecognizerShouldBegin: 方法,个人猜测 UIScrollView 通过这个代理方法来确定是否要启动 Gesture 处理来截获触摸事件。

最后,至于为什么 UIControl 不会被截获,那是因为:

并不是因为 UIControl 很厉害...如果不想被 UIButton 拦截滚动,那就可以复写这个方法来解决了。

先写这些。