iOS触摸事件那点儿事

前言

这两天在实现我的动画库Demo的过程中,遇到一个令我有些疑惑的问题:在parentView上添加了UITapGestureRecognizer之后,subview中的UITableView实例无法正常响应点击事件了,但UIButton实例仍可以正常工作,经过一系列查阅资料和实验,终于理解了这是怎么一回事。

iOS中的事件分发

下面这两张图相信大家都已经看过无数遍了:
旧的 UIResponder 响应链链接已经失效了,苹果文档把图片换成了这样:

这两幅来自于Apple官方文档的图诠释了UIResponder链式分发/响应事件的整个流程,当触摸事件由系统传给UIApplication单例后,事件从最底层ParentView一层一层嵌套传递给Subviews,最终得到的可以响应该事件的targetView,再反向向上请求处理。

上图解释了 UIResponder 事件处理链的关系,而其时间分发过程,可以参看 官方文档 中的 “Determining Which Responder Contained a Touch Event” 部分。简单的说,当点击事件发生时,系统从顶层 View 开始(通常是应用的顶层 UIWindow),通过判断点击坐标是否落在视图的 frame 之中来决定是否将该点击事件交由该视图处理,而这个过程将会从顶层视图递归向下找到最内部的子视图,这个过程/方法也就是 hitTest 的过程。找到最底层视图之后,则会尝试让其处理这个事件(UIControl 的各种事件、UIGestureRecognizer 等),若其无法处理,则沿着图中的响应链一步步再向上寻找。

这些我了然于胸,所以我自信的认为,只要子视图有处理事件的能力,那么自然会覆盖掉父视图的处理逻辑。

于是当我自信满满的写完demo,狠狠地选中tableView cell的时候:

控制台输出了如下内容:

1
MotionExperiments[5136:328131] Background was tapped !

手势识别称大王

查了一番解决方案后,在栈溢出找到了这么一篇回答,看到之后我其实是崩溃的,意思是我想写个通用的基于UITableView的组件,还得在每个有UIGestureRecognizer的视图中加入delegate来规避手势冲突😨。

尽管千百个不愿意,但正是这个在ParentView上的tap手势,导致了我的tableView手势识别的异常,所以只好通过实现

1
2
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool 

来解决这个冲突。

随后,我又在官方文档中找到了如下内容:
(文档直接贴上来有点长,大致意义如下)

苹果官方又把文档给删了

UIGestureRecognizer 的文档中,有这么一段话:

A window delivers touch events to a gesture recognizer before it delivers them to the hit-tested view attached to the gesture recognizer.(Blah..Blah..Blah..) If a gesture recognizer recognizes its gesture, the remaining touches for the view are cancelled.

也就是说:

默认情况下,识别一个触摸事件的时候,手势识别将优先UIResponder响应链式机制,而具体体现为:
1.手势识别开始、变化的过程,gestureRecognizertouchesBegan,touchesMoved同时处理响应事件。

2.但是,只有在gestureRecognizer判定失败的时候(譬如一个tapGestureRegcognizer遇到了一个swipe手势),系统才会触发UIResponder响应链中的touchesEnded,否则,会触发touchesCancelled

也就是说,一个手势是否能完整地从UIResponder响应链中传递完成,主要取决于gestrueRecognizer是否”愿意”这么做。

也就是说,手势识别器拥有更高的优先级,只有自己玩剩下的才传给UIResponder响应链处理,当然,也可以通过设置cancelsTouchesInView来强制使触摸流程执行响应链流程,但这仍改变不了手势识别器拥有更高优先级的事实。

接下来验证一下通过文档总结出的结论,看看这个Demo:

其中青蓝色的背景是ParentView,我为它添加了一个UITapGestureRecognizer,而TableView是它的子视图,我重写了touchesBegan/touchesMoved等共四个相应的触摸响应函数用来做控制台输出。

接下来我对着TableView进行如下三个操作:

1.单击,就如同我打算正常的选中某一行一样,对应控制台输出:

1
EventHandlingTest[5386:361029] Background Tap 

2.短按,即按住一小会儿后松开:

1
2
3
4
EventHandlingTest[5386:361029] touches began
EventHandlingTest[5386:361029] Background Tap
EventHandlingTest[5386:361029] touches cancelled

3.长按再松开:

1
2
3
EventHandlingTest[5386:361029] touches began 
EventHandlingTest[5386:361029] touches ended

UIControl的魔力

经过上面的“研究”,我们已经明白,UIGestureRecognizer并不按套路出牌,View的父子关系不影响其事件处理,但为什么我的UIButton就可以正常工作呢?

以上表述仅适用于子视图没有相同的手势识别器的情况,若ParentViewSubview同时有一个相同的UIGestureRecognizer子类,则Subview响应事件会覆盖ParentView

我又在官方文档找到了如下内容:

When a control-specific event occurs, the control calls any associated action methods right away. Action methods are dispatched through the current UIApplication object, which finds an appropriate object to handle the message, following the responder chain if needed.

也就是说,一个UIControl子类(UIButton/UISwitch)方法,其响应事件的方式不同于普通的UIView,它们的事件处理由UIApplication单例直接控制!尽管文档中提到其事件处理部分仍可能使用UIResponder响应链逻辑,但其事件分发与普通的UIView完全不同。

我们再看一个Demo:

和前面的Demo类似,不过此处加入了一个橙色的UIButton,此处TableViewButton是同层级的,均为青蓝色ParentView的子视图,同样,我重写了ButtontouchesBegan等一系列方法。

与前一个TableView Demo不同,我单击Button后,便立刻来到了touchesBegan中(而不是短按),此时,对比一下进入touchesBegan的时候的ButtonTableView的调用栈:

Button:

TableView:

可以清楚地看到,Button的事件直接就由UIApplication传递过来了,而TableView走的是正常的响应链,经过了数次的消息转发。注意TableView调用栈红框底部外的第一条调用:_UIGestureEnvironmentSortAndSendDelayedTouches () ,从命名上我们可以看出,TableView的点击事件是被延迟发送的。

理清头绪

经过上面的探索,我们了解到,对于一个普通的UIView来说,想处理一个触摸事件还真是难呀!接下来,我对着Demo又进行了一番折腾,不负责任的有了以下的总结:

1.当触摸事件开始后,系统会先尝试分发事件,对整个 Window递归调用 hitTest

2.系统判别返回的 UIView是不是 UIControl的子类,如果是的话,直接将整个触摸事件交付 UIControl实例完成

3.如果返回的是普通 UIView,且此时有相应的 UIGestureRecognizer,将事件交由该 recognizer处理,并同步走 touchesBegan以及 touchesMovde流程,最终由相应的 recognizer决定调用 touchesEnded还是 touchesCancelled

4.尽管在 UIGestureRecognizer中有 .delaysTouchesBegan这一变量,且默认设置为 false,但在我的Demo中( UITableViewTap的冲突),点击事件必须是轻按才会触发 UITableViewtouchesBegan,而点击并不会触发,并且 touchesBeganhitTest调用的间隔明显较长,而Demo中的 UIButton则可以在点击后立刻进入 touchesBegan,且其调用与 hitTest时间间隔明显短一些,结合前文TableView的调用栈,感觉这与文档中所提到的 touchesBegan/ touchesMovedgestureRecognizer状态更新同步略有出入