iOS 事件传递及响应链

记得在几年前写过一篇文章《不规则 Button 点击》研究了一下 iOS 中事件响应的相关知识。

最近刚好碰到了一些点击和手势相关的问题,就详细记录一下 iOS 中事件响应,事件传递以及结合手势一起使用时的情况。

寻找最佳响应者和事件传递

响应者

iOS 系统中,我们使用 UIKit 提供的众多类来搭建页面,其中 UIResponder 类为我们提供了响应事件的能力,我们常用的 UIView、UIViewController 和 UIApplication 都是 UIResponder 的子类,几乎我们常接触的一些控件都是响应者。

UIResponder 提供了以下一些方法,来处理事件:

1
2
3
4
open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)
  • UITouch
    存储用户触摸屏幕时的相关数据,随着触摸的变化,UITouch 数据也会实时更新。
  • UIEvent
    被传递的事件实例,里面存储了当前需要被响应事件的一些信息。例如,事件类型。

寻找最佳响应者

现在,弄清楚了处理事件的对象了。但是,屏幕上那么多视图堆叠,肯定是需要一套机制,让我们判断这次事件应该由谁先处理,也就是寻找最佳响应者,这个过程也叫做 Hit-Testing。

Hit-Testing 过程主要用到两个方法:

1
2
3
4
5
// recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;

// default returns YES if point is in bounds
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;

接下来,通过一个演示例子来说明具体的流程:

PS: 系统检测到一个触摸事件发生后,会有一系列的处理,这边就忽略掉系统处理的流程,直接进入到开发人员需要关注的地方。

寻找过程

首先,先明确一下视图层级:

点击 A 内部

具体流程说明:

  1. 首先对 A 进行命中测试,显然触摸点在 A 内部。于是,判断 A 是否有子视图,有则检查,无则返回 A 本身。
  2. 倒序遍历 A 的子视图(D,C),倒序的原因是,后添加视图,在 subViews 数组的后面,但是后添加的视图,但是在视图层级中处于最上方。
  3. 对 D 进行命中测试,发现触摸点不在 D 中,说明 D 及其子视图不会是最佳响应者。
  4. 对 C 进行命中测试,发现触摸点不在 C 中,说明 C 及其子视图不会是最佳响应者。
  5. 子视图检查完毕。得到结论:触摸点在 A 内部,但不在 A 的任一子视图内。于是,A 就是最佳响应者。

A 🆗 –> D❌ –> C❌ >>> A

点击 C 内部

具体流程说明:

  1. 首先对 A 进行命中测试,显然触摸点在 A 内部。于是,判断 A 是否有子视图,有则检查,无则返回 A 本身。
  2. 倒序遍历 A 的子视图(D,C),倒序的原因是,后添加视图,在 subViews 数组的后面,但是后添加的视图,但是在视图层级中处于最上方。
  3. 对 D 进行命中测试,发现触摸点不在 D 中,说明 D 及其子视图不会是最佳响应者。
  4. 对 C 进行命中测试,触摸点在 C 中,进行子视图的检查。
  5. 对 E 进行命中测试,发现触摸点不在 E 中,说明 E 及其子视图不会是最佳响应者。
  6. 子视图检查完毕。得到结论:触摸点在 A 内部子视图 C 中,但不在 C 的任一子视图内。于是,C 就是最佳响应者。

A 🆗 –> D❌ –> C🆗 –> E❌ >>> C

点击 E 内部

这个就不写了,给看文章的你们,练习一下。

小结

1. Hit-Testing 触发了两次的问题

这个问题苹果官方进行过回复:

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.

2. 视图本身能否接收事件的判断

当视图本身状态设置,符合下方任一时,则视图自身不能响应事件。

  • view.isUserInteractionEnabled = false
  • view.isHidden = true
  • view.alpha <= 0.01

3. 代码实现

这两个方法都是可以被 override 的。所以,如果需求中有一些异性的控件需要特殊处理时,可以针对自己的需求,在这里面做一些修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class TestTouchView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 1. 判断能否接收事件
if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
return nil
}
// 2. 判断触摸点是否在自身内部
if self.point(inside: point, with: event) {
// 3. 子视图倒序进行 Hit-Testing
for subview in subviews.reversed() {
let pointInSub = subview.convert(point, from: self)

// 4. 子视图下有更优响应者
if let sub = subview.hitTest(pointInSub, with: event) {
return sub
}
}
// 5. 触摸点在视图内部且子视图无更优响应者,则返回自身
return self
}
// 触摸点不在自身内部
return nil
}

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return self.bounds.contains(point)
}
}

传递事件

找到最佳响应者之后,接下来就是事件处理了,最佳响应者拥有优先处理事件的机会,如果该视图无法处理这个事件,那么事件会沿着寻找响应者时的路径反向传递。

以上面章节 点击 C 内部 的例子来说明一下:

  • 寻找最佳响应者过程

A 🆗 –> D❌ –> C🆗 –> E❌ >>> C

  • 事件传递

去掉打叉的节点,并逆转一下,就得到:

C –> A

再把这个响应链完整一下就是:

C -> A -> UIViewController 对象 -> UIWindow 对象 -> UIApplication 对象 -> App Delegate

找到下一个响应者,其实是通过 UIResponder 的 next 属性得到的,对于 UIView 来说,next 就是其父视图或者是其所属的控制器对象。

1
open var next: UIResponder? { get }

这里还是举个例子说明一下。

小结

  1. 利用 Hit-Testing 找到 最佳响应者。由下往上(从根视图往上寻找)
  2. 由最佳响应者的 next 属性来确定响应链。
  3. 事件沿着 响应链 传递。从上往下(从最佳响应者往下寻找)

响应链中的手势处理

官方文档

Gesture recognizers receive touch and press events before their view does. If a view’s gesture recognizers fail to recognize a sequence of touches, UIKit sends the touches to the view. If the view doesn’t handle the touches, UIKit passes them up the responder chain.

文档中 表明了手势识别器在视图之前接收触摸和按下事件。如果视图的手势识别器无法识别一系列触摸,UIKit 会将触摸发送到该视图。如果视图不处理触摸,UIKit 会将它们沿着响应器链向上传递。

传递的是什么呢?其实就是 UITouch 和 UIEvent,在 UITouch 中,有一个属性 gestureRecognizers 存储了触摸时收集到的所有手势,因为 UITouch 是会随着触摸的变化,不断地更新的,所以 gestureRecognizers 会存储响应链上所有节点的手势,并且在自身不断更新的同时,手势识别系统也会判断当前的 UITouch 对象是否符合收集到的手势。

1
2
3
4
5
6
7
8
9
10
11
// 手势是否识别成功了,但是状态是否要由 .possible 转到其它活跃状态交给开发者控制。
// YES:进入活跃状态。false:进入 .failed 状态,手势判定失败。
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer;

// 在新的触摸到来时,在 touchesBegan:withEvent: 之前调用。
// NO:防止手势识别器识别到此触摸。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch;

// 在 -gestureRecognizer:shouldReceiveTouch: or -gestureRecognizer:shouldReceivePress: 之前调用一次
// NO:防止手势识别器识别此事件
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveEvent:(UIEvent *)event;

我们还是搞一个例子看看,根据结果再来说明:

  1. 找到最佳响应者之后,手势系统先判断是否接收触摸和事件。
  2. UIResponder 的 touches 系列的方法开始触发。
  3. 手势识别成功了,给响应者发送 touchesCancelled 消息,之后不会收到来自该 UITouch 对象的事件了。
  4. 手势系统工作。

UIControl 和手势

UIControl 通过 target-action 方式来处理事件,但是 UIControl.Event 的事件是通过其 touches 系列方法来识别的,而手势处理的时机会更早一些。

这里是一个自定义的 TestControl 继承自 UIControl,添加了 .touchUpInside 事件和点击事件的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class TouchEventViewController: UIViewController, UIGestureRecognizerDelegate {

@IBOutlet weak var touchViewC: TestTouchViewC!

override func viewDidLoad() {
super.viewDidLoad()

let tap = UITapGestureRecognizer(target: self, action: #selector(self.tap(sender:)))
tap.delegate = self

let control = TestControl(frame: CGRect(x: 0, y: 100, width: 40, height: 40))
control.backgroundColor = UIColor.blue
control.addTarget(self, action: #selector(self.controlAction(sender:)), for: .touchUpInside)

control.addGestureRecognizer(tap)

self.view.addSubview(control)
}

@objc func controlAction(sender: UIControl) {
print(#function)
}

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
print(#function)
return true
}

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
print("\(#function) UITouch")
return true
}

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive event: UIEvent) -> Bool {
print("\(#function) UIEvent")
return true
}

@objc func tap(sender: UITapGestureRecognizer) {
print(#function)
}
}

对于自定义的 UIControl 来说,手势识别的优先级比 UIControl 自身处理事件的优先级高。

如果是这样的话,还有一个问题就是,如果 UIControl 的父视图上存在与自身 controlEvents 一样的手势事件时,会导致 UIControl 的方法无法触发,总会被父视图中的手势给截断。

但是,这仅仅是针对咱们自定义的 UIControl 而言。在实际使用过程中,我们一般都是在使用系统提供了一系列 UIControl 的子类,并不会存在上述的问题。那是因为,苹果官方已经对这种情况做过一些特殊处理

实际开发中,当我们给一个已经拥有点击手势的视图,添加一个 UIButton 作为子视图,并且给按钮添加点击类型的 target-action 方法,那么当点击按钮时,按钮的 target-action 方法会触发,手势的方法会被忽略。

并且文档中也提到了,如果不想要这种情况发生,那就应当把手势添加到目标控件上(因为手势比控件更早识别到事件,也就是上文提到的给 UIControl 添加了 .touchupInside 方法的例子),这样的话生效的就是手势了。

总结

  1. 事件传递到 APP 内部时被封装成开发者可见的 UIEvent 对象,先经过 Hit-Testing 寻找最佳响应者,而后将事件传递给最佳响应者,并开始在响应链上的传递。
  2. UIRespnder、UIGestureRecognizer、UIControl,笼统地讲,事件响应优先级依次递增。