003UIKit-02-大话iOS Responder Chain(二)

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

一、响应链流转

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 Chain(二))