美文网首页Mac·iOS开发IOS 手势点击
003UIKit-02-大话iOS Responder Chai

003UIKit-02-大话iOS Responder Chai

作者: 修_远 | 来源:发表于2021-07-27 12:07 被阅读0次

    上一篇文章中详细的介绍了响应链中的一些概念。这里会重点介绍响应链的流程。

    一、响应链流转

    1.1 事件分发

    在上一篇文章中介绍了MacOS中事件分发,而且指出事件分发的方向是“向上”。当我们手指触碰到屏幕时,最开始获取到这个事件的并不是APP中最上层的视图,而是系统的I/O Kit。我们将这个过程分为两个过程:

    event进入APP之前

    1. 首先经过I/O Kit,将触摸屏上的物理触摸事件产生的电子信号传递到下一层;
    2. Core Services对信号进行处理再转发到Window Services上;
    3. Window Services层,将这个触摸事件转成一个event对象;
    4. Window Services再将event对象通过Mach Port将event分发到当前活动的application中,具体位置是main run loop中的event queue中。

    到这里,event已经生成并分发到了指定的application中。从计算机软件设计框架来看,上面的过程属于底层框架,完成了数据从底层到上层的传递,仍然满足向上的分发方向。

    event进入APP之后

    随着响应链构造的过程,event会被分发到最上层的view,一般被当做第一响应者。当开始处理事件相应的时候,会按照响应链的方向逐个去询问是否能处理,直到事件被处理,或一直没处理并被响应链最后一个响应者对象捕获,在iOS中为ApplicationDelegate对象,会丢弃事件。

    Event Dispatch

    1.2 事件响应

    1. 如果当前响应者处理了event,则流程结束;
    2. 如果当前响应者没有处理,则将事件传递给next responders
    3. 直到事件传递到响应链最底端对象ApplicationDelegate对象,并被丢弃;
    Event Handle

    二、响应链构造

    在一个应用创建之后,系统帮我们完成了响应链最底端的链路构造:

    Window -> WindowDelegate -> Application -> ApplicationDelegate

    image.png

    与cocoa中的一样,响应链上的对象可以通过重写nextResponder属性,来改变响应链。而我们在开发过程中,可以通过以下几种方式来改变响应链:

    1. UIView
      • 如果这个view是viewController的root view,则nextResponder是这个viewController
      • 其他情况,nextResponder都是它的superView
    2. UIViewController
      • 如果viewController的view是window的root view,则nextResponder是这个window
      • 如果viewControllerA是被另一个viewControllerB present出来的,则nextResponder是presenting view controller
    3. UIWindow
      • nextResponder是UIApplication对象
    4. UIApplication
      • nextResponder是app delegate对象
      • 这个app delegate继承UIResponder,但不能是view/viewController/app对象

    三、hitTest:withEvent: 与 pointInside:withEvent:

    前面两章都是从理论上来讲解响应链的流程,现在就来看看这个过程在代码中是怎么表现的。

    3.1 hitTest:withEvent: 方法

    hitTest:withEvent:

    Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.

    返回包含指定点的视图层次结构中接收器的最远的后代(包括它自己)。

    1. 最远的后代,从视图层级来说,指的是最上层视图;
    2. 指定点,必须是包含这个点的,文档中也指出是根据pointInside:withEvent:
      方法来判断,当前视图是否包含该点。

    当我们重写这个方法,从一个view的视角来读这个方法:

    1. 返回一个能处理该事件的视图;
    2. 这个视图不是view本身,就是view的子视图;
    3. 如果都不能处理该事件,view就将事件抛给next responders;

    上面这个流程是严格按照响应链的方向来执行的,如果我们不按照响应链的顺序来读这个方法:

    1. 找到一个合适的处理该事件的视图;
    2. 业务告诉我,viewX是最合适的,所以我每次都返回viewX。只要event能走到这个界面,我每次都将event交给viewX来处理。

    这个过程跟消息转发流程:forwardingTargetForSelector是非常类似的,给消息找到一个合适的响应者,所以可以通过hitTest:withEvent:方法将event转发给指定的view来处理。

    3.2 pointInside:withEvent:

    pointInside:withEvent:

    Returns a Boolean value indicating whether the receiver contains the specified point.

    判断receiver是否包含指定的点。这个方法比较简单,只做了这一件事,判断点击位置是否落在receiver中。这个receiver指的是view本身。所以这个方法的目的:判断自己是否包含指定的点

    // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
    - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   
    

    hitTest:withEvent:方法的注释中表明会递归调用-pointInside:withEvent:方法来判断点是否落在receiver中。

    3.3 仿源码实现

    应用场景

    • 在一个方形按钮中点击中间的圆形区域有效,而点击四角无效
    • 核心思想是在pointInside: withEvent:方法中修改对应的区域
    image.png
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        // 如果控件不允许与用用户交互,那么返回nil
        if (!self.userInteractionEnabled || [self isHidden] || self.alpha <= 0.01) {
            return nil;
        }
    
        //判断当前视图是否在点击范围内
        if ([self pointInside:point withEvent:event]) {
            //遍历当前对象的子视图(倒序)
            __block UIView *hit = nil;
            [self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                //坐标转换,把当前坐标系上的点转换成子控件坐标系上的点
                CGPoint convertPoint = [self convertPoint:point toView:obj];
                //调用子视图的hitTest方法,判断自己的子控件是不是最适合的View
                hit = [obj hitTest:convertPoint withEvent:event];
                //如果找到了就停止遍历
                if (hit) *stop = YES;
            }];
    
            //返回当前的视图对象
            return hit?hit:self;
        }else {
            return nil;
        }
    }
    
    // 该方法判断触摸点是否在控件身上,是则返回YES,否则返回NO,point参数必须是方法调用者的坐标系
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {   
        CGFloat x1 = point.x;
        CGFloat y1 = point.y;
        
        CGFloat x2 = self.frame.size.width / 2;
        CGFloat y2 = self.frame.size.height / 2;
        
        //判断是否在圆形区域内
        double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
        if (dis <= self.frame.size.width / 2) {
            return YES;
        }
        else{
            return NO;
        }
    }
    

    上面的实现是非常高还原度的实现逻辑,考虑了非常多的细节。

    1. 响应条件:

      • userInteractionEnabled=YES
      • hidden=YES
      • alpha>0.01
    2. 点位条件:[self pointInside:point withEvent:event],如果点击范围超过了自己的bundle,则所有子视图将不会有机会成为响应者;

    3. 倒叙遍历:NSEnumerationReverse,后添加的响应者永远在响应链上端,所以代码实现中用的是倒叙遍历;

    4. 遍历子视图:[obj hitTest:convertPoint withEvent:event]

    5. 如果hit成功,则返回子视图,如果hit失败,则继续遍历,若子视图都没有响应,则返回self

    四、响应链探索

    4.1 应用场景

    视图层级

    视图层级 视图层级

    视图层级树

    CoreAnimation最终会将视图层级转成以下一个树结构。最上层的视图是112。最后被addSubview到视图中的在最右侧。

    视图层级树

    hitTest:withEvent: 与 pointInside:withEvent:

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        UIView *aView = [super hitTest:point withEvent:event];
        if (aView) {
            NSLog(@"hitTest from : %ld --> %ld", aView.tag, self.tag);
        } else {
            NSLog(@"hitTest from : nil --> %ld", self.tag);
        }
        return aView;
    }
    
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
        BOOL ret = [super pointInside:point withEvent:event];
        NSLog(@"pointInside : %ld (%d)", (long)self.tag, ret);
        return ret;
    }
    

    4.2 响应顺序探索

    下面将rootView上的所有点击组合的调用做了测试,并输出测试结果。

    1. 点空白处

    倒叙遍历rootView的子视图,

    • pointInside:withEvent:都返回NO
    • hitTest:withEvent:都返回nil
    pointInside : 10086 (0)
    hitTest from : nil --> 10086
    pointInside : 110 (0)
    hitTest from : nil --> 110
    pointInside : 10010 (0)
    hitTest from : nil --> 10010
    
    1. 点10010

    倒叙遍历rootView的子视图,

    • 视图10010:pointInside:withEvent:都返回YES
    • 视图10010:hitTest:withEvent:都返回视图10010
    pointInside : 10086 (0)
    hitTest from : nil --> 10086
    pointInside : 110 (0)
    hitTest from : nil --> 110
    pointInside : 10010 (1)
    hitTest from : 10010 --> 10010
    
    1. 点110

    倒叙遍历rootView的子视图,

    • 110已经响应了事件,则停止遍历

    倒叙遍历110的子视图,

    • pointInside:withEvent:都返回NO
    • hitTest:withEvent:都返回nil
    pointInside : 10086 (0)
    hitTest from : nil --> 10086
    pointInside : 110 (1)
    pointInside : 112 (0)
    hitTest from : nil --> 112
    pointInside : 111 (0)
    hitTest from : nil --> 111
    hitTest from : 110 --> 110
    
    1. 点10086

    倒叙遍历rootView的子视图,

    • 10086已经响应了事件,则停止遍历
    pointInside : 10086 (1)
    hitTest from : 10086 --> 10086
    

    后面的过程仍然遵循上述规律,就不再一一细数结果了。

    1. 点111(superview范围内)
    pointInside : 10086 (0)
    hitTest from : nil --> 10086
    pointInside : 110 (1)
    pointInside : 112 (0)
    hitTest from : nil --> 112
    pointInside : 111 (1)
    hitTest from : 111 --> 111
    hitTest from : 111 --> 110
    
    1. 点112
    pointInside : 10086 (0)
    hitTest from : nil --> 10086
    pointInside : 110 (1)
    pointInside : 112 (1)
    hitTest from : 112 --> 112
    hitTest from : 112 --> 110
    
    1. 点111(superview范围外)
    pointInside : 10086 (0)
    itTest from : nil --> 10086
    ointInside : 110 (0)
    itTest from : nil --> 110
    ointInside : 10010 (0)
    itTest from : nil --> 10010
    
    1. 点112,111混合处
    pointInside : 10086 (0)
    hitTest from : nil --> 10086
    pointInside : 110 (1)
    pointInside : 112 (1)
    hitTest from : 112 --> 112
    hitTest from : 112 --> 110
    

    4.3 响应链分析

    视图层级树
    1. 发生在rootView上的所有点击事件,每次都会先询问10086是否能处理。在视图层级树种,10086是第二层最右边的节点;
    2. 如果点没有落在110视图中,它的所有子视图都没有机会去响应事件;
    3. 发生在110上的所有点击事件,每次都会先询问112是否能处理。在视图层级树种,112是第3层的最右边的节点;

    事件响应的过程可以理解为N叉数的后续遍历,不一样的是当找到响应者之后便终止遍历。

    4.4 响应链函数分析

    以上面分析过程中的6. 点111(superview范围内)为例。

    1. 入栈:进入110视图hitTest方法,开始递归子视图;
    2. 调用:判断点是否落在110视图中,输出pointInside : 110 (1)
    3. 入栈:进入112视图hitTest方法,开始递归子视图;
    4. 调用:判断点是否落在112视图中,输出pointInside : 112(0)
    5. 出栈:退出112视图hitTest方法,返回nil;
    6. 入栈:进入111视图hitTest方法,开始递归子视图;
    7. 调用:判断点是否落在111视图中,输出pointInside : 111(1)
    8. 出栈:退出111视图hitTest方法,返回111视图
    9. 出栈:退出110视图hitTest方法,返回111视图
    pointInside : 110 (1)
    pointInside : 112 (0)
    hitTest from : nil --> 112
    pointInside : 111 (1)
    hitTest from : 111 --> 111
    hitTest from : 111 --> 110
    
    函数调用栈

    4.5 响应链函数应用

    1. 扩大点击范围,重写pointInside,指定点位的新范围;
    2. 透传点击事件,重写pointInside,返回NO;
    3. 透传点击事件,重写hitTest,返回nil;
    4. 拦截点击事件,重写hitTest,根据条件转发给指定视图;

    下面这两个实现都是非常规的调用方式,若非万不得已,尽量不要去修改。他们都依赖响应链的递归顺序,而且在中途修改的递归顺序,会让问题难以排查。

    • 显式转发:重写的方法中,显示的告诉调用者将事件转给了哪个视图;
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        UIView *aView = [super hitTest:point withEvent:event];
        if (aView) {
            NSLog(@"hitTest from : %ld --> %ld", aView.tag, self.tag);
        } else {
            NSLog(@"hitTest from : nil --> %ld", self.tag);
        }
        // 显式转发
        aView = [self viewWithTag:10010];
        return aView;
    }
    
    • 隐式转发:根据响应链的遍历过程,中途拦截某个过程。
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
        BOOL ret = [super pointInside:point withEvent:event];
        NSLog(@"pointInside : %ld (%d)", (long)self.tag, ret);
        // 隐式转发(拦截)
        if (self.tag == 10086) {
            return YES;
        }
        return ret;
    }
    

    hitTest被调用两次的issue

    对于一次tap,hitTest会被调用两次。这个问题在Apple Mailing List Re: -hitTest:withEvent: called twice?里面有描述:

    Yes, it’s normal. The system may tweak the point being hit tested between the calls. Since hitTest should be a pure function with no side-effects, this should be fine.

    苹果告诉我们-hitTest:withEvent:是一个纯函数,没有副作用。

    怎么理解这段话呢?

    1. 这个函数功能单一,并没有调用其他逻辑的函数,不会对视图造成影响;
    2. 这个函数两次调用完成之后才会进入:touchesBegantouchesEnded,所以不会影响到我们的业务;
    3. 在上一章的最后也有指出不要在这个方法中处理业务逻辑,正好与苹果解释的pure funciton相对应;

    参考资料:
    Cocoa Event Handling Guide
    Using Responders and the Responder Chain to Handle Events
    hitTest:withEvent:
    pointInside:withEvent:
    iOS中事件的响应链和传递链

    相关文章

      网友评论

        本文标题:003UIKit-02-大话iOS Responder Chai

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