iOS事件处理之Hit-Testing
iOS中,Hit-Testing主要用于决定哪个视图来首先处理Touch事件,确定完后,就会依据响应者链来进行事件的处理。接下来,我们将分析Hit-Testing的工作流程。
由于不确定的原因,Hit-Testing测试会被执行多次,导致单个视图的
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
方法会被调用多次,由于是幂等的,所以结果不影响。
Hit-Testing使用的搜索算法为逆前序深度遍历(逆前序遍历先访问根节点,然后进行从其索引最大的子视图到最小的子视图的遍历,既从右至左进行遍历)。当touch事件的points发生在多个视图的重叠部分时,根据算法将得到最右子树中的最深视图,而该视图就是位于界面最前端的视图。
下图展示了一个视图层级树以及对应在屏幕上的显示结果,树的分支顺序安排反应了子视图数组的顺序,比如View A
的索引小于View B
。
由上图,View A.2
和View B.1
两个视图重叠了,但是因为View B
和View A
是兄弟视图,且View B
的索引大于View A
,因此,当用户的touch事件发生在View A.2
和View B.1
重叠的部分时,Hit-Testing将返回View B.1
。
通过应用逆前序深度遍历算法,当找到第一个最深的后代视图满足后就会停止遍历。
遍历算法是以向UIWindow
发送hitTest:withEvent
消息开始的,UIWindow
是视图层级的根视图,hitTest:withEvent
方法返回的值即是包含触摸点最前端的视图。
下图展示了Hit-Testing的逻辑:
如下代码展示了原生hitTest:withEvent:
方法可能的实现:
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
hitTest:withEvent
方法首先检测视图是否允许接收touch事件,当如下条件满足时,既允许接收事件:
- 视图没被隐藏:
self.hidden == NO
- 视图允许用户交互:
self.userInteractionEnabled = YES
- 视图的alpha值大于0.01:
self.alpha > 0.01
- touch事件的触摸点在视图的bounds内:
pointInside:withEvent: == YES
当视图允许接收touch事件后,该方法将对其subviews
子视图数组的每个视图,以逆序的方式,发送hitTest:withEvent:
消息,直到返回非nil
的值。当其子视图都返回nil
或者没有子视图时,则返回视图本身。
如果视图不被允许接收touch事件,那么方法会返回nil
,且不需要对视图的子树进行遍历。
重载hitTest:withEvent:
示例
当默认的搜索算法不满足要求时,可以重载hitTest:withEvent:
来自定义。
增加视图的touch区域
当需要一个视图的touch区域大于它的bounds时,可以重载hitTest:withEvent:
方法,比如,下面展示了一个20X20的视图UIView
,该大小可能对于处理touch来说有点小,因此,可以通过重载hitTest:withEvent:
方法来在每个方向上增加10points:
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
注意,该实现能正确执行的前提是,希望响应touch事件的区域必须在其父视图的bounds范围内,或者重写父视图的
hitTest:withEvent:
方法来包含能响应touch的区域。
将touch事件传到其下面的视图,既透传
有时我们需要一个视图忽略touch事件并把他们传到其下面的视图,比如,当touch的pints在视图的子视图时,返回子视图,否则将事件透传到下面的视图,代码如下:
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
将touch事件传给子视图
父视图对所有touch事件进行重定向,传到子视图中,比如,一个图片旋转功能,由一个父视图和一个UIScrollView
组成,且设置UIScrollView
视图的pagingEnabled
属性为YES
,且设置父视图的clipsToBounds
属性为NO。
为了让UIScrollView
不仅能够响应其bounds范围内的事件,同样也能响应其父视图的bounds内的事件,可以重载父视图的hitTest:withEvent:
方法,代码如下:
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
调整Touches
传递路线
有时候我们希望视图在手势识别器识别前能够接收到touch
事件,在找到解决方法之前,我们先来了解一下默认的行为。举一个简单的例子,当一个触摸发生时,touch
对象将从UIApplication
传递到UIWindow
,然后,在将touches
发送给发生触摸的视图(因为视图继承自UIResponder
)本身前,window
首先会将touches
发送给发生触摸的视图(或其父视图)所关联的手势识别器。
手势识别器将优先识别Touch
window
会延时将touches
对象传递给视图,以便手势识别器能优先识别touch
.在延时的过程中,如果手势识别器成功识别,那么window
将不会再继续传递touch
对象到视图,而且会取消之前已经发送给视图的touch
对象。
比如,有一个手势识别器,用来识别两个手指的触摸,识别器会将触摸转换成两个独立的touch
对象,当触摸发生时,touch
对象会从Application
传到window
,接下来的流程,可以由如下表来表示。
window
会在Began
阶段通过UIGestureRecognizer
的touchesBegan:withEvent:
方法向识别器发送两个touch
对象,此时,手势识别器还没有成功识别,所以状态为Possible
.window
同时会发送相同的touches
到识别器关联的视图。window
在Moved
阶段通过UIGestureRecognizer
的touchesMoved:withEvent:
方法向识别器发送两个touch
对象,此时,手势识别器依然还没有成功识别,状态为Possible
,window
同时会发送相同的touches
到识别器关联的视图。window
在Ended
阶段通过UIGestureRecognizer
的touchesEnded:withEvent:
方法向识别器发送一个touch
对象,此时该touch
对象还没有生成足够的信息提供给识别器,但是window
将不会再向识别器关联的视图发送touch
对象。window
在Ended
阶段发送另一个touch
对象,手势识别器现在已经成功识别了触摸,所以其状态变为Recognized
,在action
消息(即识别器创建时的@selector方法)发送之前,视图会调用自己的touchesCancelled:withEvent:
(注意,是视图本身的方法,不是UIGestureRecognizer
的)方法来使之前在Began
和Moved
阶段发送的touch
对象失效,Ended
阶段的touches
将被取消。
现在,我们假设手势识别器在如上的最后一步没有成功识别,那么识别器会将其状态设为UIGestureRecognizerStateFailed
,然后,window
会在Ended
状态向视图发送两个touch
对象,并调用视图的touchesEnded:withEvent:
消息。