浅析iOS事件的响应及传递

Responder Chain

响应者Responders

说到事件,不得不从UIResponder说起;
UIResponder是用于响应和处理事件的抽象接口,UIResponder的实例构成了UIKit的事件处理主干,许多UIKit类也都是继承自UIResponder,包括UIApplication, UIViewController以及UIView(包括UIWindow),它们的实例都是响应者:(对用户交互动作事件进行响应的对象),当事件发生时,UIKit将它们分派到应用的responder对象中进行处理。

UI类的关系

UIResponder响应的事件有以下几种:

  • touch events
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);
  • motion events
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
  • remote-control events
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);
  • press events
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);

响应者链Responder Chain

由多个响应者组合起来的链条,就是响应者链。它表示了每个响应者之间的联系,并且可以使得一个事件可选择多个对象处理。UIResponder有nextResponder方法,返回响应链的下一个响应者;一般的,响应者的下个响应者是它的父视图(如果响应者是UIViewController的view,这个响应者的下个响应者是UIViewController)。当然我们也可以重写nextResponder方法来指定下个响应者。
官方文档举了个通俗易懂的例子来描述响应者链:

Responder Chain

如果text field没有处理事件,UIKit会将事件发送给text field的父视图UIView对象,如果UIView对象没有处理事件,事件会被发送给UIViewController的根视图;之后事件会依次传递到根视图下个响应者即视图所属的视图控制器,然后是UIWindow对象,然后是UIApplication对象,如果该对象是UIResponder的一个实例并且不是响应者链的一部分,可能会传递给AppDelegate。

使用touch events来验证下:

red

viewController和redView都加上touch event

// viewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
}

// redView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
}

当点击红色view时,很显然只有红色view响应事件。注释redView中touch方法后,点击红色view时viewController的touchesBegan事件触发了。如何让viewController和redView都能响应这个touch事件呢,可以在redView中主动传递给下个响应者:

// redView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.nextResponder touchesBegan:touches withEvent:event];
    // 或者  [super touchesBegan:touches withEvent:event];
    NSLog(@"%s",__func__);
}

Hit-Test 机制

当一个touch events产生的时候,系统是如何找到第一响应者(即最适合处理这个事件的对象)的呢?这里就是使用了Hit-Test 机制:
Hit-Test有关的两个方法:

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   // default returns YES if point is in bounds
  • 当产生一个touch事件,Runloop会接收到事件并把其加入UIApplication事件队列里;
  • UIApplication从事件队列中取出最新的事件进行分发传递给UIWindow进行处理;
  • UIWindow会调用hitTest:withEvent:方法在视图层次结构中找到一个最合适的UIView来处理这个事件;分发的顺序和响应链基本相反:UIApplication -> UIWindow -> Root View -> ··· -> subview:
  • hitTest:withEvent:方法会调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内;
  • 若pointInside:withEvent:方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest:withEvent:返回nil;
  • 若pointInside:withEvent:方法返回YES,说明触摸点在当前视图内,则遍历当前视图的所有子视图(subviews),调用子视图的hitTest:withEvent:方法(子视图重复同样的步骤),子视图的遍历顺序是栈的形式,即从最后面添加的子视图至最早添加的子视图,直到有子视图的hitTest:withEvent:方法返回非空对象或者全部子视图遍历完毕;
  • 若有子视图的hitTest:withEvent:方法返回非空对象(第一响应对象为子视图),则当前视图的hitTest:withEvent:方法就返回此对象,处理结束;
  • 若所有子视图的hitTest:withEvent:方法都返回nil(触摸点不在子视图上),则当前视图的hitTest:withEvent:方法返回当前视图self;

同样,还是来验证下:

subviews

在之前的redView上添加了2个subviews:blueView和yellowView;这三个view分别重写hitTest:withEvent:和pointInside:withEvent:方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ start hit",[self class]);
    UIView *view = [super hitTest:point withEvent:event];
    NSLog(@"%@ hitView:%@",[self class],[view class]);
    return view;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    BOOL inside = [super pointInside:point withEvent:event];
    NSLog(@"%@ pointInside:%@",[self class],inside ? @"YES" : @"NO");
    return inside;
}

首先,点击yellowView,输出log如下:

RedView start hit
RedView pointInside:YES
YellowView start hit
YellowView pointInside:YES
YellowView hitView:YellowView

结果不重要,重要的是过程,我们来分析下这个过程:

  1. redView是父视图,首先会调用redView的hitTest:withEvent:方法,在获取hitView的时候会调用pointInside:withEvent:方法判断点击的point是否在当前视图frame内;
  2. pointInside:withEvent:返回YES,则依次分发给redView的子视图;由于yellowView是后面添加的,会先分发给yellowView调用hitTest:withEvent:。
  3. 和之前redView同样的流程,yellowView判断后point在当前视图frame内,由于yellowView没有子视图分发结束;hitTest:withEvent:返回yellowView对象,也即这个事件找到了第一响应者yellowView;然后父视图redView的hitTest:withEvent:也返回yellowView对象;

综上,事件流程其实可以用一张图表示:

事件流程

实际应用

以上知识,在实际开发中有什么用途呢?

  • 通过nextResponser方法实现解耦:
    需求:在自定义UIView中获取ViewController对象;一般的做法是在自定义View中声明并引用ViewController对象,但这样做耦合性高了。可以使用nextResponser获取:
@implementation UIView (Tool)

- (UIViewController *)hh_viewController {
    UIResponder *rp = [self nextResponder];
    while (rp) {
        if ([rp isKindOfClass:[UIViewController class]]) {
            return (UIViewController *)rp;
        }
        rp = [rp nextResponder];
    }
    return nil;
}
             
@end
  • 重写hitTest:withEvent:或pointInside:withEvent:方法,解决某些事件不能响应的情况:
    控件不能响应事件,一般有如下情况:

1.userInteractionEnabled = NO(这也是UIImageView控件及其子视图不能响应事件的原因)
2.hidden = YES
3.alpha小于等于0.01
4.子视图超出了父视图frame

项目中一个常见的需求就是让超出父视图范围的子视图也能响应事件,比如TableBar中突出的按钮;现在简单模拟一下这种情况:

HEH

blueView是redView的subview,并且有一半已超出父视图范围;这时点击blueView的上半部分,肯定没有任何反应;这是因为父视图的pointInside:withEvent:方法返回了NO,就不会遍历子视图了。可以重写redView的pointInside:withEvent:方法解决此问题。

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

推荐阅读更多精彩内容