iOS 事件的传递与响应是一个重要的话题,网上谈论的很多,但大多讲述并不完整,本文将结合苹果官方的文档对事件的传递与响应原理及应用实践做一个比较完整的总结.文章将依次介绍下列内容:
事件的传递机制
事件的响应机制
事件传递与响应实践
手势识别器工作机制
标准控件的事件处理
iOS 中事件一共有四种类型,包含触摸事件,运动事件,远程控制事件,按压事件,本文将只讨论最常用的触摸事件.事件通过 UIEvent 对象描述
UIEvent
UIEvent 描述了单次的用户与应用的交互行为,例如触摸屏幕会产生触摸事件,晃动手机会产生运动事件.UIEvent 对象中记录了事件发生的时间,类型,对于触摸事件,还记录了一组 UITouch 对象,下面是 UIEvent 的几个属性:
那么触摸事件中的 UITouch 对象描述的是什么呢?
@property(nonatomic,readonly) UIEventType type NS_AVAILABLE_IOS(3_0); // 事件的类型
@property(nonatomic,readonly) UIEventSubtype subtype NS_AVAILABLE_IOS(3_0);
@property(nonatomic,readonly) NSTimeInterval timestamp; // 事件的时间
@property(nonatomic, readonly, nullable) NSSet <UITouch *> *allTouches; // 事件包含的 touch 对象
UITouch
UITouch 记录了手指在屏幕上触摸时产生的一组信息,包含触摸的时间,位置,所在的窗口或视图,触摸的状态,力度等信息
每一根手指的触摸都会产生一个 UITouch 对象,多个手指触摸便会有多个 UITouch 对象,当手指在屏幕上移动时,系统会更新 UITouch 的部分属性值,在触摸结束后系统会释放 UITouch 对象.
@property(nonatomic,readonly) NSTimeInterval timestamp; // 时间
@property(nonatomic,readonly) UITouchPhase phase; // 状态,例如 begin,move,end,cancel
@property(nonatomic,readonly) NSUInteger tapCount; // 短时间内单击的次数
@property(nonatomic,readonly) UITouchType type NS_AVAILABLE_IOS(9_0); // 类型
@property(nonatomic,readonly) CGFloat majorRadius NS_AVAILABLE_IOS(8_0); // 触摸半径
@property(nonatomic,readonly) CGFloat majorRadiusTolerance NS_AVAILABLE_IOS(8_0);
@property(nullable,nonatomic,readonly,strong) UIWindow *window; // 触摸所在窗口
@property(nullable,nonatomic,readonly,strong) UIView *view; // 触摸所在视图
@property(nullable,nonatomic,readonly,copy) NSArray <UIGestureRecognizer *> *gestureRecognizers NS_AVAILABLE_IOS(3_2); // 正在接收该触摸对象的手势识别器
@property(nonatomic,readonly) CGFloat force NS_AVAILABLE_IOS(9_0); // 触摸的力度
当事件产生后,系统会寻找可以响应该事件的对象来处理事件,如果找不到可以响应的对象,事件就会被丢弃.那么哪些对象可以响应事件呢?只有继承于 UIResponder 的对象才能够响应事件,UIApplication,UIView,UIViewcontroller 均继承于 UIResponder,因此它们均能够响应事件.UIResponder 提供了响应事件的一组方法:
如果我们想要对事件进行自定义的处理(比如手指在屏幕滑动时让某个 view 跟着移动),我们需要重写以上四个方法,对于 UIViewcontroller,我们只需要在 UIViewcontroller 中重写上面四个方法,对于 UIView,我们需要创建继承于 UIView 的子类,然后在子类中重写上面的方法,这点需要注意
- (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; // 触摸被中断,例如触摸时电话呼入
事件的传递
事件产生之后,会被加入到由 UIApplication 管理的事件队列里,接下来开始自 UIApplication 往下传递,首先会传递给主 window,然后按照 view 的层级结构一层层往下传递,一直找到最合适的 view(发生 touch 的那个 view)来处理事件.查找最合适的 view 的过程是一个递归的过程,其中涉及到两个重要的方法 hitTest:withEvent: 和
pointInside:withEvent:
当事件传递给某个 view 之后,会调用 view 的 hitTest:withEvent: 方法,该方法会递归查找 view 的所有子 view,其中是否有最合适的 view 来处理事件,整个流程如下所示:
hitTest:withEvent: 代码实现:
方法作用是判断点是否在视图内,是则返回 YES,否则返回 NO
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 首先判断是否可以接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
// 然后判断点是否在当前视图上
if ([self pointInside:point withEvent:event] == NO) return nil;
// 循环遍历所有子视图,查找是否有最合适的视图
for (NSInteger i = self.subviews.count - 1; i >= 0; i--) {
UIView *childView = self.subviews[i];
// 转换点到子视图坐标系上
CGPoint childPoint = [self convertPoint:point toView:childView];
// 递归查找是否存在最合适的 view
UIView *fitView = [childView hitTest:childPoint withEvent:event];
// 如果返回非空,说明子视图中找到了最合适的 view,那么返回它
if (fitView) {
return fitView;
}
}
// 循环结束,仍旧没有合适的子视图可以处理事件,那么就认为自己是最合适的 view
return self;
}
pointInside:withEvent:
判断一个 view 是否能够接收事件有三个条件,分别是,是否禁止用户交互(userInteractionEnabled = NO),是否被隐藏(hidden = YES)以及透明度是否小于等于 0.01(alpha <=0.01)
从递归的逻辑我们知道,如果触摸的点不在父 view 上,那么其上的所有子 view 的 hitTest 都不会被调用,需要指出的是,如果子 view 尺寸超出了父 view,并且属性 clipsToBounds 设置为 NO(也就是子 view 超出部分不被裁剪),触摸发生在子 view 超出父 view 的区域内,依旧不返回子 view.反过来,如果触摸的点在父 view 上并且父 view 就是最合适的 view,那么它的所有子 view 的 hitTest 还是会被调用,因为如果不调用就无法知道是否还有比父 view 更合适的子 view 存在.
事件的响应
在找到最合适的 view 之后,会调用 view 的 touches 方法对事件进行响应,如果没有重写 view 的 touches 方法,touches 默认的做法是将事件沿着响应者链往上抛,交给下一个响应者对象.也就是说,touches 方法默认不处理事件,只是将事件沿着响应者链往上传递.那么响应者链是什么呢?
响应者链
在应用程序中,视图放置都是有一定层次关系的,点击屏幕之后该由下方的哪个 view 来响应需要有一个判断的方式.响应者链是由一系列可以响应事件的对象(继承于 UIResponder)组成的,它决定了响应者对象响应事件的先后顺序关系.下图展示了 UIApplication,UIViewcontroller 以及 UIView 之间的响应关系链:
响应者链在递归查找最合适的 view 的时候形成,所找到的 view 将成为第一响应者,会调用它的 touches 方法来响应事件,touches 方法默认的处理是将事件往上抛给下一个响应者,而如果下一个响应者的 touches 方法没有重写,事件会继续沿着响应者链往上走,一直到 UIApplication,如果依旧不能处理事件那么事件就被丢弃.
UIView 如果 view 是 viewcontroller 的根 view,那么下一个响应者是 viewcontroller,否则是 super view
UIViewcontroller 如果 viewcontroller 的 view 是 window 的根 view,那么下一个响应者是 window;如果 viewcontroller 是另一个 viewcontroller 模态推出的,那么下一个响应者是另一个 viewcontroller;如果 viewcontroller 的 view 被 add 到另一个 viewcontroller 的根 view 上,那么下一个响应者是另一个 viewcontroller 的根 view
UIWindow UIWindow 的下一个响应者是 UIApplication
UIApplication 通常 UIApplication 是响应者链的顶端(如果 app delegate 也继承了 UIResponder,事件还会继续传给 app delegate)
事件传递与响应实践
首先我们通过代码创建一个具有层次结构的视图集合,在 viewcontroller 的 viewDidLoad 中添加如下代码:
执行后如下所示:
greenView *green = [[greenView alloc] initWithFrame:CGRectMake(50, 50, 300, 500)];
[self.view addSubview:green];
redView *red = [[redView alloc] initWithFrame:CGRectMake(0, 0, 200, 300)];
[green addSubview:red];
orangeView *orange = [[orangeView alloc] initWithFrame:CGRectMake(0, 350, 200, 100)];
[green addSubview:orange];
blueView *blue = [[blueView alloc] initWithFrame:CGRectMake(10, 10, 100, 100)];
[red addSubview:blue];
要实现我们自定义的事件处理逻辑,通常有两种方式,我们可以重写 hitTest:withEvent: 方法指定最合适处理事件的视图,即响应链的第一响应者,也可以通过重写 touches 方法来决定该由响应链上的谁来响应事件.
情景 1:点击黄色视图,红色视图响应
黄色视图和红色视图均为绿色视图的子视图,我们可以重写绿色视图的 hitTest:withEvent: 方法,在其中直接返回红色视图,代码示例如下:
我们这里是重写了父视图的 hitTest 方法,而不是重写红色视图的 hitTest 方法并让它返回自身,道理也很显然,在遍历绿色视图所有子视图的过程中,可能还没来得及调用到红色视图的 hitTest 方法时,就已经遍历到了触摸点真正所在的黄色视图,这个时候重写红色视图的 hitTest 方法是无效的.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
if ([self pointInside:point withEvent:event] == NO) return nil;
// 红色视图是先被 add 的,所以是第一个元素
return self.subviews[0];
}
情景 2:点击红色视图,绿色视图响应(也就是事件透传)
我们可以重写红色视图的 hitTest 方法,让其返回空,这时候便没有了合适的子视图来响应事件,父视图即绿色视图就成为了最合适的响应事件的视图,代码示例如下:
当然,我们也可以重写绿色视图的 hitTest 方法,让其直接返回自身,也能实现同样效果,不过这样的话点击其它子视图(比如黄色视图)就也不能响应事件了,因此如何处理需要视情况而定.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
return nil;
}
情景 3:点击红色视图,红色和绿色视图均做响应
我们知道,事件在不能被处理时,会沿着响应者链传递给下一个响应者,因此我们可以重写响应者对象的 touches 方法来实现让一个事件多个响应者对象响应的目的.因此我们可以通过重写红色视图的 touches 方法,先做自己的处理,然后在把事件传递给下一个响应者,代码示例如下:
需要说明的是,事件传递给下一个响应者时,用的是 super 而不是 superview,这并没有问题,因为 super 调用了父类的实现,而父类默认的实现就是调用下一个响应者的 touches 方法.如果直接调用 superview 反而会有问题,因为下一个响应者可能是 viewcontroller
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"red touches begin"); // 自己的处理
[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"red touches moved"); // 自己的处理
[super touchesBegan:touches withEvent:event];
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"red touches end"); // 自己的处理
[super touchesBegan:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"red touches canceled"); // 自己的处理
[super touchesBegan:touches withEvent:event];
}
手势识别器
事实上,我们要处理事件除了使用前面提到的方式,还有另一种方式,就是手势识别器.手势识别器可以很方便的处理常用的各种触摸事件,常见的手势包括单击,拖动,长按,横扫或竖扫,缩放,旋转等,另外我们还可以创建自定义的手势.
UIGestureRecognize 是手势识别器的父类,所有具体的手势识别器均继承于该父类,如果我们自定义手势,也需要继承该类.然而,该类并没有继承于 UIResponder,所以手势识别器并不参与响应者链.那么手势识别器是如何工作的呢?
手势识别器工作机制
当触摸屏幕产生 touch 事件后,UIApplication 会将事件往下分发,如果视图绑定了手势识别器,那么 touch 事件会优先传递给绑定在视图上的手势识别器,然后手势识别器会对手势进行识别,如果识别出了手势,就会调用创建手势时所绑定的回调方法,并且会取消将 touch 事件继续传递给其所绑定的视图,如果手势识别器没有识别出对应的手势,那么 touch 事件会继续向手势识别器所绑定的视图传递.
虽然手势识别器并不是响应者链中的一员,但是手势识别器像一个观察者,会在一旁观察 touch 事件,并延迟事件向所绑定的视图传递,这短暂的延迟使手势识别器有机会优先去识别手势处理 touch 事件.
标准控件的事件处理
对于 UIKit 提供的的标准控件,可以很方便地通过 Target-Action 的方式增加事件处理逻辑(例如 UIButton 的 addTarget 方法),那么 Target-Action,手势识别器,以及 touches 方法的优先顺序是怎样的呢?
情景 1 我们以 UIbutton 为例,首先继承 UIbutton 并重写 touches 方法,然后创建 button 对象并绑定单击手势,然后再通过 addtarget 的方式添加点击事件.三者同时存在时,手势识别器优先响应,其他方式不再响应,手势识别器不存在时,touches 方法优先响应,仅当 UIbutton 没有绑定手势识别器,也没有被重写 touches 方法时,target-action 方式才会响应.这里我们也可以推测 target-action 方式应该就是重写了 button 的 touches 方法
情景 2 仍以 UIbutton 为例,我们创建 button 对象,并在 button 的父视图上绑定手势(或者重写父视图的 touches 方法),结果是 button 的 target-action 方式优先进行了响应,父视图并没有响应.这也很显然,从 hittest 的递归逻辑看,当发现了合适的子视图(button)时就直接由子视图第一响应,父视图将不是最合适的响应者,当然它处于响应者链的上一层.
来源: https://juejin.im/post/5a6abaff5188257350516efe