一篇搞定事件传递、响应者链条、hitTest和pointInside的使用

96
我帮你打水
3.5 2016.05.28 22:57* 字数 3191

发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中,UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理。通常,会先发送事件给应用程序的keyWindow,主窗口会在其视图层次结构中找到一个最合适的视图来处理触摸事件,这个找寻的过程就是事件传递。

一、事件传递

传递过程示例

1.png

触摸事件的传递是从父控件传递到子控件
点击了绿色的view:UIApplication -> UIWindow -> 白色 -> 绿色
点击了蓝色的view:UIApplication -> UIWindow -> 白色 -> 橙色 -> 蓝色
点击了红色的view:UIApplication -> UIWindow -> 白色 -> 橙色 -> 红色

传递过程详解:

keyWindow会在它的内容视图上调用hitTest:withEvent:(该方法返回的就是处理此触摸事件的最合适view)来完成这个找寻过程。
hitTest:withEvent:在内部首先会判断该视图是否能响应触摸事件,如果不能响应,返回nil,表示该视图不响应此触摸事件。然后再调用pointInside:withEvent:(该方法用来判断点击事件发生的位置是否处于当前视图范围内)。如果pointInside:withEvent:返回NO,那么hiteTest:withEvent:也直接返回nil。
如果pointInside:withEvent:返回YES,则向当前视图的所有子视图发送hitTest:withEvent:消息,所有子视图的遍历顺序是从最顶层视图一直到到最底层视图,即从subviews数组的末尾向前遍历。直到有子视图返回非空对象或者全部子视图遍历完毕;若第一次有子视图返回非空对象,则 hitTest:withEvent:方法返回此对象,处理结束;如所有子视图都返回非,则hitTest:withEvent:方法返回该视图自身。

二、hitTest:withEvent方法的底层实现

不接收触摸事件的三种情况

(1)不接收用户交互 userInteractionEnabled = NO
(2)隐藏 hidden = YES
(3)透明 alpha = 0.0 ~ 0.01

hitTest:底层实现

// point是该视图的坐标系上的点
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // 1.判断自己能否接收触摸事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    // 2.判断触摸点在不在自己范围内
    if (![self pointInside:point withEvent:event]) return nil;
    // 3.从后往前遍历自己的子控件,看是否有子控件更适合响应此事件
    int count = self.subviews.count;
    for (int i = count - 1; i >= 0; i--) {
        UIView *childView = self.subviews[i];
        CGPoint childPoint = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childPoint withEvent:event];
        if (fitView) {
            return fitView;
        }
    }
    // 没有找到比自己更合适的view
    return self;
}

只有弄清楚了hitTest:方法的底层实现,才能更容易理解事件传递机制。

三、一个示例

2.png

如图所示,视图结构为红绿黄均为黑的子控件,而白为黄的子控件,以下是点击不同颜色区域的打印结果:


1.点击白色中间区域
2016-05-27 17:58:45.502 hitTest----BlackView (pointInside返回YES)
2016-05-27 17:58:45.502 hitTest----YellowView (pointInside返回YES)
2016-05-27 17:58:45.503 hitTest----WhiteView (pointInside返回YES)
2016-05-27 17:58:45.506 touchBegan---WhiteView
2016-05-27 17:58:45.506 touchBegan---YellowView
2016-05-27 17:58:45.506 touchBegan---BlackView
分析(先只看hitTest方法的打印结果,忽略touchesBegan):
首先是blackView的hitTest方法被调用,内部调用pointInside方法,返回YES,表示触摸点在blackView范围内。然后倒叙遍历blackView的子控件数组,发送hitTest消息。如果子控件调用hitTest方法返回不为空,就中断遍历。所以,最后一个子控件yellowView调用hitTest方法,在pointInside方法里面发现触摸点在自己范围内,继续向yellowView的子控件数组发送消息,此时whiteView在hitTest方法中将自己逐层返回出去。最后响应事件的就是whiteView。


2.点击超出黄色区域的白色区域
2016-05-27 18:00:38.372 hitTest----BlackView (pointInside返回YES)
2016-05-27 18:00:38.372 hitTest----YellowView (pointInside返回NO)
2016-05-27 18:00:38.372 hitTest----GreenView (pointInside返回YES)
2016-05-27 18:00:38.374 touchBegan---GreenView
2016-05-27 18:00:38.374 touchBegan---BlackView
分析:
前面相同,倒叙遍历blackView子控件数组,最后一个子控件YellowView在pointInside方法里面返回的是NO,从而其hitTest返回nil。接着遍历到GreenView,可以响应该触摸事件,最后返回GreenView。
可得出结论,不属于父控件范围的子控件部分,子控件无法响应该部分的触摸事件。


3.点击黄色区域
2016-05-27 18:01:22.933 hitTest----BlackView (pointInside返回YES)
2016-05-27 18:01:22.933 hitTest----YellowView (pointInside返回YES)
2016-05-27 18:01:22.933 hitTest----WhiteView (pointInside返回NO)
2016-05-27 18:01:22.935 touchBegan---YellowView
2016-05-27 18:01:22.935 touchBegan---BlackView
分析:
遍历到YellowView,pointInside返回YES。继续遍历YellowView的子控件,whiteView的pointInside返回NO,从而其hitTest返回nil。 所以YellowView在hitTest方法中将自己返回出去。


4.点击绿色区域
2016-05-27 18:02:13.333 hitTest----BlackView (pointInside返回YES)
2016-05-27 18:02:13.334 hitTest----YellowView (pointInside返回NO)
2016-05-27 18:02:13.334 hitTest----GreenView (pointInside返回YES)
2016-05-27 18:02:13.335 touchBegan---GreenView
2016-05-27 18:02:13.335 touchBegan---BlackView


5.点击红色区域
2016-05-27 18:03:02.687 hitTest----BlackView (pointInside返回YES)
2016-05-27 18:03:02.687 hitTest----YellowView (pointInside返回NO)
2016-05-27 18:03:02.687 hitTest----GreenView (pointInside返回NO)
2016-05-27 18:03:02.687 hitTest----RedView (pointInside返回YES)
2016-05-27 18:03:02.689 touchBegan---RedView
2016-05-27 18:03:02.689 touchBegan---BlackView


6.点击灰色区域
2016-05-27 18:04:08.176 hitTest----BlackView (pointInside返回YES)
2016-05-27 18:04:08.177 hitTest----YellowView (pointInside返回NO)
2016-05-27 18:04:08.177 hitTest----GreenView (pointInside返回NO)
2016-05-27 18:04:08.177 hitTest----RedView (pointInside返回NO)
2016-05-27 18:04:08.179 touchBegan---BlackView


括号内容非打印内容。在hitTest:方法的消息分发过程中,并不是所有包含触摸点范围的view都会经历事件传递。以2为例,yellowView的pointInside方法直接返回NO,那么触摸事件就不会传递到yellowView的子控件whiteView了。

四、响应者链条

分析到这里,就可以引出响应者链条这一概念了。每个能执行hitTest:方法的view都属于事件传递的一部分,但是,只有pointInside返回YES的view才属于响应者链条。与上述打印中的touchesBegan方法的打印结果一致。

相关概念

响应者:继承UIResponder的对象称之为响应者对象,能够处理touchesBegan等触摸事件。
响应者链条:由很多响应者链接在一起组合起来的一个链条称之为响应者链条

处理原则

响应者链条其实还包括视图控制器、UIWindow和UIApplication,上述例子并没有表现出来。如下图所示:

3.png

我的理解:通过事件传递找到最合适的处理触摸事件的view后(就是最后一个pointInside返回YES的view,它是第一响应者),如果该view是控制器view,那么上一个响应者就是控制器。如果它不是控制器view,那么上一个响应者就是前面一个pointInside返回YES的view(其实就是它的父控件)。 最后这些所有pointInside返回YES的view加上它们的控制器、UIWindow和UIApplication共同构成响应者链条。响应者链条是自上而下的(我把window上最外面的那个view称为上),前面的事件传递是自下而上的。

响应者链条的作用

可以让一个触摸事件让多个响应者同时处理该事件。
上面能够在多个view内打印出touchBegan就是利用了此作用,
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchBegan---%@", [self class]);
[super touchesBegan:touches withEvent:event];
}

五、hitTest:和pointInside:的使用

屏蔽

由前面例子的打印结果与分析可以看出,无论点击哪里,blackView的pointInside方法返回都是YES。
如果将其pointInside返回值改为NO,则其hitTest方法直接返回空,这个屏幕上有色区域都不接收触摸事件。


如果将greenView的pointInside方法返回YES,会影响上面的5、6变成:
2016-05-27 13:50:45.448 hitTest----BlackView (pointInside返回YES)
2016-05-27 13:50:45.448 hitTest----YellowView (pointInside返回NO)
2016-05-27 13:50:45.448 hitTest----GreenView (pointInside返回YES)
2016-05-27 13:50:45.449 touchBegan—GreenView
2016-05-27 13:50:45.449 touchBegan---BlackView
如果将greenView的pointInside方法返回NO,会影响2、4,绿色区域不再响应触摸事件,都交给红色区域处理:


如果将yellowView的pointInside方法返回YES,会影响上面的2、4、5、6。其中2变成
2016-05-27 11:02:19.268 hitTest----BlackView (pointInside返回YES)
2016-05-27 11:02:19.268 hitTest----YellowView (pointInside返回YES)
2016-05-27 11:02:19.268 hitTest----WhiteView (pointInside返回YES)
2016-05-27 11:02:19.270 touchBegan---WhiteView
2016-05-27 11:02:19.270 touchBegan---YellowView
2016-05-27 11:02:19.270 touchBegan---BlackView
4、5、6变成,
2016-05-27 11:07:18.131 hitTest----BlackView (pointInside返回YES)
2016-05-27 11:07:18.131 hitTest----YellowView (pointInside返回YES)
2016-05-27 11:07:18.131 hitTest----WhiteView (pointInside返回NO)
2016-05-27 11:07:18.133 touchBegan---YellowView
2016-05-27 11:07:18.133 touchBegan---BlackView
如果将yellowView的pointInside方法返回NO,黄色和白色区域不再响应触摸,交给后面区域响应。


其余的情况不再列出,总结:
如果将某个view的pointInsdie方法直接返回NO(无论子控件的pointInsdie返回什么),影响的是子控件区域和自身区域的点击事件处理,这些区域不再响应事件。其余区域响应点击事件不发生变化。
如果将某个view的pointInside方法直接返回YES,自身区域响应点击事件不变。其它改变:
首先,父控件所有区域点击事件交给该view处理。
然后,再看该view处于父控件的子控件数组中的位置。数组前面的兄弟控件的点击事件交给该view处理,数组后面的兄弟控件的点击事件由其兄弟控件处理。
最后,该view的子控件原来能够自己处理点击的区域继续由子控件处理,子控件原来不能够自己处理点击的(超出了该view范围)区域可以由子控件处理了。
所以,想要屏蔽掉某个view响应点击事件,如果其没有子控件或者子控件响应事件也想屏蔽掉,直接将该view的pointInside返回为NO就行了。而在一般情况下,不建议将view的pointInside直接返回YES(影响范围太广,不好控制)。

穿透

还是看前面例子的图片样式,现在的要求是想点击覆盖在黄色区域的白色区域,点击事件由yellowView处理,点击超出黄色区域的白色区域,点击事件由whiteView自己处理。
1.如果whiteView是yellowView的兄弟控件。
可以重写whiteView里面的hitTest方法:判断触摸在whiteView上的点,如果在yellowView上,hitTest返回yellowView,交给其响应。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint yellowPoint = [self convertPoint:point toView:_yellowView];
if ([_yellowView pointInside:yellowPoint withEvent:event]) {
return _yellowView;
}

    return [super hitTest:point withEvent:event];
}

也可以重写whiteView的pointInside方法:如果触摸点属于yellowView范围,返回NO,该范围内whiteView不响应点击。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint yellowPoint =[_yellowView convertPoint:point fromView:self];
if ([_yellowView pointInside:yellowPoint withEvent:event]) return NO;

     return [super pointInside:point withEvent:event];
}

2.如果whiteView是yellowView的子控件。
需要重写whiteView里面的hitTest方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"hitTest----%@", [self class]);
CGPoint yellowPoint = [self convertPoint:point toView:_yellowView];
if ([_yellowView pointInside:yellowPoint withEvent:event]) {
return _yellowView;
}

    return [super hitTest:point withEvent:event];
 }

和greenView里面的hitTest方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"hitTest----%@", [self class]);
CGPoint whitePoint = [self convertPoint:point toView:_whiteView];
if ([_whiteView pointInside:whitePoint withEvent:event]) {
return _whiteView;
}

     return [super hitTest:point withEvent:event];
 }

究竟什么时候重写hitTest,什么时候重写pointInside,在哪个view内重写它们?

很多情况下hitTest和pointInside方法任选其一都可以实现某个功能,比如在屏蔽中,pointInside返回NO可以实现的话,都可以用hitTest返回nil代替。
但是,hitTest更强大。因为pointInside在一般情况下其内部顶多只能根据情况判断怎么返回NO,屏蔽掉自己和子控件的事件响应。所以只要是想保留子控件对触摸事件响应,屏蔽其父控件的响应,单独重写pointInside无法办到,必须要重写hitTest方法。

触摸事件原本该由某个view响应,现在你不想让它处理而让别的控件处理,那么就应该在该view内重写hitTest或pointInside方法。

Demo下载地址:https://github.com/wobangnidashui/hitTestDemo

iOS--一篇搞定系列
Web note ad 1