@[TOC](IOS 事件,响应链机制分析)
1. 事件分发和响应者链条
1.1 简述
- 事件分发:自上而下的由UIApplication开始,一路往最具体的View查找,直到找到最应该处理并且能够处理事件的那个控件。
- 响应者链条:当找到那个最应该处理并且能够处理事件的那个控件以后,如果这个控件确实处理了这个事件,那么这个事件的就到此处理完毕,但是很有可能出现的情况是,虽然这个控件最应该处理,也能够处理事件,但是它并没有处理事件,那么这时这个事件就要传给下一个响应者处理,下一个响应者还不处理,那就再下一个,这个事件就沿着这条响应者链条找直到响应者确实处理了这个事件(并中断了事件传递,就是在touch方法里没有调用下一个响应者的touch方法)为止
事件的分发路径和响应者链条的路径并不是同一条路从两头走的关系。首先,事件分发时候除了最开始的UIApplication,一路都是在查找下一个更应该响应的UIView,而响应者链条除了考虑UIView以外还有其他的UIResponder,例如UIViewController;其次,事件分发会考虑同级UIView之间的关系,就是如果一个UIView有多个子View,那么哪个是更应该响应的View,而响应者链条则不会考虑同级View之间的关系,一个View的下一个响应者并不会考虑除了自己以为其它适合响应的同级View,而是之间考虑它的父View或者是控制器。
2. 事件分发
当我们手指触摸屏幕时,系统会生成UITouch对象(一个手指一个UITouch),然后系统又会帮我们把所有的UITouch对象包装成一个UIEvent,然后把这个UIEvent交给UIApplication单例维护着的一个事件队列,当轮到这个事件处理的时候,UIApplication首先会将这个UIEvent交给KeyWindow,然后KeyWindow再交给它的根控制器的View,然后不断的递归寻找最适合处理这个事件的View。
这个递归查找的方法叫做hitTest
下面我们尝试的写一下这个方法的实现:
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
//如果不能处理,返回nil
if (self.hidden==YES||self.alpha<=0.01||self.userInteractionEnabled == NO) {
return nil;
}
//如果不应该处理返回nil
if ([self pointInside:point withEvent:event]==NO) {
return nil;
}
//找找看有没有更适合处理的子View
//最后添加的(也就是最上面的View)先处理
NSArray *reverseArray = [[self.subviews reverseObjectEnumerator]allObjects];
for (UIView *subView in reverseArray) {
CGPoint subPoint = [self convertPoint:point toView:subView];
UIView *resultView = [subView hitTest:subPoint withEvent:event];
if (resultView) {
return resultView;
}
}
//遍历完都没有子控件更适合,那么最适合的就是自己
return self;
}
3. 响应者链条
当找到这个最适合的控件后,会从这个控件开始尝试处理事件并传递给下一个响应者(直到某个响应者中断了传递)。
那么,怎么寻找下一个响应者呢?原则如下:
如果这个view是一个控制器的View那么这个View的下一个响应者就是控制器。
如果这个View不是控制器的View那么下一个响应者就是它的父View。
根控制器的下一个响应者是谁UIWindow,再下一个响应者是UIApplication,链条结束。
如果我们把A控制器的View添加到B控制器的View上,那么A控制器会在响应者链条上吗,如果在,它的下一个响应者是谁?
如果要把A控制器添加到响应者链条,就要B控制器add A为子控制器,如果不add的话A控制器不会在这个响应者链条内,A控制器的下一个响应者为A控制器View的父View。
- 响应链是如何工作,正确找到应该响应该事件的响应者的?
UIKit使用基于视图的hit-testing来确定touch事件发生的位置。具体解释就是,UIKit将touch的位置和视图层级中的view的边界进行了比较,UIView的方法 hitTest:withEvent: 在视图层级中进行,寻找包含指定touch的最深子视图。这个视图成为touch事件的第一个响应者。
说白了就是,当有touch事件来的时候,会从最下面的视图开始执行 hitTest:withEvent: ,如果符合成为响应者的条件,就会继续遍历它的 subviews 继续执行 hitTest:withEvent: ,直到找到最合适的view成为响应者。
- 符合响应者的条件包括:
- touch事件的位置在响应者区域内
- 响应者 hidden 属性不为 YES
- 响应者 透明度 不是 0
- 响应者 userInteractionEnabled 不为 NO
- 响应者链有以下特点:
1、响应者链通常是由视图(UIView)构成的;
2、一个视图的下一个响应者是它视图控制器(UIViewController)(如果有的话),然后再转给它的父视图(Super View);
3、视图控制器(如果有的话)的下一个响应者为其管理的视图的父视图;
4、单例的窗口(UIWindow)的内容视图将指向窗口本身作为它的下一个响应者
需要指出的是,Cocoa Touch应用不像Cocoa应用,它只有一个UIWindow对象,因此整个响应者链要简单一点;
4. 事件传递和响应原理分析
4.1事件传递流程图
-
事件流程图
IOKit.framework 为系统内核的库
SpringBoard.app 相当于手机的桌面
Source1 主要接收系统的消息
Source0 - UIApplication - UIWindow
- 从UIWindow 开始步骤,见下图
比如我们在self.view 上依次添加view1、view2、view3(3个view是同级关系),那么系统用hitTest以及pointInside时会先从view3开始便利,如果pointInside返回YES就继续遍历view3的subviews(如果view3没有子视图,那么会返回view3),如果pointInside返回NO就开始便利view2。
反序遍历,最后一个添加的subview开始。也算是一种算法优化
4.2 HitTest 、pointInside
当我们触控手机屏幕时系统便会将这一操作封装成一个UIEvent放到事件队列里面,然后Application从事件队列取出这个事件,接着需要找到去响应这个事件的最佳视图也就是Responder, 所以开始的第一步应该是找到Responder, 那么又是如何找到的呢?那就不得不引出UIView的2个方法:
- -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
返回视图层级中能响应触控点的最深视图 - -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
返回视图是否包含指定的某个点
- 通过简单示例代码来验证一下
EOCLightGrayView *grayView = [[EOCLightGrayView alloc] initWithFrame:CGRectMake(50.f, 100.f, 260.f, 200.f)];
redView = [[EOCRedView alloc] initWithFrame:CGRectMake(0.f, 0.f, 120.f, 100.f)];
EOCBlueView *blueView = [[EOCBlueView alloc] initWithFrame:CGRectMake(140.f, 100.f, 100.f, 100.f)];
EOCYellowView *yellowView = [[EOCYellowView alloc] initWithFrame:CGRectMake(50.f, 360.f, 200.f, 200.f)];
[self.view addSubview:grayView];
[grayView addSubview:redView];
[grayView addSubview:blueView];
[self.view addSubview:yellowView];
测试结果如下:
我们可以得出结论:
点击red,由于yellow 与 grey 同级,yellow 比 grey 后添加,所以先打印yellow,由于触摸点不在yellow内,打印grey,然后遍历grey,打印他的两个subviews
通过在HitTest返回nil,pointInside并没有执行,我们可以得知,pointInside调用顺序你在HitTest之后的。
pointInside 的 参数 :(CGPoint)poinit 的值是以自身为坐标系的,判断点是否view内的范围是以view自身的bounds为范围,而非frame
如果在grey的hitTest返回[super hitTest:point event:event],则会执行gery.subviews的遍历(subviews 的 hitTest 与 pointInside),grey 的 pointInside 是判断触摸点是否在grey的bounds内(不准确),grey 的 hitTest 是判断是否需要遍历他的subviews.
pointInside 只是在执行hitTest时,会在hitTest内部调用的一个方法
pointInside 只是辅助hitTest的关系
hitTest是一个递归函数
- 还原hitTest内部实现代码
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
///hitTest:判断pointInside,是不是在view里?是的话,遍历,不是的话返回nil;假设我就是点击灰色的,返回的是自己;
NSLog(@"%s",__func__);
NSArray *subViews = [[self.subviews reverseObjectEnumerator] allObjects];
UIView *tmpView = nil;
for (UIView *view in subViews) {
CGPoint convertedPoint = [self convertPoint:point toView:view];
if ([view pointInside:convertedPoint withEvent:event]) {
tmpView = view;
break;
}
}
if (tmpView) {
return tmpView;
} else if([self pointInside:point withEvent:event]) {
return self;
} else {
return nil;
}
return [self hitTest:point event:event]; //redView
///这里是hitTest的逻辑
///alpha(<=0.01)、userInterActionEnabled(NO)、hidden(YES) pointInside返回的为NO
}
4.3 UIRespond 与 响应链的组成
所有视图按照树状层次结构组织,每个view都有自己的superView, 包括vc的self.view:
当一个view被添加到superView上的时候, 它的nextResponder就会被指向所在controller
当vc被初始化的时候,self.view的nextResponder会被指向所在的controller
如果当前这个view是控制器的self.view,那么控制器就是上一个响应者,如果当前这个view不是控制器的view,那么父控件就是上一个响应者)vc的nextResponder会被指向self.view的superView
最顶级的vc的nextResponder指向UIWindow
UIWindow的nextResponder指向UIApplication
这就形成了响应链,通过UIResponder串连起来的
touches方法实际上没做什么,UIView继承了它并重写,把事件传递给nextResponder,相当于[self.nextResponder touchBegan:touches withEvent:event]. 当一个view没有重写touch事件,那么这个事件就会一直传递下去, 直到UIApplication. 如果重写了touch方法,这个view响应了事件,事件就被拦截了, 它的nextResponder不会收到这个事件.
- 响应链事件传递 向上传递:
如果view的控制器存在, 就传递给控制器,如果控制器不存在,则将其传递给它的父视图.
在视图层次结构的最顶级视图,如果不能处理收到的事件/消息,则将事件/消息传递给window对象进行处理
如果window对象不处理,则将其事件/消息传递给UIApplication对象
如果UIApplication不处理事件/消息,则将其丢弃
- 监听事件的基本流程:
当应用程序启动以后创建UIApplication对象
然后启动消息循环监听所有事件
当用户触摸屏幕的时候,消息循环监听到这个触摸事件
消息循环首先把监听到的触摸事件传递给UIApplication对象
UIApplication对象再传递给UIWindow对象
UIWindow对象再传递给UIWindow的根控制器rootViewController
控制器再传递给控制器所管理的view
控制器所管理的view在其内部搜索看本次触摸的点在哪个控件的范围内
找到某个控件以后,调用这个控件的touchBegan方法,再一次向上返回,最终返回给消息循环
消息循环知道哪个按钮被点击后, 在搜索这个按钮是否注册了对应的事件,如果注册了,就调用这个事件处理程序.(一般就是执行控制器中的事件处理方法)
4.4 手势与事件关系
- 手势 与 hitTest 的关系
相同上面的学习我们可以推测出,手势的响应也得必须经过hitTest先找到视图才能触发(已验证)
- 手势与 触摸事件的关系
touch事件是UIView内部的东西,而手势叠加上去的触摸事件
subview会响应superview的手势, 但是同级的subview不会响应
- 系统如何分辨手势种类
首先我们想在手势中调用 touches 方法必须要导入
import <UIKit/UIGestureRecognizerSubclass.h>
因为gesture继承的是NSObject 而不是 UIRespond
通过尝试不调用 tap手势 的touchesBegan ,发现tap手势无法响应
通过尝试调用touchesBegan ,但是不调用 pan手势 的touchesMoved ,发现pan手势无法响应
我们通过UITouch的实例,可以看到里面有很多属性,比如点击的次数,上次的位置等,结合这个属性系统与touches方法就可以判断出你使用的是什么手势
- 手势与view的touches事件的关系
首先通过触摸事件,先响应touchesBegan 以及 touchesMoved,直到手势被识别出来,调用touchesCancelled,全权交给手势处理。
但是我们可以改变这种关系
下面是系统的默认设置
tapGesture.delaysTouchesBegan = NO;
///是否延迟view的touch事件识别;如果延迟了(YES),并且手势也识别到了,touch事件会被抛弃
tapGesture.cancelsTouchesInView = YES;
///识别手势之后,是否取消view的touch事件
// 如果为NO, touchesCancelled 不会调用,取而代之的是手势结束后touchesEnd
4.5 手势识别
手势识别器 UIGestureRecognizer
简介
UIGestureRecognizer是苹果在iOS 3.2之后,推出的手势识别功能。UIGestureRecognizer是一个抽象类,将触摸事件封装成了手势对象,大大简化了开发者的开发难度,同时也提升了用户的交互体验。UIGestureRecognizer有七个子类,它们具体实现了不同手势的功能。
属性方法,代理
- 初始化、添加target、移除target
//初始化方法 且 添加 target的方法
- (instancetype)initWithTarget:(nullable id)target action:(nullable SEL)action
//单独添加target的方法
- (void)addTarget:(id)target action:(SEL)action;
//移除target的方法
- (void)removeTarget:(nullable id)target action:(nullable SEL)action;
- 属性和方法
//手势的状态
@property(nonatomic,readonly) UIGestureRecognizerState state;
//手势代理
@property(nullable,nonatomic,weak) id <UIGestureRecognizerDelegate> delegate;
//手势是否有效 默认YES
@property(nonatomic, getter=isEnabled) BOOL enabled;
//获取手势所在的view
@property(nullable, nonatomic,readonly) UIView *view;
//取消view上面的touch事件响应 default YES **下面会详解该属性**
@property(nonatomic) BOOL cancelsTouchesInView;
//延迟touch事件开始 default NO **下面会详解该属性**
@property(nonatomic) BOOL delaysTouchesBegan;
//延迟touch事件结束 default YES **下面会详解该属性**
@property(nonatomic) BOOL delaysTouchesEnded;
//允许touch的类型数组,**下面会详解该属性**
@property(nonatomic, copy) NSArray<NSNumber *> *allowedTouchTypes
//允许按压press的类型数组
@property(nonatomic, copy) NSArray<NSNumber *> *allowedPressTypes
//是否只允许一种touchType 类型,**下面会详解该属性**
@property (nonatomic) BOOL requiresExclusiveTouchType
//手势依赖(手势互斥)方法,**下面会详解该方法**
- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;
//获取在传入view的点击位置的信息方法
- (CGPoint)locationInView:(nullable UIView*)view;
//获取触摸点数
@property(nonatomic, readonly) NSUInteger numberOfTouches;
//(touchIndex 是第几个触摸点)用来获取多触摸点在view上位置信息的方法
- (CGPoint)locationOfTouch:(NSUInteger)touchIndex inView:(nullable UIView*)view;
// 给手势加一个名字,以方便调式(iOS11 or later可以用)
@property (nullable, nonatomic, copy) NSString *name API_AVAILABLE(ios(11.0)
先来说说requiresExclusiveTouchType这个属性
是不是有很多人和我之前一样,把它理解成了设置为NO,就可以同时响应几种手势点击了呢?
这个属性的意思:是否同时只接受一种触摸类型,而不是是否同时只接受一种手势。默认是YES。设置成NO,它会同时响应 allowedTouchTypes 这个数组里的所有触摸类型。这个数组里面装的touchType类型如下:
//目前touchType有三种
typedef NS_ENUM(NSInteger, UITouchType) {
UITouchTypeDirect, // 手指直接接触屏幕
UITouchTypeIndirect, // 不是手指直接接触屏幕(例如:苹果TV遥控设置屏幕上的按钮)
UITouchTypeStylus NS_AVAILABLE_IOS(9_1), // 触控笔接触屏幕
}
如果把requiresExclusiveTouchType设置为NO,假设view上添加了tapGesture手势,你同时用手点击和用触控笔点击该view,这个tapGesture手势的方法都会响应。
接下来说说cancelsTouchesInView、delaysTouchesBegan、delaysTouchesEnd这三个属性。
cancelsTouchesInView 属性默认设置为YES,如果识别到了手势,系统将会发送touchesCancelled:withEvent:消息,终止触摸事件的传递。也就是说默认当识别到手势时,touch事件传递的方法将被终止,如果设置为NO,touch事件传递的方法仍然会被执行。
delaysTouchesBegan 用于控制事件的开始响应的时机,"是否延迟响应触摸事件"。设置为NO,不会延迟响应触摸事件,如果我们设置为YES,在手势没有被识别失败前,都不会给事件传递链发送消息。
delaysTouchesEnd 用于控制事件结束响应的时机,"是否延迟结束触摸事件",设置为NO,则会立马调用touchEnd:withEvent这个方法(如果需要调用的话)。设置为YES,会等待一个很短的时间,如果没有接收到新的手势识别任务,才会发送touchesEnded消息到事件传递链。
手势依赖方法-requireGestureRecognizerToFail
用法:[A requireGestureRecognizerToFail:B] 当A、B两个手势同时满足响应手势方法的条件时,B优先响应,A不响应。如果B不满足条件,A满足响应手势方法的条件,则A响应。其实这就是一个设置响应手势优先级的方法。
如果一个view上添加了多个手势对象的,默认这些手势是互斥的,一个手势触发了就会默认屏蔽其他手势动作。比如,单击和双击手势并存时,如果不做处理,它就只能发送出单击的消息。为了能够优先识别双击手势,我们就可以用requireGestureRecognizerToFail:这个方法设置优先响应双击手势。
- UIGestureRecognizerDelegate代理方法
//开始进行手势识别时调用的方法,返回NO,则手势识别失败
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer;
//手指触摸屏幕后回调的方法,返回NO则手势识别失败
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch:(UITouch *)touch;
//是否支持同时多个手势触发
//返回YES,则可以多个手势一起触发方法,返回NO则为互斥
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)
otherGestureRecognizer;
//下面这个两个方法也是用来控制手势的互斥执行的
//这个方法返回YES,第二个手势的优先级高于第一个手势
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)
otherGestureRecognizer
//这个方法返回YES,第一个手势的优先级高于第二个手势
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)
otherGestureRecognizer
子类
点击手势——UITapGestureRecognizer
捏合手势——UIPinchGestureRecognizer
旋转手势——UIRotationGestureRecognizer
滑动手势——UISwipeGestureRecognizer
长按手势——UILongPressGestureRecognizer
平移手势——UIPanGestureRecognzer
屏幕边缘平移手势——UIScreenEdgePanGestureRecognzer