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 | 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.手势识别开始、变化的过程,gestureRecognizer
与touchesBegan
,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 | EventHandlingTest[5386:361029] touches began |
3.长按再松开:
1 | EventHandlingTest[5386:361029] touches began |
UIControl的魔力
经过上面的“研究”,我们已经明白,UIGestureRecognizer
并不按套路出牌,View
的父子关系不影响其事件处理,但为什么我的UIButton
就可以正常工作呢?
以上表述仅适用于子视图没有相同的手势识别器的情况,若
ParentView
和Subview
同时有一个相同的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
,此处TableView
和Button
是同层级的,均为青蓝色ParentView
的子视图,同样,我重写了Button
的touchesBegan
等一系列方法。
与前一个TableView Demo不同,我单击Button
后,便立刻来到了touchesBegan
中(而不是短按),此时,对比一下进入touchesBegan
的时候的Button
和TableView
的调用栈:
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中(UITableView
与Tap
的冲突),点击事件必须是轻按才会触发UITableView
的touchesBegan
,而点击并不会触发,并且touchesBegan
和hitTest
调用的间隔明显较长,而Demo中的UIButton
则可以在点击后立刻进入touchesBegan
,且其调用与hitTest
时间间隔明显短一些,结合前文TableView
的调用栈,感觉这与文档中所提到的touchesBegan
/touchesMoved
与gestureRecognizer
状态更新同步略有出入