概述
iOS响应者链(Responder Chain)是支撑App界面交互的重要基础,点击、滑动、旋转、摇晃等都离不开其背后的响应者链,所以每个iOS开发人员都应该彻底掌握响应者链的响应逻辑,本文旨在通过demo测试的方式展现响应者链的具体响应过程,帮助读者彻底掌握响应者链。
Demo
你可以在这里(GitHub地址)下载本文测试的Demo源码,阅读本文的同时结合Demo程序有助于更加直观深刻的理解。
探究过程
响应者(Responder)
当我们触控手机屏幕时系统便会将这一操作封装成一个UIEvent放到事件队列里面,然后Application从事件队列取出这个事件,接着需要找到去响应这个事件的最佳视图也就是Responder, 所以开始的第一步应该是找到Responder, 那么又是如何找到的呢?那就不得不引出UIView的2个方法:
- -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
返回视图层级中能响应触控点的最深视图 - -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
返回视图是否包含指定的某个点
通过在显示视图层级中依次对视图调用这个2个方法来确认该视图是不是能响应这个点击的点,首先会调用hitTest,然后hitTest会调用pointInside,最终hitTest返回的那个view就是最终的响应者Responder, 那么问题来了,在视图层级中是如何确定该对哪个View调用呢?优先级又是什么?
为了探寻其中的逻辑,在Demo中我们构建了一个如下图所示的多重视图:
这是一个简单的控制器视图,在Controller的视图上添加了View1-View4共4个视图,View1-View4和RootView都继承自BaseView, BaseView继承自UIView; 其中 View1、View2是RootView的子视图,View3、View4是View2的子视图,他们的继承关系和父子关系图下图:
为了能观测到UIView的hitTest和pointInside调用过程,我们写个分类通过方法交换来打印调用的日志:
@implementation UIView (DandJ)
+ (void)load {
Method origin = class_getInstanceMethod([UIView class], @selector(hitTest:withEvent:));
Method custom = class_getInstanceMethod([UIView class], @selector(dandJ_hitTest:withEvent:));
method_exchangeImplementations(origin, custom);
origin = class_getInstanceMethod([UIView class], @selector(pointInside:withEvent:));
custom = class_getInstanceMethod([UIView class], @selector(dandJ_pointInside:withEvent:));
method_exchangeImplementations(origin, custom);
}
- (UIView *)dandJ_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"%@ hitTest", NSStringFromClass([self class]));
UIView *result = [self dandJ_hitTest:point withEvent:event];
NSLog(@"%@ hitTest return: %@", NSStringFromClass([self class]), NSStringFromClass([result class]));
return result;
}
- (BOOL)dandJ_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"%@ pointInside", NSStringFromClass([self class]));
BOOL result = [self dandJ_pointInside:point withEvent:event];
NSLog(@"%@ pointInside return: %@", NSStringFromClass([self class]), result ? @"YES":@"NO");
return result;
}
@end
当我们点击视图中的View3(紫色)时看看日志输出:
从日志中我们可以看到,首先是从UIWindow开始调用hitTest, 然后经过一段导航控制器的视图,因为我们的控制器是在导航控制的,所以可以先忽略这一段,然后来到RootView,调用RootView的hitTest和pointInside,因为点击发生在RootView中所以继续遍历它的子视图,可以看到是从View2开始的,调用View2的hitTest和pointInside,pointInside返回YES,然后继续遍历View2的子视图,从View4开始,因为点击不发生在View4所以pointInside返回NO,而View4没有子视图了,所以返回了nil也就是打印出来的null,然后继续在View2的另外一个子视图View3(目标视图)中调用hitTest和pointInside,因为我们点击的就是View3所以pointInside返回YES,且View3没有子视图所以hitTest返回了自己View3,接着View2的hitTest也返回View3直到UIWindow返回View3, 自此我们找到了响应视图:View3!另外我们看到对其他的Window也有调用,只不过返回了nil。
- 结论:
- 寻找事件的最佳响应视图是通过对视图调用hitTest和pointInside完成的
- hitTest的调用顺序是从UIWindow开始,对视图的每个子视图依次调用,子视图的调用顺序是从后面往前面,也可以说是从显示最上面到最下面
- 遍历直到找到响应视图,然后逐级返回最终到UIWindow返回此视图
PS:
1.关于最后一个能响应的子视图demo中是因为没有子视图而确定的,这不是唯一确定的条件,因为有些情况下视图可能会被忽略,不会调用hitTest,这与userInteractionEnabled, alpha, frame等有关,在下个demo会演示。
2.与加速度器、陀螺仪、磁力仪相关的运动事件不遵循此响应链,他们是由Core Motion 直接派发的
处理者
在上面我们已经找到了点击事件的响应者View3,但是我们并未给View3添加相应的点击处理逻辑(UITapGestureRecognizer),所以View3并不会处理事件,那么View3不处理由会交给谁处理呢?如果View3处理了又是怎么样的呢?
能够处理UI事件都是继承UIResponder的子类对象,UIResponder主要有以下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;
分别是对应从触摸事件的开始、移动、结束、取消,如果你想自定义响应事件可以重写这几个方法来实现。如果某个Responder没处理事件,事件会被传递,UIResponder都有一个nextResponder属性,此属性会返回在Responder Chain中的下一个事件处理者,如果每个Responder都不处理事件,那么事件将会被丢弃。所以继承自UIResponder的子类便会构成一条响应者链,所以我们可以打印下以View3为开始的响应者链是什么样的:
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
UIResponder *nextResponder = self.view3.nextResponder;
NSMutableString *pre = [NSMutableString stringWithString:@"--"];
NSLog(@"View3");
while (nextResponder) {
NSLog(@"%@%@", pre, NSStringFromClass([nextResponder class]));
[pre appendString:@"--"];
nextResponder = nextResponder.nextResponder;
}
}
可以看到响应者链一直延伸到AppDelegate, View3的下一个是View2也就是View3的父视图,View2下一个是RootView也是父视图,而RootView的下一个则是Controller, 所以下一个响应者的规则是如果有父视图则nextResponder指向父视图,如果是控制器根视图则指向控制器,控制器如果在导航控制器中则指向导航控制器的相关显示视图最后指向导航控制器,如果是根控制器则指向UIWindow,UIWindow的nexResponder指向UIApplication最后指向AppDelegate,而他们实现这一套指向都是靠重写nextReponder实现的。
为了验证点击上面的事件的处理顺序,我们继续上面那个demo,为RootView和View1-View4的基类BaseView重写这几个方法:
@implementation BaseView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ touchesBegan", NSStringFromClass([self class]));
[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ touchesMoved", NSStringFromClass([self class]));
[super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ touchesEnded", NSStringFromClass([self class]));
[super touchesEnded:touches withEvent:event];
}
@end
同样也为控制器(FindResponderController)添加相关touches方法,日志打印看调用顺序:
可以看到先是由UIWindow通过hitTest返回所找到的最合适的响应者View3, 接着执行了View3的touchesBegan,然后是通过nextResponder依次是View2、RootView、FindResponderController,可以看到完全是按照nextResponder链条的调用顺序,touchesEnded也是同样的顺序。
PS:感兴趣的可以继续重写AppDelegate的相关touches方法,验证最终是不是会被顺序调用。
上面是View3不处理点击事件的情况,接下来我们为View3添加一个点击事件处理,看看又会是什么样的调用过程:
@implementation View3
- (void)awakeFromNib {
[super awakeFromNib];
[self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAction:)]];
}
- (void)tapAction:(UITapGestureRecognizer *)recognizer {
NSLog(@"View3 taped");
}
@end
运行程序,点击View3看看日志打印:
可以看到touchesBegan顺着nextResponder链条调用了,但是View3处理了事件,去执行了相关是事件处理方法,而touchesEnded并没有得到调用。
总结
1.找到最适合的响应视图后事件会从此视图开始沿着响应链nextResponder传递,直到找到处理事件的视图,如果没有处理的事件会被丢弃。
2.如果视图有父视图则nextResponder指向父视图,如果是根视图则指向控制器,最终指向AppDelegate, 他们都是通过重写nextResponder来实现。
无法响应的情况
在[响应者]章节我们已经提到寻找最佳响应者是通过hitTest函数调用完成的,那么存在哪些情况下视图会被忽视,而不被调用hiTest呢?
下面我么也通过第2个demo来演示,在什么情况下hitTest不会被调用或者返回nil,在demo中从上到下我们分别模拟了Alpha=0、子视图超出父视图的情况、userInteractionEnabled=NO、hidden=YES这4中情况:
结论
1.Alpha=0、子视图超出父视图的情况、userInteractionEnabled=NO、hidden=YES视图会被忽略,不会调用hitTest
2.父视图被忽略后其所有子视图也会被忽略,所以View3上的button不会有点击反应
3.出现视图无法响应的情况,可以考虑上诉情况来排查问题
应用示例
点击透传
RootView有2个重叠在一起的子视图View1和View2, View2覆盖在View1上面,如何做到点击View1触发View2的处理逻辑?
很简单,设置View2的userInteractionEnabled=NO即可。
限定点击区域
给定一个显示为圆形的视图,实现只有在点击区域在圆形里面才视为有效。
我们可以重写View的pointInside方法来判断点击的点是否在圆内,也就是判断点击的点到圆心的距离是否小于等于半径就可以。
@implementation CircleView
- (void)awakeFromNib {
[super awakeFromNib];
self.layer.cornerRadius = self.frame.size.width / 2.0f;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
const CGFloat radius = self.frame.size.width / 2.0f;
CGFloat xOffset = point.x - radius;
CGFloat yOffset = point.y - radius;
CGFloat distance = sqrt(xOffset * xOffset + yOffset * yOffset);
return distance <= radius;
}
@end
个人理解与总结
1、概述
首先,当发生事件响应时,必须知道由谁来响应事件。在IOS中,由响应者链来对事件进行响应,所有事件响应的类都是UIResponder的子类,响应者链是一个由不同对象组成的层次结构,其中的每个对象将依次获得响应事件消息的机会。当发生事件时,事件首先被发送给第一响应者,第一响应者往往是事件发生的视图,也就是用户触摸屏幕的地方。事件将沿着响应者链一直向下传递,直到被接受并做出处理。一般来说,第一响应者是个视图对象或者其子类对象,当其被触摸后事件被交由它处理,如果它不处理,事件就会被传递给它的视图控制器对象viewcontroller(如果存在),然后是它的父视图(superview)对象(如果存在),以此类推,直到顶层视图。接下来会沿着顶层视图(top view)到窗口(UIWindow对象)再到程序(UIApplication对象)。如果整个过程都没有响应这个事件,该事件就被丢弃。一般情况下,在响应者链中只要由对象处理事件,事件就停止传递。
2、响应者链(Responder Chain)
响应者链有以下特点:
1、响应者链通常是由视图(UIView)构成的;
2、一个视图的下一个响应者是它视图控制器(UIViewController)(如果有的话),然后再转给它的父视图(Super View);
3、视图控制器(如果有的话)的下一个响应者为其管理的视图的父视图;
4、单例的窗口(UIWindow)的内容视图将指向窗口本身作为它的下一个响应者
需要指出的是,Cocoa Touch应用不像Cocoa应用,它只有一个UIWindow对象,因此整个响应者链要简单一点;
5、单例的应用(UIApplication)是一个响应者链的终点,它的下一个响应者指向nil,以结束整个循环。
3、事件分发(Event Delivery)
第一响应者(First responder)指的是当前接受触摸的响应者对象(通常是一个UIView对象),即表示当前该对象正在与用户交互,它是响应者链的开端。整个响应者链和事件分发的使命都是找出第一响应者。
UIWindow对象以消息的形式将事件发送给第一响应者,使其有机会首先处理事件。如果第一响应者没有进行处理,系统就将事件(通过消息)传递给响应者链中的下一个响应者,看看它是否可以进行处理。
iOS系统检测到手指触摸(Touch)操作时会将其打包成一个UIEvent对象,并放入当前活动Application的事件队列,单例的UIApplication会从事件队列中取出触摸事件并传递给单例的UIWindow来处理,UIWindow对象首先会使用hitTest:withEvent:方法寻找此次Touch操作初始点所在的视图(View),即需要将触摸事件传递给其处理的视图,这个过程称之为hit-test view。
UIWindow实例对象会首先在它的内容视图上调用hitTest:withEvent:,此方法会在其视图层级结构中的每个视图上调用pointInside:withEvent:(该方法用来判断点击事件发生的位置是否处于当前视图范围内,以确定用户是不是点击了当前视图),如果pointInside:withEvent:返回YES,则继续逐级调用,直到找到touch操作发生的位置,这个视图也就是要找的hit-test view。
hitTest:withEvent:方法的处理流程如下:
首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内;
若返回NO,则hitTest:withEvent:返回nil;
若返回YES,则向当前视图的所有子视图(subviews)发送hitTest:withEvent:消息,所有子视图的遍历顺序是从最顶层视图一直到到最底层视图,即从subviews数组的末尾向前遍历,直到有子视图返回非空对象或者全部子视图遍历完毕;
若第一次有子视图返回非空对象,则hitTest:withEvent:方法返回此对象,处理结束;
如所有子视图都返回非,则hitTest:withEvent:方法返回自身(self)。
4、说明
1、响应者链的传递顺序是从子类逐级到父类的传递方向,事件分发的顺序是从父类逐级到子类的顺序。
2、如果最终hit-test没有找到第一响应者,或者第一响应者没有处理该事件,则该事件会沿着响应者链向上回溯,如果UIWindow实例和UIApplication实例都不能处理该事件,则该事件会被丢弃;
3、hitTest:withEvent:方法将会忽略隐藏(hidden=YES)的视图,禁止用户操作(userInteractionEnabled=YES)的视图,以及alpha级别小于0.01(alpha<0.01)的视图。如果一个子视图的区域超过父视图的bound区域(父视图的clipsToBounds 属性为NO,这样超过父视图bound区域的子视图内容也会显示),那么正常情况下对子视图在父视图之外区域的触摸操作不会被识别,因为父视图的pointInside:withEvent:方法会返回NO,这样就不会继续向下遍历子视图了。当然,也可以重写pointInside:withEvent:方法来处理这种情况。
4、我们可以重写hitTest:withEvent:来达到某些特定的目的,实际应用中很少用到这些。
以上内容参考作者原文地址:原文地址