iOS--事件传递/响应者链

应用程序使用响应者对象接收和处理事件。响应者对象是UIResponder类的任何实例,常见的子类包括UIView、UIViewController和UIApplication。响应者接收原始事件数据,必须处理该事件或将其转发给另一个响应程序对象。当应用程序接收到事件时,UIKit会自动将该事件定向到最合适的响应程序对象,即第一响应者。

在iOS程序中响应者对象的摆放是有前后关系的,多个响应者对象有序的连接起来的链条就叫“响应者链”。

创建一个UIView的子类ChainView,重写touchesBegan方法:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"%@ --- touchesBegan withEvent ---", self.name);
    UIResponder * next = [self nextResponder];
    NSMutableString * prefix = @"".mutableCopy;
    while (next != nil) {
        NSLog(@"%@%@", prefix, [next class]);
        [prefix appendString: @"--"];
        next = [next nextResponder];
    }
}

创建一个ChainView实例添加到ViewController.view,点击log输出:

2020-09-24 11:08:56.494864+0800 001--ClientDemo[83858:1377593] greenView --- touchesBegan withEvent ---
ClientDemo[83858:1377593] UIView
ClientDemo[83858:1377593] --ViewController
ClientDemo[83858:1377593] ----UIWindow
ClientDemo[83858:1377593] ------UIApplication
ClientDemo[83858:1377593] --------AppDelegate

选择不同版本iOS有不同输出,在iOS12之前是上面输出,iOS12及以后输出如下

2020-09-24 11:11:41.146777+0800 001--ClientDemo[84116:1402680] greenView --- touchesBegan withEvent ---
ClientDemo[84116:1402680] UIView
ClientDemo[84116:1402680] --ViewController
ClientDemo[84116:1402680] ----UIDropShadowView
ClientDemo[84116:1402680] ------UITransitionView
ClientDemo[84116:1402680] --------UIWindow
ClientDemo[84116:1402680] ----------UIWindowScene
ClientDemo[84116:1402680] ------------UIApplication
ClientDemo[84116:1402680] --------------AppDelegate

这里及以下都以iOS11上为例。上面打印的是本例中事件响应传递关系也即事件响应者链,响应者链是事件传递链的逆序,则事件传递链如下图:

事件传递链

UIView 的 controller 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果当前 View 不是 ViewController 直接管理的 View,则 nextResponder 是它的 superView( view.nextResponder = view.superView )。
UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
UIWindow 的 nextResponder 是 UIApplication 。
UIApplication 的 nextResponder 是 AppDelegate。

有了响应链,并且找到了第一个响应事件的对象,接下来就是把事件发送给这个响应者了。 UIApplication中有个sendEvent:的方法,在UIWindow中同样也可以发现一个同样的方法。UIApplication是通过这个方法把事件发送给UIWindow,然后UIWindow通过同样的API,把事件发送给hit-testview。

响应链工作步骤

  1. 事件产生
  2. 事件顺着传递链 AppDelegate---> UIApplication --->UIWindow ---> 查找第一响应者,查找过程主要用到两个函数:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
  1. 找到最合适响应者视图后,视图会调用自己的touches方法处理事件。如果自身没有做处理,UIKit那么会逆着事件传递链,向上查找,直到找到能够响应这个事件的视图。当将事件传递给UIApplication对象时,如果该对象是UIResponder实例,而不属于响应程序链的一部分,则可能会将事件传递给应用程序委托AppDelegate。最终如果没有对象响应该事件,该事件会被丢弃(不是Crash!)。

示例

左侧是实现效果,其中设置了cyanView.userInteractionEnabled = NO,右侧是当前程序的响应者链(同级视图左边线与右边添加到父视图上)。


1600846386263.jpg

修改上面提到的的ChainView实现:

@interface ChainView : UIView
@property(nonatomic, copy) NSString *name;
@end

@implementation ChainView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"%@ --- touchesBegan withEvent ---", self.name);
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"%@ --- hitTest withEvent", self.name);
    UIView * view = [super hitTest:point withEvent:event];
    NSLog(@"%@ --- hitTest withEvent --- hitTestView:%@", self.name, ((ChainView *)view).name);
    return view;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    BOOL isInside = [super pointInside:point withEvent:event];
    NSLog(@"%@ --- pointInside withEvent --- isInside:%d", self.name, isInside);
    return isInside;
}
@end

点击blueView和cyanView的重叠处,log输出:

2020-09-23 16:48:39.439271+0800 TT[72680:1155021] redView --- hitTest withEvent
2020-09-23 16:48:39.439426+0800 TT[72680:1155021] redView --- pointInside withEvent
2020-09-23 16:48:39.439538+0800 TT[72680:1155021] redView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.439644+0800 TT[72680:1155021] yllowView --- hitTest withEvent
2020-09-23 16:48:39.439933+0800 TT[72680:1155021] yllowView --- pointInside withEvent
2020-09-23 16:48:39.440452+0800 TT[72680:1155021] yllowView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.440935+0800 TT[72680:1155021] purpleView --- hitTest withEvent
2020-09-23 16:48:39.441447+0800 TT[72680:1155021] purpleView --- pointInside withEvent
2020-09-23 16:48:39.442443+0800 TT[72680:1155021] purpleView --- pointInside withEvent --- isInside:0
2020-09-23 16:48:39.442865+0800 TT[72680:1155021] purpleView --- hitTest withEvent --- hitTestView:(null)
2020-09-23 16:48:39.443304+0800 TT[72680:1155021] orangeView --- hitTest withEvent
2020-09-23 16:48:39.443728+0800 TT[72680:1155021] orangeView --- pointInside withEvent
2020-09-23 16:48:39.444398+0800 TT[72680:1155021] orangeView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.444861+0800 TT[72680:1155021] cyanView --- hitTest withEvent
2020-09-23 16:48:39.445325+0800 TT[72680:1155021] cyanView --- hitTest withEvent --- hitTestView:(null)
2020-09-23 16:48:39.445743+0800 TT[72680:1155021] blueView --- hitTest withEvent
2020-09-23 16:48:39.446166+0800 TT[72680:1155021] blueView --- pointInside withEvent
2020-09-23 16:48:39.446590+0800 TT[72680:1155021] blueView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.446985+0800 TT[72680:1155021] blueView --- hitTest withEvent --- hitTestView:blueView
2020-09-23 16:48:39.447281+0800 TT[72680:1155021] orangeView --- hitTest withEvent --- hitTestView:blueView
2020-09-23 16:48:39.447678+0800 TT[72680:1155021] yllowView --- hitTest withEvent --- hitTestView:blueView
2020-09-23 16:48:39.448096+0800 TT[72680:1155021] redView --- hitTest withEvent --- hitTestView:blueView
  1. 首先是redView的hitTest被调用,hitTest里通过pointInside判断事件在redView相应范围内,向下判断redView的子视图;
  2. 子视图的判断是逆序的,所以先判断yellowView,事件在yellowView响应范围,继续判断yellowView的子视图;
  3. 依然是因为子视图的判断是逆序的,所以先判断purpleView,没有在purpleView响应范围;判断orangeView通过,继续向下判断orangeView的子视图;
  4. 在判断cyanView的时候,因为设置了cyanView.userInteractionEnabled = NO,所以cyanView不能响应事件;
  5. 最后判断blueView是第一响应者。逆着响应者链通知前面的响应者 blueView是第一响应者,blueView的touches事件调用。

事件拦截

改变第一响应者

image.png

在需要拦截的 view 中重写 hitTest 方法改变第一响应者。
比如上面示例中,让事件在yellowView中断,由yellowView响应:
在ChainView中重新实现hitTest:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 
    0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        if([self.name isEqualToString:@"yellowView"]){
             return self;
         }
    }
    return nil;
}

限制第一响应者范围

下图中的按钮,只有中间圆形部分可以点击,四个角上不响应事件:


CustomButton

创建一个继承自UIButton的CustomButton,实现:

@implementation CustomButton
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    // 判断触摸位置是否在当前视图内
    if ([self pointInside:point withEvent:event]) {
        //逆序遍历当前对象的子视图
        __block UIView *hitView = nil;
        [self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            //坐标转换系 使坐标基于子视图
            CGPoint convertedPoint = [self convertPoint:point toView:obj];
            //调用子视图的 hitTest 方法
            hitView = [obj hitTest:convertedPoint withEvent:event];
            // 如果子视图返回一个view 遍历终止
            if (hitView) {
                *stop = YES;
            }
        }];
        //如果子视图返回一个view 返回这个view
        if(hitView) {
            return hitView;
        }
        
        //所有子视图都返回 nil, 则返回自身.
        return self;
    }
    return nil;
}

- (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 dist = sqrt((x1-x2) * (x1-x2) + (y1-y2) * (y1-y2));
    
    //在以当前控件中心为圆心,直径为控件宽度的圆内
    if(dist <= self.frame.size.width/2){
        return YES;
    }
    else{
        return NO;
    }
    
//    return [super pointInside:point withEvent:event];
}

具体使用和UIButton一样,有兴趣可以自己测试一下效果。

事件转发

有时候还需要将事件转发出去。让本不能响应事件的 view 响应事件,最常用的场景就是让子视图超出父视图的部分也能响应事件


image.png

cyanView的右下区域超出了父视图 orangeView 的区域,如果不作处理,那么点击超出的区域是无法响应事件的,因为超出区域的坐标不在orangeView的范围内,当执行到orangeView的 pointInside 的时候就会返回 NO。
想要让超出区域响应事件,就需要重写父视图的 pointInside 或 hitTest 方法让 pointInside 返回 YES 或 让hitTest 直接返回cyanView
重写hitTest

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"%@ --- hitTest withEvent", self.name);
    
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    
    // 触摸点在视图范围内 则交由父类处理
    if ([self pointInside:point withEvent:event]) {
        return [super hitTest:point withEvent:event];
    }

    // 如果触摸点不在范围内 而在子视图范围内依旧返回子视图
    NSArray<UIView *> * superViews = self.subviews;
    // 倒序 从最上面的一个视图开始查找
    for (NSUInteger i = superViews.count; i > 0; i--) {
        UIView * subview = superViews[i - 1];
        // 转换坐标系 使坐标基于子视图
        CGPoint newPoint = [self convertPoint:point toView:subview];
        // 得到子视图 hitTest 方法返回的值
        UIView * view = [subview hitTest:newPoint withEvent:event];
        // 如果子视图返回一个view 就直接返回 不在继续遍历
        if (view) {
            return view;
        }
    }
    return nil;

重写 pointInside 方法原理相同, 重点注意转换坐标系,就算他们不是一条响应链上,也可以通过重写 hitTest 方法转发事件。

参考: //www.greatytc.com/p/69c578165054

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