iOS触摸事件响应与传递

事件响应

什么是事件?

iOS中有大量的人机交互事件,比如触摸屏幕、摇晃手机、耳机或外接手柄的操作等等。这些事情传递到手机中让手机作出响应的就是事件。

iOS中事件分为三大类:

  • 触屏事件(手势:触摸、滑动、拖动…)
  • 传感器事件(摇一摇、陀螺仪)
  • 远程控制事件(耳机、外接手柄)

事件的响应

ios 中只有继承了 UIResponder 的对象才能接受并处理事件,这样的对象称为“响应者对象”。

UIResponder作为响应者的基类,定义了一些关于响应的属性和方法,用于子类判断或者执行关于响应的操作。iOS中有3种类继承自 UIResponder:

  • UIApplication
  • UIViewController
  • UIView

触摸事件解析

我们在手机上的一个触摸或点击,它其实首先是手机屏幕和底层相关的硬件对这个触摸一个解析的过程,将这个触摸解析成 event,然后 iOS 系统将这个 event 事件传递到相应的界面上,由界面来响应我们的操作,给出对应的反馈。

  • 事件传递
    是从父控件到子控件(从上到下)的寻找最合适响应事件的view。

  • 事件响应
    顺着响应者链条向上传递(子控件到父控件),直到传递给 window 能否处理此事件,若最终无法处理事件将被 application 丢弃。

UITouch、UIEvent、UIResponder

UITouch

当用户用手指触摸屏幕时,会创建相关的UITouch对象,当手指离开屏幕时,系统会销毁相应的UITouch对象。

UITouch 保存着跟手指相关的信息,如:触摸的位置,大小,移动和力度等

一根手指对应一个UITouch,多个手指同时点击会产生多个 UITouch,但 UIResponder 只会相应一次。

1
2
3
4
5
6
@interface UITouch : NSObject
// 返回UITouch对象在指定视图的坐标系中的当前位置;如果nil,则表示当前window中的坐标
- (CGPoint)locationInView:(nullable UIView *)view;
// 返回前一个触摸点的位置
- (CGPoint)previousLocationInView:(nullable UIView *)view;
@end

UIEvent

每次产生的事件,就对应一个 UIEvent,UIEvent 来描述单个用户与APP交互的对象。APP可以接收很多不同类型的 Event。

1
@interface UIEvent : NSObject

UIResponder响应者对象

UIResponder 是响应和处理事件的抽象接口。即它可以接收到UIEvent的对象,也是可以最终响应用户的操作的对象。

UIResponder中提供了以下4个对象方法来处理触摸事件:

1
2
3
4
5
6
7
8
9
10
// 手指按下
- (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 API_AVAILABLE(ios(9.1));

UIGestureRecongnizer 是个手势识别器,把识别到手势事件通过 addTarget 添加到对应的相应者进行相应。

触摸事件传递过程

无法接收触摸事件的情况

  • 不接收用户交互
    userInteractionEnabled = NO

  • 隐藏
    hidden = YES

  • 透明
    alpha = 0.0~0.01

  • 如果父控件不能接收触摸事件,那么子控件不可能接收到触摸事件。

事件的产生和传递

事件的产生过程:
  1. 发生触摸事件后,系统会将该事件加入到一个由 UIApplication 管理的事件队列中。因为队列的特点是FIFO,先进先出,先产生的事件先处理。

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

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

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

简单来说:触摸事件是从父控件传递到子控件,UIApplication -> window -> 寻找最合适处理事件的View

事件的传递:
  1. keyWindow 接收到UIApplication传递的事件后,会先判断自己能否接收事件,如果能,则会判断点是否在自己身上。

  2. 如果触摸点在 keyWindow 上,keyWindow会倒序遍历子控件来寻找合适View(先遍历新添加的,效率更高)

  3. 当遍历到每个 View 时,会重复执行1、2两个步骤 (传递事件给子控件, 判断子控件能否接受事件, 触摸点是否在子控件上)

  4. 循环遍历子控件后,直到找到最合适的View,如果没有符合的子控件,则认为自己为最合适处理的View.

寻找最合适的View的过程,用到如下的两个重要方法 :

1
2
3
4
5
6
7
8
9
10
11
12
/// 返回接收事件的对象,该对象是当前视图或者其后代。如果 pointInside: withEvent: 返回NO,则其返回 nil
/// 该方法会调用每个子视图的 pointInside: withEvent: 方法遍历视图的层次结构,来确定那个视图接收事件
/// @param point 接受者坐标系中的点
/// @param event 需要调用此方法的事件
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;

/// 判断触摸点是否在自己身上
/// 它在 hitTest:withEvent: 中被调用。如果返回YES,则遍历子视图的层次结构,找到包含指定点的最前面的视图。
/// 如果视图不包含该点或者设置视图无法接收事件,则忽略其视图的层次结构的分支,并且返回NO
/// @param point 当前视图坐标系内的一个点
/// @param event 需要调用此方法的事件
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

hitTest:withEvent:方法实现的原理:

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
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{

//1.判断自己能否接收事件
if(self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) {
return nil;
}
//2.判断当前点在不在当前View.
if (![self pointInside:point withEvent:event]) {
return nil;
}
//3.从后往前遍历自己的子控件.让子控件重复前两步操作,(把事件传递给,让子控件调用hitTest)
int count = (int)self.subviews.count;
for (int i = count - 1; i >= 0; i--) {
//取出每一个子控件
UIView *chileV = self.subviews[i];
//把当前的点转换成子控件从标系上的点.
CGPoint childP = [self convertPoint:point toView:chileV];
UIView *fitView = [chileV hitTest:childP withEvent:event];
//判断有没有找到最适合的View
if(fitView){
return fitView;
}
}

//4.没有找到比它自己更适合的View.那么它自己就是最适合的View
return self;

}

由上总结事件的传递顺序:
产生触摸事件 -> UIApplication事件队列 -> [UIWindow hitTest:withEvent:] -> 返回更合适的 View -> 子控件hitTest:withEvent: -> 返回最合适的View

代码验证

添加方法执行的标注:

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
class ZMWindow: UIWindow {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
print("\n")
print(type(of: self), #function, separator: " ", terminator: " --> ")
let v = super.hitTest(point, with: event)
return v
}

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
print(type(of: self), #function)
let res = super.point(inside: point, with: event)
return res
}
}

class ZMView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
print(type(of: self), #function, separator: " ", terminator: " --> ")
let v = super.hitTest(point, with: event)
return v
}

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
print(type(of: self), #function)
let res = super.point(inside: point, with: event)
return res
}
}

class RedView: ZMView {}
class YellowView: ZMView {}
class BlueView: ZMView {}

在UIViewController中依次添加View

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
override func viewDidLoad() {
super.viewDidLoad()

let red = RedView(frame: CGRect(x: 100, y: 100, width: 200, height: 50))
red.backgroundColor = .red
let yellow = YellowView(frame: CGRect(x: 150, y: 200, width: 150, height: 50))
yellow.backgroundColor = .yellow
let blue = BlueView(frame: CGRect(x: 100, y:300, width: 200, height: 50))
blue.backgroundColor = .blue

view.addSubview(red)
view.addSubview(yellow)
view.addSubview(blue)
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print(type(of: self), #function)
}

最后点击VC的View,结果打印:

1
2
3
4
5
6
7
8
9
10
11
ZMWindow hitTest(_:with:) --> ZMWindow point(inside:with:)
BlueView hitTest(_:with:) --> BlueView point(inside:with:)
YellowView hitTest(_:with:) --> YellowView point(inside:with:)
RedView hitTest(_:with:) --> RedView point(inside:with:)


ZMWindow hitTest(_:with:) --> ZMWindow point(inside:with:)
BlueView hitTest(_:with:) --> BlueView point(inside:with:)
YellowView hitTest(_:with:) --> YellowView point(inside:with:)
RedView hitTest(_:with:) --> RedView point(inside:with:)
MainViewController touchesBegan(_:with:)

通过上述验证,我们可以看到,为什么 VC 会进行2次寻找最适合的 View,然后再自己响应触摸事件?

触摸事件响应过程

1、事件传递找到了最合适的 View,最合适的 View 会调用 touches 的几个方法进行具体的事件处理,如果合适 View 重写了 touches 方法,则会进行事件的处理。否则系统调用该方法的默认实现。

2、Apple 官方文档上说 UIView 的 touches 方法,默认不处理事件, 它会将事件顺着响应链向上转发给上一个 UIResponder 处理,如果找到合适的 View 就会调用该view的touches方法要进行响应处理具体的事件,找不到就不会调用。

响应者链条

响应者链条是由多个响应者对象(UIResponder类型)一起组合起来的链条。事件在响应者链条中传递的过程:
1、如果当前 View 是控制器的 View,那么控制器是上一个响应者,事件就会传递给控制器。如果当前 View 不是控制器的View,那么当前View 的父视图就是上一个响应者,事件会传递给父视图。

2、事件会从合适的View的视图层级中向上传递,如果顶层View也无法处理,则事件最终传递给 UIWindow 处理

3、如果 UIWindow 也无法处理,则事件会传递给 UIApplication 处理

4、如果UIApplication无法处理,则该事件会被丢弃。

触摸事件的完整过程总结

1、用户触摸屏幕后会产生触摸事件,触摸事件会被添加到 UIApplication 管理的事件队列中。
2、UIApplication 会从事件队列中取出最前面的事件,把事件出递给 keyWindow
3、keyWindow会在视图层级中找到最合适的 View 来处理触摸事件
4、最合适的View会调用自己的touches方法进行事件处理
5、touches方法会顺着响应者链条向上传递,直到找到处理事件的视图,或者最终被 UIApplication抛弃。

事件响应的应用

找到 View 所属的 VC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation UIView (Extension)
-(UIViewController*)parentController {
UIResponder *responder = [self nextResponder];
while (responder)
{
if ([responder isKindOfClass:[UIViewController class]])
{
return (UIViewController*)responder;
}
responder = [responder nextResponder];
}
return nil;
}
@end

一个事件被多个View响应

点击 yellow 时,red 的touches 方法也会被响应。
因为事件的响应是顺着响应者链条向上传递的, 即从子控件传递给父控件, touch方法默认不处理事件, 而是把事件顺着响应者链条传递给上一个响应者.

1
2
3
4
5
6
7
8
9
10
11
12
class RedView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print(type(of: self), #function)
}
}

class YellowView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print(type(of: self), #function)
super.touchesBegan(touches, with: event)
}
}
1
2
3
4
5
6
7
8
9
10
11
override func viewDidLoad() {
super.viewDidLoad()

let red = RedView(frame: CGRect(x: 100, y: 250, width: 100, height: 100))
red.backgroundColor = .red
let yellow = YellowView(frame: CGRect(x: 50, y: 50, width: 50, height: 50))
yellow.backgroundColor = .yellow
view.addSubview(red)
red.addSubview(yellow)

}

扩大UIView的点击范围

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 YellowView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// 1、判断响应条件
if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
return nil
}

// 2、设定扩大10个点的点击范围
let touchRect = bounds.insetBy(dx: -10, dy: -10)

// 3、逆序遍历YellowView中的subView,寻找最合适的View,若没找到否则返回它自己
if touchRect.contains(point) {
for subView in subviews.reversed() {
let convertedPoint = (subView as UIView).convert(point, to: self)
let hitTestView = (subView as UIView).hitTest(convertedPoint, with: event)
if let hitTestView = hitTestView {
return hitTestView
}
}
return self
}
return nil
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("点击了YellowView")
}
}

视图被遮挡时响应

YellowView 遮挡了 greenBtn,点击YellowView 时,让 greenBtn 响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class YellowView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hitView = super.hitTest(point, with: event)
if hitView === self {
return nil
}
return hitView
}
}

let btn = UIButton(type: .custom)
btn.backgroundColor = .green
btn.frame = CGRect(x: 0, y: 0, width: 60, height: 60)
btn.center = view.center
btn.addTarget(self, action: #selector(clickGreenBtn), for: .touchUpInside)
view.addSubview(btn)

let yellow = YellowView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
yellow.backgroundColor = .yellow
yellow.center = self.view.center
self.view.addSubview(yellow)

学习博客

HChong
zhangzr’s blog
史上最详细的iOS之事件的传递和响应机制-原理篇
子控件超出父控件响应点击事件
FrizzleFur/DailyLearning

文章作者: Czm
文章链接: http://yoursite.com/2020/06/19/iOS%E8%A7%A6%E6%91%B8%E4%BA%8B%E4%BB%B6%E5%93%8D%E5%BA%94%E4%B8%8E%E4%BC%A0%E9%80%92/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Czm