0%

iOS 教程(三)事件响应链

在我们点击屏幕的时候,系统捕获到触摸事件,系统把包含这些触摸事件的信息包装成 UITouch 和 UIEvent 实例,然后找到当前运行的应用,逐级寻找能够响应这个事件的对象,直到没有响应者响应。这一系列响应者组成了响应链。

首先,系统捕获到点击行为后,将点击事件封装成 UIEvent 对象。接下来,就需要确定具体触摸到哪个 view,也即是找到手指触摸到的处于屏幕最前端的 view,这一步叫 hit-testing。

hit-testing

UIView 中有两个方法,hitTest 和 pointInside:

1
2
- (UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;

pointInside 方法是判断 point 是否在当前 view 内。
hitTest 方法是调用 pointInside 函数判断触点是否在当前 view 内,以及递归调用子 view 的 hitTest 方法,找到实际触摸的 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
33
34
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return CGRectContainsPoint(self.bounds, point);
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.userInteractionEnabled ||
self.alpha <= 0.01 ||
self.hidden) {
return nil;
}

if (![self pointInside:point withEvent:event]) {
return nil;
}

UIView *hitView = nil;
NSInteger subViewCount = [self.subviews count];
for (NSInteger index = subViewCount - 1; index >= 0; --index) {
UIView *subView = self.subviews[index];
if (subView.userInteractionEnabled && subView.alpha > 0) {
CGPoint pointInSub = [subView convertPoint:point fromView:self];
hitView = [subView hitTest:pointInSub withEvent:event];
if (hitView) {
break;
}
}
}

if (hitView == nil) {
hitView = self;
}

return hitView;
}

这里要注意,hitTest 方法中的 point 是已经转化到当前 view 的坐标系的,也即是当前 view 的左上角为 (0, 0)。所以当递归调用子 view 的 hitTest 方法时,也要注意转化到子 view 的坐标系。

系统也正是从 UIWindow 开始,层层递归调用 hitTest 方法,直到找到实际触摸到处于屏幕最前端的 view,姑且称之为 target-view 吧。

⚠️ 注意,UIEvent 会和 target-view 绑定,target-view 一旦确定,整个触摸过程中 target-view 都不会再改变,即使手指滑动离开了 target-view 的范围。

那么问题来了,如果 view.clipToBounds = NO,而且 subview 有超出 view 的部分。那么点击 subview 超过 view 的部分,view 的 hitTest 会返回什么呢?

答案是返回 nil 哦。

理解了 hitTest 和 pointInside 的逻辑之后,重写 hitTest 和 pointInside 可以只有圆形区域响应事件的效果,这里就不再细讲了。

响应链

所有需要响应触摸事件的类,都继承自 UIResponder,UIResponder 的子类有:UIView、UIApplication、UIViewController。响应链中的每个元素都是 UIResponder 的子类实例。

UIResponder 中有 4 个触摸事件处理方法:

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;

同时,UIResponser 中有一个 nextResponder 的属性,nextResponder 就是下一个响应者。

我们写一个 demo 来看下 nextResponder 都返回什么值。新建一个 ViewController 类,其中部分代码如下:

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
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.

self.title = @“ResponderChainDemo”;
self.view.backgroundColor = [UIColor whiteColor];

CGFloat navBarBottom = CGRectGetMaxY(self.navigationController.navigationBar.frame) + self.navigationController.view.window.windowScene.statusBarManager.statusBarFrame.size.height;

CGFloat side = 200;
CGFloat redViewX = (self.view.bounds.size.width - side) / 2.0f;

// UINamedView 继承自 UIView,并有一个 name 属性
self.redView = [[[UINamedView alloc] initWithFrame:CGRectMake(redViewX, navBarBottom + 10, side, side)] autorelease];
self.redView.backgroundColor = [UIColor redColor];
self.redView.name = @"RedView";
[self.view addSubview:self.redView];

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self printResponder];
});
}

- (void)printResponder {
NSLog(@"printResponder with nextResponder");
UIResponder *responder = self.redView;
while (responder) {
if ([responder isKindOfClass:[UINamedView class]]) {
NSLog(@"%@ (%@)", [responder class], [(UINamedView *)responder name]);
} else {
NSLog(@"%@", [responder class]);
}

responder = [responder nextResponder];
}
}

运行之后的 Log 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
UINamedView (RedView)
UIView
ViewController
UIViewControllerWrapperView
UINavigationTransitionView
UILayoutContainerView
UINavigationController
UIDropShadowView
UITransitionView
UIWindow
UIWindowScene
UIApplication
AppDelegate

我们可以看到,响应链通过 nextResponder 属性将 UIResponder 都连接起来了。

响应链的结构大致是:view -> superview -> ViewController -> UIWindow -> UIWindowScene -> UIApplication -> AppDelegate,ViewController、UIApplication、AppDelegate 都在响应链中。

如果存在子 controller,响应链的结构大致是:view -> ChildViewController -> ViewController.view -> ViewController -> …

借用苹果官方文档的图来说明下,更直观。
iOS_and_OSX_responder_chain_2x

系统会调用 UIApplication 的 sendEvent: 方法,继而调用 UIWindow 的 sendEvent: 方法。在 UIWindow 的 sendEvent: 方法中会调用 target-view 的触摸事件处理方法,事件也就开始在响应链中传递。如果 target-view 不处理这些事件,则通过 nextResponder 传递给下一个 responder 处理,这也是默认逻辑。如果 target-view 已经处理了这些事件了,则不需要往 nextResponder 传递,比如 UIButton。

⚠️ 在重写子类的事件处理方法时,不要手动传递事件给 nextResponder。应该通过调用 super 的事件处理方法,来把事件传递给 nextResponder。

手势 UIGestureRecognizer

给 view 添加手势后,touchesBegan 和 touchesMoved 仍旧会被调用,与没有添加手势时的逻辑一致。
如果 gesture 识别成功,则 touchesCancelled 被调用,否则 touchesEnded 被调用。

正确构造响应链

大部分的情况下,系统都会自动正确的构造响应链。例如,通过 addSubview 添加子 view 时,子 view 的 nextResponder 会自动的指向 superview,不需要写额外的逻辑。

但是,需要特别注意 UIViewController。有好些朋友创建子 controller 只是单纯的 addSubview,例如:

1
2
3
4
- (void)displayContentController: (UIViewController*) content {
content.view.frame = [self frameForContentController];
[self.view addSubview:self.currentClientView];
}

这样会导致子 controller 没有在响应链中,子 controller 的 viewDidAppear 等回调缺失。
正确的写法应该是:

1
2
3
4
5
6
- (void) displayContentController: (UIViewController*) content {
[self addChildViewController:content];
content.view.frame = [self frameForContentController];
[self.view addSubview:self.currentClientView];
[content didMoveToParentViewController:self];
}

同样,移除 ViewController 也不仅仅是 removeFromSuperview 方法调用。

1
2
3
4
5
- (void) hideContentController: (UIViewController*) content {
[content willMoveToParentViewController:nil];
[content.view removeFromSuperview];
[content removeFromParentViewController];
}

firstResponder

firstResponder 听起来像是通过 hit-testing 找到的 target-view。实际上,优先接收某些事件(例如键盘 KeyEvent、MotionEvent等)的响应者,称之为 firstResponder。而触摸事件中的 target-view,可能是 firstResponder,也可能不是。

最简单的例子就是,调用 UITextField 的 becomeFirstResponder 方法,可以激活键盘并输入文字。

参考

iOS开发 - 事件传递响应链 - CocoaChina_一站式开发者成长社区
Using Responders and the Responder Chain to Handle Events | Apple Developer Documentation
iOS - Difference between addChildViewController and addSubview? - Stack Overflow
View Controller Programming Guide for iOS: Implementing a Container View Controller
深入浅出iOS事件机制
Hit-Testing in iOS
UIKit: UIResponder | 南峰子的技术博客
Responder object

相关文章

iOS教程(二)消息发送
iOS教程(一)iOS内存管理