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中事件的响应链和传递链

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容