美文网首页事件的产生、传递
事件传递、响应者链条、hitTest和pointInside的使

事件传递、响应者链条、hitTest和pointInside的使

作者: LeeRich | 来源:发表于2018-01-31 17:28 被阅读51次
    作者:我帮你打水
    链接:https://www.jianshu.com/p/2f664e71c527
    來源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    

    前言

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

    一、事件传递

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

    传递过程详解:

    1. keyWindow会在它的内容视图上调用hitTest:withEvent:(该方法返回的就是处理此触摸事件的最合适view)来完成这个找寻过程。
    2. hitTest:withEvent:在内部首先会判断该视图是否能响应触摸事件,如果不能响应,返回nil,表示该视图不响应此触摸事件。然后再调用pointInside:withEvent:(该方法用来判断点击事件发生的位置是否处于当前视图范围内)。如果pointInside:withEvent:返回NO,那么hiteTest:withEvent:也直接返回nil。
    3. 如果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:方法的底层实现,才能更容易理解事件传递机制。

    三、一个示例

    image

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


    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。


    1. 点击超出黄色区域的白色区域
    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。
    可得出结论,不属于父控件范围的子控件部分,子控件无法响应该部分的触摸事件。


    1. 点击黄色区域
    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方法中将自己返回出去。


    1. 点击绿色区域
    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
    

    1. 点击红色区域
    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
    

    1. 点击灰色区域
    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,上述例子并没有表现出来。如下图所示:


    image

    我的理解:通过事件传递找到最合适的处理触摸事件的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];
    }
    
    1. 如果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];
     }
    

    <pre>

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

    很多情况下hitTest和pointInside方法任选其一都可以实现某个功能,比如在屏蔽中,pointInside返回NO可以实现的话,都可以用hitTest返回nil代替。

    但是,hitTest更强大。因为pointInside在一般情况下其内部顶多只能根据情况判断怎么返回NO,屏蔽掉自己和子控件的事件响应。所以只要是想保留子控件对触摸事件响应,屏蔽其父控件的响应,单独重写pointInside无法办到,必须要重写hitTest方法。

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

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

    相关文章

      网友评论

        本文标题:事件传递、响应者链条、hitTest和pointInside的使

        本文链接:https://www.haomeiwen.com/subject/xdajoxtx.html