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.2View B.1两个视图重叠了,但是因为View BView A是兄弟视图,且View B的索引大于View A,因此,当用户的touch事件发生在View A.2View B.1重叠的部分时,Hit-Testing将返回View B.1

通过应用逆前序深度遍历算法,当找到第一个最深的后代视图满足后就会停止遍历。

遍历算法是以向UIWindow发送hitTest:withEvent消息开始的,UIWindow是视图层级的根视图,hitTest:withEvent方法返回的值即是包含触摸点最前端的视图。
下图展示了Hit-Testing的逻辑:

如下代码展示了原生hitTest:withEvent:方法可能的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
return nil;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
CGRect touchRect = CGRectInset(self.bounds, -10, -10);
if (CGRectContainsPoint(touchRect, point)) {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
return nil;
}

注意,该实现能正确执行的前提是,希望响应touch事件的区域必须在其父视图的bounds范围内,或者重写父视图的hitTest:withEvent:方法来包含能响应touch的区域。

将touch事件传到其下面的视图,既透传

有时我们需要一个视图忽略touch事件并把他们传到其下面的视图,比如,当touch的pints在视图的子视图时,返回子视图,否则将事件透传到下面的视图,代码如下:

1
2
3
4
5
6
7
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitTestView = [super hitTest:point withEvent:event];
if (hitTestView == self) {
hitTestView = nil;
}
return hitTestView;
}

将touch事件传给子视图

父视图对所有touch事件进行重定向,传到子视图中,比如,一个图片旋转功能,由一个父视图和一个UIScrollView组成,且设置UIScrollView视图的pagingEnabled属性为YES,且设置父视图的clipsToBounds属性为NO。

为了让UIScrollView不仅能够响应其bounds范围内的事件,同样也能响应其父视图的bounds内的事件,可以重载父视图的hitTest:withEvent:方法,代码如下:

1
2
3
4
5
6
7
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitTestView = [super hitTest:point withEvent:event];
if (hitTestView) {
hitTestView = self.scrollView;
}
return hitTestView;
}

调整Touches传递路线

有时候我们希望视图在手势识别器识别前能够接收到touch事件,在找到解决方法之前,我们先来了解一下默认的行为。举一个简单的例子,当一个触摸发生时,touch对象将从UIApplication传递到UIWindow,然后,在将touches发送给发生触摸的视图(因为视图继承自UIResponder)本身前,window首先会将touches发送给发生触摸的视图(或其父视图)所关联的手势识别器。

手势识别器将优先识别Touch

window会延时将touches对象传递给视图,以便手势识别器能优先识别touch.在延时的过程中,如果手势识别器成功识别,那么window将不会再继续传递touch对象到视图,而且会取消之前已经发送给视图的touch对象。

比如,有一个手势识别器,用来识别两个手指的触摸,识别器会将触摸转换成两个独立的touch对象,当触摸发生时,touch对象会从Application传到window,接下来的流程,可以由如下表来表示。

  1. window会在Began阶段通过UIGestureRecognizertouchesBegan:withEvent:方法向识别器发送两个touch对象,此时,手势识别器还没有成功识别,所以状态为Possible.window同时会发送相同的touches到识别器关联的视图。
  2. windowMoved阶段通过UIGestureRecognizertouchesMoved:withEvent:方法向识别器发送两个touch对象,此时,手势识别器依然还没有成功识别,状态为Possible,window同时会发送相同的touches到识别器关联的视图。
  3. windowEnded阶段通过UIGestureRecognizertouchesEnded:withEvent:方法向识别器发送一个touch对象,此时该touch对象还没有生成足够的信息提供给识别器,但是window将不会再向识别器关联的视图发送touch对象。
  4. windowEnded阶段发送另一个touch对象,手势识别器现在已经成功识别了触摸,所以其状态变为Recognized,在action消息(即识别器创建时的@selector方法)发送之前,视图会调用自己的touchesCancelled:withEvent:(注意,是视图本身的方法,不是UIGestureRecognizer的)方法来使之前在BeganMoved阶段发送的touch对象失效,Ended阶段的touches将被取消。

现在,我们假设手势识别器在如上的最后一步没有成功识别,那么识别器会将其状态设为UIGestureRecognizerStateFailed,然后,window会在Ended状态向视图发送两个touch对象,并调用视图的touchesEnded:withEvent:消息。