响应者链 & 传递链

1.响应者

UIResponder,用来响应用户的操作并处理各种事件:

  • 触摸事件
  • 加速计事件
  • 远程控制事件

继承自UIResponder的类对象,如UIViewUIViewControllerUIApplication,才能接收和处理事件,它们被称为“响应者对象”。UIResponder.h中定义了如下处理方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
//处理触摸事件
- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event;
- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event;
- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event;
- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)ev

//加速计事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent*)event;
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent*)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent*)event;

//远程控制事件
- (void)remoteControlReceivedWithEvent:(UIEvent*)event;

2.响应者链

下面是在自定义视图CustomView-hitTest:withEvent:方法中断点后,随机在界面中点击后得到的堆栈信息:

chain

调用顺序为底部先顶部后,大致经过了main->UIApplication->UIWindow->UIView

用户在屏幕上做点击、缩放等操作时会触发触摸事件。UIKit会创建一个UIEvent事件对象并将其添加到事件队列中,后续由 UIApplication 负责事件的分发和传递,直到找到其最佳的响应者。

查找事件响应者的过程就是Hit-Testing的过程,被检测者需要做两个判断:

  • 自己是否能接收事件?
  • 触摸点是否在自己身上?

具体查找过程如下:

  1. UIApplication 从事件队列中取出最前面的事件,传递给主窗口(keyWindow);
  2. 主窗口检测:①自己是否能接收事件;②触摸点是否在自己身上;
  3. 若满足此两个条件,再倒序遍历主窗口的子视图数组,对子视图重复以上两个判断;
  4. 若主窗口的子视图中没有符合条件的,则主窗口成为触摸事件的最佳接收者;
  5. 若主窗口的子视图A符合条件,则倒序遍历A的子视图,并重复上面的两个判断;
  6. A的子视图中没有符合条件的,则A成为最佳接收视图;
  7. A的子视图中有符合条件的X,则继续遍历X的子视图,直到找到最合适的视图;

不能接收事件的情况

  • userInteractionEnabled = NO;
  • hidden = YES; (注:父视图隐藏时,子视图也会隐藏,不能接收事件)
  • alpha < 0.01;

触摸点范围检测

1
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

返回YES,则触摸点在当前 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
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 1.判断下窗口能否接收事件
if (!self.userInteractionEnabled ||
self.hidden == YES ||
self.alpha <= 0.01){
return nil;
}
// 2.判断触摸点在不在当前视图上
if (![self pointInside:point withEvent:event]){
return nil; //nil,表示不在当前视图上,无法响应
}

// 3.如果触摸点在当前视图范围内,则继续从后往前遍历子视图数组
int count = (int)self.subviews.count;

for (int i = count - 1; i >= 0; i--) {
// 获取子视图
UIView *childView = self.subviews[i];
// 坐标系的转换,把当前视图上的点转换为子视图上的点
CGPoint childP = [self convertPoint:point toView:childView];
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView) { //不为空,说明触摸点在子视图上
return fitView; //返回子视图,事件交由子视图接收
}
}
// 4.没有找到更合适的view,返回self,事件由自己接收
return self;
}

触摸事件传递给视图A后,会触发A的hitTest:方法。

若此方法返回 nil,则A和其子视图都不是响应者,响应为A的父视图。

响应者链是从父视图到子视图,若父视图不能响应事件,则子视图也就不能响应。

找到最合适的视图后会触发视图的-touches方法,开始向上(从子视图到父视图)传递事件。

2.1.应用-事件的拦截

正因为hitTest:可以返回响应者视图,所以想让谁成为响应者就重写谁父视图的hitTest:方法返回指定的子视图;或者重写自己的hitTest:方法并返回self

一般来说,建议在父视图的hitTest:中返回子视图作为最佳响应者!

案例1-指定响应视图

默认情况下 label 的userInteractionEnabled为 NO,即 label 不能接收触摸事件,接下来的示例还是以3.2小节中的树形结构为例,要让 label 响应触摸事件。可能你会觉得这个功能有点鸡肋,是的,这里我只是想演示一下如何让指定视图响应触摸或点击事件。

思路:自定义蓝色视图和label,重写蓝色视图的hitTest方法,当触摸点在其子视图 label 的范围内时,返回此 label,这样触摸事件就转发给 label 并触发 label 的-touch: 系列方法。

  • 蓝色视图 ASDFBlueView:
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
43
#import "ASDFBlueView.h"

@implementation ASDFBlueView

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
// 1.判断下窗口能否接收事件
if (!self.userInteractionEnabled ||
self.hidden == YES ||
self.alpha <= 0.01){
return nil;
}
// 2.判断触摸点在不在窗口上
if (![self pointInside:point withEvent:event]){
return nil;
}
// 3.从后往前遍历子视图数组
int count = (int)self.subviews.count;

for (int i = count - 1; i >= 0; i--) {
// 获取子视图
UIView *childView = self.subviews[i];
// 坐标系的转换,把窗口上的点转换为子视图上的点
// 把自己视图上的点转换成子视图上的点
CGPoint childP = [self convertPoint:point toView:childView];

//label的interaction enable默认被关闭,这里让label响应触摸事件
if ([childView isKindOfClass:NSClassFromString(@"ASDFResponsibleLabel")] &&
[childView pointInside:childP withEvent:event]) {
return childView;
}
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView) {
return fitView;
}
}
// 4.没有找到更合适的view,返回自己
return self;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"++Touched ASDFBlueView~");
}
@end
  • 自定义的 ASDFResponsibleLabel
1
2
3
4
5
6
7
8
#import "ASDFBlueView.h"

@implementation ASDFBlueView

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"++Touched ASDFBlueView~");
}
@end
  • ViewController中重写touch方法
1
2
3
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"++Touch began in ViewController~");
}

测试结果:

  • 如果只点击橙色或白色区域,则只会触发ViewController中的-touch:方法;
  • 如果点击 label 以外的蓝色区域,则只会触发ASDFBlueView中的-touch:方法;
  • 如果点击 label,则 label 会响应点击并触发其-touch:方法;

延伸

1、按照上面的思路,如果点击任何地方都只让当前ViewController响应事件,则重写其所有子视图的-hitTest方法并都返回nil即可;

2、如果既想 label 响应事件,又想蓝色视图响应事件,只需在 label 的-touch:方法中调用[super touch..]即可。

案例2-抢红包

需求:红包按照某种轨迹飘落,点到飘落中的红包才算抢到。

思路分析:

  • 飘落中的红包实则是一个执行了position动画的视图;
  • 对于静止的红包,判断点击是否命中时只需在响应链将事件传递到红包视图时,判断触摸点是否在视图中即可;
  • 但红包视图在执行位移动画,其实际位置在动画过程中并未真正改变,对动画中的红包判断触摸点并不可行;
  • 红包视图的图层树中,presentationLayer保存了动画时 layer 的位置信息,只需判断触摸点是否在此图层中即可~

步骤1:自定义红包视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#import "AnimateClickedView.h"
@implementation AnimateClickedView

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
//转换point到presentationLayer的坐标系中
CGPoint convertedPoint = [self.layer convertPoint:point toLayer:self.layer.presentationLayer];

//判断点击是否在运动中的红包范围内
if ([self.layer.presentationLayer containsPoint:convertedPoint]) {
NSLog(@"++++++恭喜您中奖了!");
return self;
}else{
NSLog(@"没点中红包,继续加油哦~");
return nil;
}
}
@end

步骤2:将红包视图添加到界面中并执行动画

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
43
44
45
#import "AnimateClickedView.h"
@interface ViewController ()
@property (nonatomic, strong) AnimateClickedView *animateView;
@end

@implementation ViewController
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];

//添加视图
_animateView = [[AnimateClickedView alloc] init];
_animateView.backgroundColor = [UIColor redColor];
float size = 50;
uint32_t originX = arc4random() % (int)(self.view.frame.size.width - size);//视图x起点为随机数
_animateView.frame = CGRectMake(originX, -size, size, size);
[self.view addSubview:_animateView];
}

//模拟开始动画
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
if (aInt != 0) {
return;
}
aInt +=1;

//创建路径
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:_animateView.center];//动画路径的起点为视图中心点
//路径中的控制点随机
CGFloat height = CGRectGetHeight(self.view.frame);
[path addCurveToPoint:CGPointMake(_animateView.frame.origin.x, height)
controlPoint1:CGPointMake(0, arc4random() % (int)(height / 4.0))
controlPoint2:CGPointMake(CGRectGetWidth(self.view.frame), height - 200)];
//创建动画
CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
animation.path = path.CGPath;
animation.duration = 5;
animation.autoreverses = NO;
animation.removedOnCompletion = NO;
animation.fillMode = kCAFillModeForwards;
//执行动画
[_animateView.layer addAnimation:animation forKey:@"HAPPY_NEW_YEAR_ANIMATION"];
}
@end

步骤3:点击界面中的红包,查看日志信息~

3.事件的传递

事件的响应者确定后,其内部的-touches系列方法会自动触发并开始处理事件:

1
2
3
4
5
6
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
// 默认会把事件传递给下一个响应者,下一个响应者是父视图,交给父视图处理
[super touchesBegan:touches withEvent:event];
// 注意不是调用父视图的touches方法,而是调用父类的touches方法
}

如果视图实现了touches系列方法,则事件将由该视图来处理;

如果调用了[super touches….],则事件还将传递给其下一个响应者;

下一个响应者触发其touches系列方法(自己处理事件或者接着向上传递);

如此循环下去就构成了事件的传递链

3.1. nextResponder

1
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

这是UIResponder中的属性,用来查找下一个响应者。事件从第一个响应者开始传递,直到被某个响应者处理时事件才会停止传递;如果事件传到最后都没有被响应,则该事件就被丢弃。

事件传递的过程:

  1. 若当前view是控制器的self.view,则控制器就是下一个响应者,事件传递给控制器响应;
  2. 若当前view不是控制器的self.view,则view的父视图就是下一个响应者,事件就传递给它的父视图响应;
  3. 在视图层次结构的最顶级视图,如果也不能处理该事件,则该事件会被传递给window对象;
  4. window对象也不处理,则事件会传递给UIApplication对象;
  5. UIApplication也不能处理该事件,则将其丢弃,本次点击毫无反应。

小结:事件是从子视图往父视图传递。

#示例:

ViewControllerself.view(橙色)中有个白色圆角的视图(UIViewInspectable),圆角视图内有个自定义的蓝色视图(ASDFBlueView),蓝色视图中有个label,如下:

responsiblelabel

结构图:

1
2
3
4
5
ViewController
|--self.view
|--圆角的视图
|--蓝色视图
|--label

下面是根据响应者链查找Label所属ViewController的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (UIViewController *)findSuperControllerForView:(UIView *)view
{
UIViewController *resultController;

for (UIView *next = view; next; next = [next superview]) {
UIResponder *responder = [next nextResponder];
NSLog(@"++++Class:%@",[responder class]);
if ([responder isKindOfClass:[UIViewController class]]) {
resultController = (UIViewController*)responder;
}
}
NSLog(@"++++Equal Self?:%@",[resultController isEqual:self] ? @"YES" : @"NO");
return resultController;
}

输出日志:

1
2
3
4
5
6
++++Class:ASDFBlueView
++++Class:UIViewInspectable
++++Class:UIView
++++Class:ViewController
++++Class:UIApplication
++++Equal Self?:YES

日志展示了响应者的查找过程,从子视图一直向父视图和UIWindowUIApplication查找。最终找到label所属的控制器,即当前ViewController

3.2.应用-拖拽视图

自定义一个view,并重写下面方法:

1
2
3
4
5
6
7
8
9
10
11
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];

CGPoint curP = [touch locationInView:self];
CGPoint preP = [touch previousLocationInView:self];
CGFloat offsetX = curP.x - preP.x;
CGFloat offsetY = curP.y - preP.y;

self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}

4.小结

  • 事件的响应者链是从上到下(父视图到子视图,子视图数组内从后往前)。
  • 事件的传递链是从下到上(顺着响应者链条向上传递:子视图到父视图)。

响应者链 & 传递链
https://davidlii.cn/2018/01/07/responder.html
作者
Davidli
发布于
2018年1月7日
许可协议