之前面试问到一个响应者链的问题,结果让我很尴尬。于是,就想着写篇关于响应链的总结。当然,响应者链也包含事件、响应者的知识点,所以就一起总结复习一下。
一. 事件(UIEvent)
一个UIEvent对象代表iOS中的事件(简单理解,事件就是用户对设备的操作)。事件分为三类:触摸事件、晃动事件、远程控制事件(比如耳机按钮操控)。
要学习UIEvent之前,我们先来简单了解一下UITouch(你可以理解为触碰点)。
当用户用一根手指触摸屏幕时,会创建一个与手指相关联的UITouch对象,一根手指对应一个UITouch对象。UITouch的作用是保存着跟手指相关的信息,比如触摸的位置、时间、阶段。当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指在的触摸位置,当手指离开屏幕时,系统会销毁相应的UITouch对象。
UITouch常用属性和方法有:
@property(nonatomic,readonly) NSTimeInterval timestamp;//记录了触摸事件产生或变化时的时间
@property(nonatomic,readonly) UITouchPhase phase;//当前触摸事件所处的状态
@property(nonatomic,readonly) NSUInteger tapCount; // 短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或更多的点击
@property(nullable,nonatomic,readonly,strong) UIWindow *window;//触摸产生时所处的窗口
@property(nullable,nonatomic,readonly,strong) UIView *view;//触摸产生时所处的视图
//触碰点在所处view的位置(以view的左上角为原点(0, 0))
//调用时传入的view参数为nil的话,返回的是触摸点在UIWindow的位置
- (CGPoint)locationInView:(nullable UIView *)view;
//取得移动的前一个位置
- (CGPoint)previousLocationInView:(nullable UIView *)view;
以常见的触摸事件为例:
一个触摸事件包含一个或者多个手指,每个手指是一个UITouch对象;每产生一个事件,就会产生一个UIEvent对象,用于记录事件产生的时刻和类型。
一次完整的触摸过程,会经历3个状态(开始,移动,结束。也可能会有取消)
- (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 *)event;
4个触摸事件处理方法中,都有touches和event两个参数。一次完整的触摸过程中,只会产生一个事件对象,所以4个触摸方法都是同一个event参数。如果两根手指同时触摸一个view,那么view只会调用一次touchesBegan:withEvent:方法,touches参数中装着2个UITouch对象。简单点说,一次触摸事件就是一个event,touches的数量取决于你用了几个手指。
同样的,我们也看看UIEvent常用属性和方法,最主要的还是关注里面的touches:
@property(nonatomic,readonly) UIEventType type;
@property(nonatomic,readonly) UIEventSubtype subtype;
@property(nonatomic,readonly) NSTimeInterval timestamp;
@property(nonatomic, readonly, nullable) NSSet *allTouches;
- (nullable NSSet *)allTouches;
- (nullable NSSet *)touchesForWindow:(UIWindow *)window;
- (nullable NSSet *)touchesForView:(UIView *)view;
二. 响应链(UIResponder Chain)
在讲响应链之前,我们先来讲讲事件的处理机制(事件的传递)。
有这么一个问题:当我们触碰屏幕时,会产生一个事件(UIEvent),系统是怎么找到查找事件触发者?(或者这么想:当我们触碰屏幕时,程序是怎么知道我们在触碰哪一个控件的?),这就涉及到事件的分发。
事件的传递涉及到了UIView中的两个方法:
//询问当前点击事件最优响应者是谁(nil为没有最优响应者)
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
//判断当前点击是否在控件的Bounds之内(用来判断某个view能不能成为最优响应者)
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
在iOS中发生触摸后,事件会加入UIApplication事件队列,UIApplication会从事件队列取出最前面的事件并分发处理,通常,先发送事件给应用程序的主窗口(UIWindow),主窗口会调用hitTest:withEvent:
方法在视图(UIView)层次结构中找到一个最合适的UIView(事件触发视图)来处理触摸事件。
具体步骤如下:
① 在顶级视图(key window的视图)上调用pointInside:withEvent:
方法判断触摸点是否在当前视图内。
② 如果返回NO,那么hitTest:withEvent:
返回nil(说明这个点都不在我的视图里,我这边肯定没有你的最优响应者)。
③ 如果返回YES,那么它会向当前视图的所有子视图(key window的子视图)发送hitTest:withEvent:
消息(我这边有最优响应者,要嘛是我,要嘛是我的子视图,我再帮你找找)。遍历所有子视图的顺序是从subviews数组的末尾向前遍历(从界面最上方开始向下遍历)。
④ 如果subview(没有子视图了)的hitTest:withEvent:
返回非空对象则顶级视图的hitTest:withEvent:
也返回此对象,处理结束(注意所有视图的hitTest:withEvent:
都是根据pointInside:withEvent:的返回值来确定是返回空还是当前子视图对象的。如果该视图的hidden=YES
、userInteractionEnabled=NO
或者alpha<0.1
都会直接返回nil)。
⑤ 如果所有subview遍历结束仍然没有返回非空对象,则顶级视图的hitTest:withEvent:
返回它自己(儿子都不是最优响应视图,只能我这当爹的来了)。
具体的一个伪代码就是如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//先判断alpha,userInteractionEnabled,hidden
if (self.alpha < 0.01 || !self.userInteractionEnabled || self.hidden) {
return nil;
}
//再判断是不是在我的范围内
if (![self pointInside:point withEvent:event]) {
return nil;
}
//在我的范围内,我就问我的儿子,有一个儿子回答是它,我就返回它,如果都没人回答,我就回答是我了。
__block UIView *hitView = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) {
hitView = [subview hitTest:point withEvent:event];
if (hitView) {
*stop = YES;
}
}];
return hitView ? : self;
}
知道事件怎么传递的,那再让我们来探探我们的重点:响应者链
我们知道在iOS程序中无论是最后面的UIWindow还是最前面的某个按钮,它们的摆放是有前后关系的,一个控件可以放到另一个控件上面或下面,那么用户点击某个控件时是触发上面的控件还是下面的控件呢,这种先后关系构成一个链条就叫“响应者链”。
当然,在iOS中不是所有的对象都能处理事件,只有继承了UIResponder的对象才能接收并处理事件,称之为响应者对象,比如UIApplication、UIViewController、UIView都继承自UIResponder。
之前提到的事件的传递,其实就是在找事件触发者的过程。但是事件触发者(触摸对象)并非就是事件的响应者。
比如这么一个例子:在视图控制器放一个UIImageView,通过touchesMoved:
来控制imageView的位置。
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{
//取得一个触摸对象(对于多点触摸可能有多个对象)
UITouch *touch=[touches anyObject];
//NSLog(@"%@",touch);
//取得当前位置
CGPoint current=[touch locationInView:self.view];
//取得前一个位置
CGPoint previous=[touch previousLocationInView:self.view];
//移动前的中点位置
CGPoint center=_image.center;
//移动偏移量
CGPoint offset=CGPointMake(current.x-previous.x, current.y-previous.y);
//重新设置新位置
_image.center=CGPointMake(center.x+offset.x, center.y+offset.y);
}
你会发现我们即使在imageView上移动,也会执行视图控制器的touchesMoved:
,这也意味着这时的响应者是视图控制器,而不是触摸对象imageView。这也说明了触摸对象imageView不自己处理事件,把它转移给视图控制器。为什么会这样呢?
其实,当某个视图的属性满足这样条件时,意味着它不处理事件,会把事件转移给响应者链的下一个去处理。对于视图控制器这种,你没有实现开始触摸方法,就意味着你不处理事件。
- userInteractionEnabled = NO
- hidden = YES
- alpha = 0~0.01
- 没有实现开始触摸方法(只针对视图控制器这种类型的响应者)
例子中的imageView的userInteractionEnabled默认为NO,所以会把事件转移给响应者链的下一个(self.view),self.view的userInteractionEnabled默认为NO,也不处理事件,就继续转移给下一个(ViewController),刚好ViewController实现了触摸方法,可以处理事件。要是ViewController也没用实现了触摸方法的话,就会继续传递下去。
要是例子中的imageView换成button就不一样了,button的userInteractionEnabled默认为YES,自己就能处理事件,就没有ViewController什么事了(也就是touchesMoved:
不会执行了)。
所以,一个完整的流程是这样的:
① 当一个事件发生后首先看initial view(触摸对象)能否处理这个事件,如果不能则会将事件传递给其上级视图(inital view的superView)。
② 如果上级视图仍然无法处理则会继续往上传递;一直传递到视图控制器view controller,首先判断视图控制器的根视图view是否能处理此事件;如果不能则接着判断该视图控制器能否处理此事件,如果还是不能则继续向上传递。(对于视图控制器本身还在另一个视图控制器中,则继续交给父视图控制器的根视图,如果根视图不能处理则交给父视图控制器处理)。
③ 一直到window,如果window还是不能处理此事件则继续交给application(UIApplication单例对象)处理,如果最后application还是不能处理此事件则将其丢弃。
三. 总结
以常见的触摸操作来说,我们来串一下:
事件
我们主要知道事件(UIEvent)就是保存这次触摸的信息(时间,手指,类型等),当然手指的信息(位置,所处视图,点击次数等)是UITouch来保存。一次触摸从开始到结束就是一个事件,不管你用几个手指。
响应者链
触碰操作产生一个事件,事件是通过UIApplication分发找到对应的最优响应者(触摸对象)。找到归找到,又不一定是这个触摸对象来处理事件,要是你不能处理,你就交给你的上级(响应者链的上一级),直到有人处理,或者都没人处理。