【iOS】事件响应链

在iOS中不是任何对象都能处理事件,只有继承了UIResponder的对象才能接收并处理事件,我们称之为响应者对象

一、UIResponder

UIApplicationUIViewControllerUIView都继承自UIResponder,因此他们都是响应者对象,都能接收并处理事件。

UIResponder内部提供了以下方法来处理事件:

1.1. 触摸事件

1
2
3
4
- (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;

touches是一个集合对象,集合存放的对象都是UITouch类型。为什么要放到集合里面呢?因为每一根手指对应一个UITouch对象,多个手指就会对应多个UITouch对象,所以要放到一个集合里面。

UITouch

作用:

  • 保存手指触摸相关的信息,比如触摸的位置、时间、阶段等;
  • 当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指所在的触摸位置;
  • 当手指离开屏幕时,系统会销毁对应的UITouch对象。
UITouch常用属性
  • 触摸产生时所处的窗口

    1
    @property(nullable,nonatomic,readonly,strong) UIWindow *window;
  • 触摸产生时所处的视图

    1
    @property(nullable,nonatomic,readonly,strong) UIView *view;
  • 短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或多次点击

    1
    @property(nonatomic,readonly) NSUInteger tapCount; 
  • 记录了触摸事件产生或变化时的时间,单位是秒

    1
    @property(nonatomic,readonly) NSTimeInterval timestamp;
  • 当前触摸事件所处的状态

    1
    @property(nonatomic,readonly) UITouchPhase phase;
UITouch常用方法
  • 获取触摸点的坐标

    1
    - (CGPoint)locationInView:(nullable UIView *)view;
    • 返回值表示触摸在view上的位置;
    • 这里返回的位置是针对触摸view的坐标系的(以view的左上角为原点{0, 0});
    • 调用时传入的view参数为nil的话,返回的是触摸点在UIWindow的位置。
  • 获取触摸view上一个触摸点坐标(上一个触摸位置的历史记录点)

    1
    - (CGPoint)previousLocationInView:(nullable UIView *)view;

UIEvent

UIEvent称为事件对象,记录事件产生的时刻和类型。每产生一个事件,就会产生一个UIEvent对象。

UIEvent常用属性
  • 事件类型

    1
    2
    3
    @property(nonatomic,readonly) UIEventType type;

    @property(nonatomic,readonly) UIEventSubtype subtype;
  • 事件产生的时间

    1
    @property(nonatomic,readonly) NSTimeInterval timestamp;

1.2. 加速计事件

1
2
3
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));

1.3. 远程控制事件

1
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event API_AVAILABLE(ios(4.0));

1.4. 3DTouch事件

3DTouch是iOS9新增的事件,只有iPhone6s以上机型才支持。

1
2
3
4
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));

二、事件的产生和传递

发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中。

UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常,先发送事件给应用程序的主窗口(就是UIApplicationkeyWindow)。

主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程的第一步。

找到合适的视图控件后,就会调用视图控件的touches系列方法来作具体的事件处理。

触摸事件的传递是从父控件传递到子控件,如果父控件不能接收触摸事件,那么子控件就不可能接收到触摸事件。

什么情况下UIView不接收事件呢?

  • 不接收用户交互 userInteractionEnabled = NO;
  • 视图隐藏 hidden = YES;
  • 透明 alpha <= 0.01

注意:UIImageViewuserInteractionEnabled默认值是NO,所以UIImageView及其子控件默认是不能接收事件的。

我们通过一个示例程序,看下事件传递过程:

示例中视图的层级关系:

点击红色view时事件传递:

1
-> UIApplication -> UIWindow(keyWindow)-> 白色view(或控制器view)-> 红色view

点击紫色view时事件传递:

1
-> UIApplication -> UIWindow(keyWindow)-> 白色view(或控制器view)-> 黄色view -> 蓝色view -> 紫色view

2.1. hitTest

这个方法是UIView提供的,当事件传递给当前view时,会调用当前view的该方法看自己及其子类是否能够接收事件,最终返回能够接收事件的view对象。

1
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event
  • point:触摸点
  • event:事件信息
  • 返回值:处理事件的view

我们通过上面案例看下该方法的响应过程:

ViewController.m

1
2
3
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"控制器");
}

DBRedView

1
2
3
4
5
6
7
8
9
10
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"RedView-Touch");
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"RedView");
UIView *fitView = [super hitTest:point withEvent:event];
NSLog(@"RedView:%@", fitView);
return fitView;
}

DBGreenView

1
2
3
4
5
6
7
8
9
10
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"GreenView-Touch");
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"GreenView");
UIView *fitView = [super hitTest:point withEvent:event];
NSLog(@"GreenView:%@", fitView);
return fitView;
}

DBYellowView

1
2
3
4
5
6
7
8
9
10
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"YellowView-Touch");
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"YellowView");
UIView *fitView = [super hitTest:point withEvent:event];
NSLog(@"YellowView:%@", fitView);
return fitView;
}

DBBlueView

1
2
3
4
5
6
7
8
9
10
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"BlueView-Touch");
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"BlueView");
UIView *fitView = [super hitTest:point withEvent:event];
NSLog(@"BlueView:%@", fitView);
return fitView;
}

DBPurpleView

1
2
3
4
5
6
7
8
9
10
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"PurpleView-Touch");
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"PurpleView");
UIView *fitView = [super hitTest:point withEvent:event];
NSLog(@"PurpleView:%@", fitView);
return fitView;
}

DBTealView

1
2
3
4
5
6
7
8
9
10
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"TealView-Touch");
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"TealView");
UIView *fitView = [super hitTest:point withEvent:event];
NSLog(@"TealView:%@", fitView);
return fitView;
}

点击控制器白色view时,输出:

1
2
3
4
5
2016-09-02 15:44:06.321289+0800 ResponderDemo[26021:1537173] RedView
2016-09-02 15:44:06.325440+0800 ResponderDemo[26021:1537173] RedView:(null)
2016-09-02 15:44:06.328733+0800 ResponderDemo[26021:1537173] YellowView
2016-09-02 15:44:06.328981+0800 ResponderDemo[26021:1537173] YellowView:(null)
2016-09-02 15:44:06.330303+0800 ResponderDemo[26021:1537173] 控制器

点击红色view时,输出:

1
2
3
4
5
2016-09-02 16:02:10.479834+0800 ResponderDemo[26986:1550250] RedView
2016-09-02 16:02:10.480586+0800 ResponderDemo[26986:1550250] GreenView
2016-09-02 16:02:10.481762+0800 ResponderDemo[26986:1550250] GreenView:(null)
2016-09-02 16:02:10.482075+0800 ResponderDemo[26986:1550250] RedView:<DBRedView: 0x7ff8bef0acb0; frame = (61 142; 240 128); autoresize = RM+BM; layer = <CALayer: 0x6000028c1be0>>
2016-09-02 16:02:10.483538+0800 ResponderDemo[26986:1550250] RedView-Touch

点击紫色view时,输出:

1
2
3
4
5
6
7
8
9
10
11
2016-09-02 16:02:35.484942+0800 ResponderDemo[26986:1550250] RedView
2016-09-02 16:02:35.485866+0800 ResponderDemo[26986:1550250] RedView:(null)
2016-09-02 16:02:35.486541+0800 ResponderDemo[26986:1550250] YellowView
2016-09-02 16:02:35.488789+0800 ResponderDemo[26986:1550250] BlueView
2016-09-02 16:02:35.489842+0800 ResponderDemo[26986:1550250] TealView
2016-09-02 16:02:35.493370+0800 ResponderDemo[26986:1550250] TealView:(null)
2016-09-02 16:02:35.494274+0800 ResponderDemo[26986:1550250] PurpleView
2016-09-02 16:02:35.495143+0800 ResponderDemo[26986:1550250] PurpleView:<DBPurpleView: 0x7ff8bef0be20; frame = (23 20; 138 86); autoresize = RM+BM; layer = <CALayer: 0x6000028c1b00>>
2016-09-02 16:02:35.496871+0800 ResponderDemo[26986:1550250] BlueView:<DBPurpleView: 0x7ff8bef0be20; frame = (23 20; 138 86); autoresize = RM+BM; layer = <CALayer: 0x6000028c1b00>>
2016-09-02 16:02:35.497910+0800 ResponderDemo[26986:1550250] YellowView:<DBPurpleView: 0x7ff8bef0be20; frame = (23 20; 138 86); autoresize = RM+BM; layer = <CALayer: 0x6000028c1b00>>
2016-09-02 16:02:35.499034+0800 ResponderDemo[26986:1550250] PurpleView-Touch
  • 分析点击紫色view事件传递过程:
    • 查看UIWindow上的视图,把事件传递给控制器view;
    • 倒序遍历控制器view子视图[Yellow, RedView],把事件传递给RedView
    • 遍历RedView的子视图,发现是空的,且不是最适合接收事件的对象,返回nil,把事件传递给YellowView
    • 遍历YellowView的子视图[BlueView],把事件传递给BlueView
    • 遍历BlueView的子视图[PurpleView, TealView],把事件传递给TealView
    • 遍历TealView的子视图,发现是空的,且不是最适合接收事件的对象,返回nil,把事件传递给PurpleView
    • 遍历PurpleView的子视图,发现是空的,但是自己是最适合接收事件的对象,返回自己,并把这个返回值依次往上传递给自己的父视图(BlueViewYellowView),最后执行Touch方法;

通过上面案例我们可以总结以下结论:

系统触摸事件到App进程后,由UIApplication的事件队列进行管理,之后会按照数组倒序形式匹配适合的视图来接收事件,并把事件传递给子控件,调用子控件的hitTest:withEvent:方法,如果子控件没有找到最适合的view,那么自己就是最适合接收事件的view,最终执行Touch方法。

问题来了,怎样判断一个视图就是最适合接收事件的视图呢?

继续往下看……

2.2. pointInside

这个方法也是UIView提供的,当事件传递给当前视图时,会判断触摸点是否在当前视图范围内,以此来判断是否可以有效接收事件。

1
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
  • point:触摸点
  • event:事件信息
  • 返回值:YES代表点在view的范围内,NO代表触摸点不在范围内

继续研究上面的案例:
DBYellowView新增pointInside:方法

1
2
3
4
5
6
7
8
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"YellowView-Touch");
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"YellowView-pointInside");
return NO;
}

点击紫色view时,输出:

1
2
3
4
5
6
2016-09-02 16:53:56.407022+0800 ResponderDemo[29351:1579409] RedView
2016-09-02 16:53:56.407183+0800 ResponderDemo[29351:1579409] RedView:(null)
2016-09-02 16:53:56.407358+0800 ResponderDemo[29351:1579409] YellowView
2016-09-02 16:53:56.407619+0800 ResponderDemo[29351:1579409] YellowView-pointInside
2016-09-02 16:53:56.408169+0800 ResponderDemo[29351:1579409] YellowView:(null)
2016-09-02 16:53:56.410258+0800 ResponderDemo[29351:1579409] 控制器

pointInside方法返回NO时,事件没有继续往下传递,touches相关方法也就不再执行了。而且事件往下传递前调用了pointInside:withEvent:方法,最后在hitTest:withEvent:方法中返回了nil

把黄色view中的pointInside返回YES,点击控制器view时,输出:

1
2
3
4
5
6
7
8
2016-09-02 17:23:23.508077+0800 ResponderDemo[30341:1594278] RedView
2016-09-02 17:23:23.508731+0800 ResponderDemo[30341:1594278] RedView:(null)
2016-09-02 17:23:23.509501+0800 ResponderDemo[30341:1594278] YellowView
2016-09-02 17:23:23.510144+0800 ResponderDemo[30341:1594278] YellowView-pointInside
2016-09-02 17:23:23.510786+0800 ResponderDemo[30341:1594278] BlueView
2016-09-02 17:23:23.511459+0800 ResponderDemo[30341:1594278] BlueView:(null)
2016-09-02 17:23:23.512512+0800 ResponderDemo[30341:1594278] YellowView:<DBYellowView: 0x7fa89df113a0; frame = (32 335; 269 143); autoresize = RM+BM; layer = <CALayer: 0x600002317a80>>
2016-09-02 17:23:23.514456+0800 ResponderDemo[30341:1594278] YellowView-Touch

为什么点击控制器view时,YellowView会执行Touch方法?因为事件传递给黄色view时,黄色view把该触摸点认为是在自身范围内的,所以最终在hitTest方法中返回自己。

这在正常逻辑中是错误的行为,因为pointInside方法必须是和方法调用者在同一个坐标系。默认情况下,由系统判断一个点是否在view的范围内时,是根据触摸点的坐标是否超出view的范围来判断的,如果超出view的范围,就不应该触发touch方法。

2.3. hitTest方法的内部实现

上面了解事件传递的规则后,我们尝试手动实现其内部逻辑代码。

内部逻辑:

  1. 判断自己能否接收事件;
  2. 点在不在自己身上;
  3. 倒序遍历子控件,把事件传递给子控件,调用子控件的hitTest:withEvent方法;
  4. 如果子控件没有找到最适合的view,那么自己就是最适合的view。

内部实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// 1.判断自己是否可以接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) {
return nil;
}
// 2.判断触摸点是否在自己范围内
if (![self pointInside:point withEvent:event]) {
return nil;
}
// 3.逆序遍历自己的子控件,事件传递给子控件,调用子控件的hitTest:withEvent方法
for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
// 此处不要直接传上面的point和event,子视图和父视图不在同一个坐标系,需要把当前触摸点的坐标系转换为subView的坐标系
// CGPoint convertPoint = [self convertPoint:point toView:subView]; // 两个方法通用
CGPoint convertPoint = [subView convertPoint:point fromView:self];
UIView *fitView = [subView hitTest:convertPoint withEvent:event];
if (fitView) {
return fitView;
}
}
// 4.没有找到更合适的view,就把自己返回
return self;
}

三、案例分析

场景:一个view覆盖到按钮上面,按钮绑定的事件和view的点击事件互不影响。

代码实现:
DBTealView.h

1
2
3
@interface DBTealView : UIView

@end

DBTealView.m

1
2
3
4
5
6
7
8
9
#import "DBTealView.h"

@implementation DBTealView

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"TealView-Touch");
}

@end

DBTouchButtonViewController.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#import "DBTouchButtonViewController.h"
#import "DBTealView.h"

@interface DBTouchButtonViewController ()

@property (weak, nonatomic) IBOutlet UIButton *redBtn;
@property (weak, nonatomic) IBOutlet DBTealView *tealView;

@end

@implementation DBTouchButtonViewController

- (void)viewDidLoad {
[super viewDidLoad];
}

- (IBAction)redBtnTouch:(id)sender {
NSLog(@"%s", __func__);
}

@end

点击蓝色view输出:TealView-Touch
点击红色按钮输出:-[DBTouchButtonViewController redBtnTouch:]
点击红色按钮和蓝色view的交集(覆盖面)输出:TealView-Touch

很显然不是我们想要的结果,利用上面介绍的知识修改下代码就可以。
DBTouchButtonViewController.m

1
2
3
4
- (void)viewDidLoad {
[super viewDidLoad];
self.tealView.bindBtn = self.redBtn;
}

DBTealView.h

1
2
3
4
5
@interface DBTealView : UIView

@property (nonatomic, strong) UIButton *bindBtn;

@end

DBTealView.m

1
2
3
4
5
6
7
8
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint btnPoint = [self convertPoint:point toView:self.bindBtn];
if ([self.bindBtn pointInside:btnPoint withEvent:event]) {
return self.bindBtn;
} else {
return [super hitTest:point withEvent:event];
}
}

点击蓝色view输出:TealView-Touch
点击红色按钮输出:-[DBTouchButtonViewController redBtnTouch:]
点击红色按钮和蓝色view的交集(覆盖面)输出:-[DBTouchButtonViewController redBtnTouch:]

结果符合预期。

结论:当遇到事件冲突或者需要事件转移的时候,应首先考虑让哪个视图在什么样的条件下成为事件最终处理者。在平时开发中,通过该方式可以增加视图的热区(触摸)范围。

四、事件的响应链

响应链条:由多个响应者对象连接起来的链条。
作用:能够很清楚的看见每个响应者之间的关系,并且可以让一个事件多个对象处理。

示例

UI构建还是以上面的示例程序为参考

自定义Application、Window、然后重写他们以及界面上view的touchesBegan:withEvent:方法

main.m

1
2
3
4
5
6
7
8
9
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
NSString * applicationClassName;
@autoreleasepool {
appDelegateClassName = NSStringFromClass([AppDelegate class]);
applicationClassName = NSStringFromClass([DBApplication class]);
}
return UIApplicationMain(argc, argv, applicationClassName, appDelegateClassName);
}

DBApplication.m

1
2
3
4
5
6
7
8
@implementation DBApplication

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

@end

DBWindow

1
2
3
4
5
6
7
8
@implementation DBWindow

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

@end

AppDelegate.m

1
2
3
4
5
6
7
8
9
10
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[DBWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];

UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
UIViewController *rootVC = [storyboard instantiateInitialViewController];
self.window.rootViewController = rootVC;

[self.window makeKeyAndVisible];
return YES;
}

其他view都参照DBWindow,重写touchesBegan:withEvent:方法,并调用父类的方法。

点击紫色view输出:

1
2
3
4
5
6
2016-09-02 19:14:16.940994+0800 ResponderDemo[59378:1948636] -[DBPurpleView touchesBegan:withEvent:]
2016-09-02 19:14:16.941752+0800 ResponderDemo[59378:1948636] -[DBBlueView touchesBegan:withEvent:]
2016-09-02 19:14:16.942950+0800 ResponderDemo[59378:1948636] -[DBYellowView touchesBegan:withEvent:]
2016-09-02 19:14:16.947504+0800 ResponderDemo[59378:1948636] -[ViewController touchesBegan:withEvent:]
2016-09-02 19:14:16.947691+0800 ResponderDemo[59378:1948636] -[DBWindow touchesBegan:withEvent:]
2016-09-02 19:14:16.948315+0800 ResponderDemo[59378:1948636] -[DBApplication touchesBegan:withEvent:]

事件处理的流程

  1. 先将事件对象由上往下传递(由父控件传递给子控件),找到合适的控件来处理这个事件;
  2. 调用最合适控件的touches..系列方法;
  3. 如果调用了[super touches...]就会将事件顺着响应者链条往上传递,传递给上一个响应者;
  4. 接着就会调用上一个响应者的touches..系列方法;
  5. 如果没有响应者或响应者对象是UIApplication,则事件终止。

如何判断上一个响应者?

  • 如果当前view是控制器的view,那么控制器就是上一个响应者;
  • 如果当前view不是控制器的view,那么父控件就是上一个响应者。

后记

如果想监听一个view上面的触摸事件,需要自定义view,实现view的touches方法,在方法内部实现具体处理代码。但这种方式实现起来有明显的缺点:必须自定义view,由于在view内部touches方法中监听事件,因此默认情况下,无法让其他外界对象监听view的触摸事件,而且实现用户的具体手势行为非常繁琐。因此苹果给我们提供了手势识别功能(Gesture Recognizer),简单易用,开发效率大大提升。


注意:hitTestpointInside方法系统会执行两次,文中的相关输出都是经过过滤的。至于为什么会执行两次,苹果工程师给出了以下答复(查看原文回复)

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.