1. 目录

  • 介绍
  • 响应者
  • 响应者链
  • 第一响应者
  • 下一响应者(nextResponder)
  • 寻找第一响应者
  • 寻找触摸事件的第一响应者
  • 实验与分析
    • 基本测试
    • 按钮(UIControl子类)点击实验
    • hitTest方法对事件传递的影响
    • 调用栈测试
  • 总结

介绍

在UIKit中,事件传递简单来说仅为三个步骤:

  • 确定第一响应者
  • 给第一响应者发事件
  • 让事件在响应者链上传递(可选)

响应者

首先,我们先要理解什么是「响应者」。简单来说,响应者就是一个能响应(处理)事件的「实例对象」。在iOS中,只要是UIResponder的子类的实例对象便是响应者。常见的UIResponder子类有以下几种:

  • UIView
  • UIViewController
  • UIApplication

响应者链

了解了响应者之后,也就不难理解响应者链了。正如其名,它实际上是由响应者组成的一个链表。在下图中:

  • UIButton->UIView->UIView->UIViewController->UIWindow->UIApplication->UIApplicationDelegate

就是其中一条响应者链。

第一响应者

第一响应者:最适合处理这个事件的响应者,你可以认为它就是事件苦苦追寻的「如意郎君」。

下一响应者(nextResponder)

UIResponder有一个nextResponder属性(swift中是next),指向其下一个响应者结点,也可以理解为数据结构链表中的next指针。nextResponder属性是只读的,所以在运行时不能动态的修改它,但是你可以自定义UIResponder的子类重写(override )它的get方法。

UIKit中的一些类就已经重写了该属性的get方法:

描述
UIView 若该View是UIViewController根视图,则nextResponder指向UIViewController的实例对象;否则,nextResponder指向该View的父视图
UIViewController 若该视图控制器是Window的根视图控制器,则nextResponder指向Window;若由另外一个视图控制器呈现,则nextResponder指向这另外一个视图控制器。
UIWindow nextResponder指向UIApplication的实例对象。
UIApplication nextResponder指向AppDelegate(去项目里看看AppDelegate文件,是不是继承UIResponder)

寻找第一响应者

如何确定第一响应者?Apple官方文档给出了相应的列表:

描述
触摸事件(Touch events) 所触摸的那个视图
按压事件(Press events) 获得焦点的那个视图
摇晃运动事件(Shake-motion events) 由开发者或UIKit指定
远程控制事件(Remote-control events) 由开发者或UIKit指定
编辑菜单消息(Editing menu messages) 由开发者或UIKit指定

寻找触摸事件的第一响应者

如何找到事件的如意郎君?UIView中的两个函数是找人的好帮手~

函数名 说明
hitTest:withEvent: 在该视图树中寻找最适合作为第一响应者的View
pointInside:withEvent: 返回某个point是否在当前View所覆盖的范围中

这里给出一份网上流传比较广的hitTest大概逻辑代码,与真实代码肯定会有一定出入,仅供参考。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
return nil;
}

总结一下上面代码的逻辑:

  • (isUserInteractionEnabled属性设置为NO) 或 (视图被隐藏)或(透明度<=0.01的视图)直接返回nil。这个不难理解,满足以上条件的视图不被当成事件的接收者,在这里我称它为「障碍响应者」。
  • 当传入的Point不在该视图范围内时,返回nil。这个也很好理解,当Point不在该视图范围内,也就没有必要去递归hitTest其子视图了。
  • 当Point在该视图内且该视图不是「障碍响应者」时,倒序递归调用子视图的hitTest。这里要注意几点:
    • 第一,倒序遍历是因为视图树上后加入的视图的z轴坐标值更高,优先让最贴近屏幕的那个视图作为响应者。
    • 第二,调用子视图的hitTest时,会将当前Point转换成子视图坐标系的Point。
    • 第三,当遇到第一个合适的视图(z轴值高的那个)时直接返回。

实验与分析

基本测试

下面设计了一个例子,来理解hitTest是如何确定第一响应者,以及响应者间是如何传递事件的。

这个界面所对应的视图树如下图。在同一层内,从左到右的顺序加入结点。需要注意的是,黄色框加了一个UITapGestureRecognizer。

测试代码如下较长,点TestCode.txt查看。

当点击蓝色方块时,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

⭐️ orange - hitTest enter
⭐️ black - hitTest enter
💗 black - hitTest result: nil
⭐️ green - hitTest enter
💗 green - hitTest result: nil
⭐️ blue - hitTest enter
⭐️ red - hitTest enter
💗 red - hitTest result: nil
💗 blue - hitTest result: Optional(<iOS_Touch.TestView: 0x120d09270; frame = (20 20; 150 150); backgroundColor = UIExtendedSRGBColorSpace 0 0 1 1; layer = <CALayer: 0x60000115c8c0>>)
💗 orange - hitTest result: Optional(<iOS_Touch.TestView: 0x120d09270; frame = (20 20; 150 150); backgroundColor = UIExtendedSRGBColorSpace 0 0 1 1; layer = <CALayer: 0x60000115c8c0>>)
blue - touchesBegan
orange - touchesBegan
ViewController - touchesBegan
AppDelegate - touchesBegan
UITapGestureRecognizer - tap
blue - touchesCancelled
orange - touchesCancelled
ViewController - touchesCancelled
AppDelegate - touchesCancelled

关于hitTest,可以得出如下几个结论:

  • 由hitTest enter可看出,从橙色方块开始,深度优先遍历其子视图,同一层下优先遍历后加入的视图(黑->绿->蓝)
  • 由hitTest result:可看出,其它颜色块返回nil,到了被点击的蓝色块时命中,并返回蓝色块对象作为第一响应者。

关于事件传递,可以得到如下结论:

  • 从蓝色块开始,沿着其响应链向上传递事件,一直到AppDelegate。
  • UITapGestureRecognizer是依附在橙色方块上的,但是点击其子视图(蓝色块),也得到了识别。说明UITapGestureRecognizer会识别其子视图的触摸。
  • UITapGestureRecognizer识别成功后,从第一响应者(蓝色块)开始往上传递touchesCancelled事件,一直到AppDelege。
按钮(UIControl子类)点击实验

点击btn按钮后,输出如下:

1
2
3
4
5
6
7
8
9
10
11
⭐️ orange - hitTest enter
⭐️ black - hitTest enter
💗 black - hitTest result: nil
⭐️ green - hitTest enter
💗 green - hitTest result: nil
⭐️ blue - hitTest enter
⭐️ red - hitTest enter
💗 red - hitTest result: Optional(<UIButton: 0x155509db0; frame = (0 0; 50 50); opaque = NO; layer = <CALayer: 0x600000d5ada0>>)
💗 blue - hitTest result: Optional(<UIButton: 0x155509db0; frame = (0 0; 50 50); opaque = NO; layer = <CALayer: 0x600000d5ada0>>)
💗 orange - hitTest result: Optional(<UIButton: 0x155509db0; frame = (0 0; 50 50); opaque = NO; layer = <CALayer: 0x600000d5ada0>>)
button - tap

可以得出如下结论:

  • 按钮为第一响应者时,响应链上的touchesBegan方法未输出,阻断了响应链的事件传递。
hitTest方法对事件传递的影响

接下来,我们将hitTest方法返回值固定为nil,再查看输出结果,代码如下。

1
2
3
4
5
6
7

override func hitTest(_ point: CGPoint,
with event: UIEvent?) -> UIView? {
print("⭐️ \(name) - hitTest enter")
return nil
}

分别点击蓝色块,橙色块,btn按钮,输出结果皆为:

1
2
3
4
5
⭐️ orange - hitTest enter
ViewController - touchesBegan
AppDelegate - touchesBegan
ViewController - touchesEnded
AppDelegate - touchesEnded
通过上面的输出,可以得到如下结论:

  • 预期的touchesBegan、手势识别、按钮事件都未输出。说明hitTest用于确定第一响应者,若该方法失效,事件传递、UITapGestureRecognizer、基于UIControl的按钮都会失效。
  • 由于TestView的hitTest固定为nil,实际上的第一响应者变成了ViewController的View(这里没写打印的代码),事件一直传递到AppDelegate
调用栈测试

分别对UITapGestureRecognizer、按钮、普通视图进行断点,结果如下图:

可以得到如下的结论:

  • 调用栈开始部分都相同,到了-[UIWindow sendEvent:]后,按钮和普通视图都走 -[UIWindow _sendTouchesForEvent:],而手势识别开始走-[UIGestureEnvironment _updateForEvent:window:]。所以,手势识别不受响应者链的影响。
  • 按钮的点击事件在-[UIControl touchesEnded:withEvent:]之后,因此受响应者链影响。

我们将所有super.touchesBegan/Ended等代码注释后。发现手势识别不受影响,但按钮的点击事件无效了,与我们结论可以对应上。

总结

在确定第一响应者后,系统就可以从RunLoop开始给该响应者发送touch事件了。比较典型的是touchesMoved方法。当你触摸某个View的时候,会先通过hitTest确定第一响应者,然后持续不断的调用该响应者的touchesMoved方法传递touch数据。

默认情况下事件会从第一响应者开始传递到响应者链的尾巴(一般是AppleDelegate)。所以如果某个响应者是该响应者链上的一个结点的话,可以通过重写touchesBegan/touchesEnded/touchesMoved... 方法来截取事件。

下面有几种特殊情况,开发中需要特别注意:

  • 在重写touchesBegan/...的代码中没调用super.touchesBegan/...的话,就不会继续将事件传递下一个响应者了。
  • UIControl的实例对象默认不会将事件传递给下一个响应者。但是如果你把它的target设置为nil,它会在响应者链上寻找匹配的action。
  • UIGestureRecognizer、UIControl、事件传递都受hitTest影响,因此重写此函数需格外谨慎。
  • UIGestureRecognizer 会识别对应的视图及其子视图的事件。
  • UIGestureRecognizer 不受响应者链的影响。
  • 重写按钮等UIControl的touchesBegan/...时需要谨慎,可能会使按钮失效。