从点击屏幕到系统做出响应,经历了哪些过程?需要详细探究下ios的响应机制。
UIResponder
点击事件响应流程
系统响应阶段
- 手指触碰屏幕,事件交由 IOKit 处理
- IOKit 将触摸事件封装为一个 Event 对象,通过 march port 传递给 SpringBoard 桌面进程。
- SpringBoard 进程因接收到触摸事件,触发了其主线程 runloop 的 source1 事件源的回调。若前台无应用,则触发 SpringBoard 本身主线程 runloop 的 source0 事件源的回调,将事件交由桌面系统去消耗;若前台有应用,则将触摸事件通过IPC传递给前台APP进程。
APP响应阶段
- APP进程的 mach port 接受到 SpringBoard 进程传递来的触摸事件,主线程的 runloop 被唤醒,触发了 source1 回调。
- source1 回调又触发了一个 source0 回调,将接收到的 Event 对象封装成 UIEvent 对象,此时APP将正式开始对于触摸事件的响应。
- source0 回调内部将触摸事件添加到 UIApplication 对象的事件队列中。事件出队后,UIApplication 开始通过不断 hit-testing 寻找最佳响应者。
- 找到响应者后,事件便在响应链上传播。事实上,事件除了被响应者消耗,还能被手势识别器或是 target-action 模式捕捉并消耗掉。
- 事件处理完毕后,runloop 进入休眠。等待再次被唤醒。
几个名词:响应者、触摸、事件
UIResponder
在iOS中不是任何对象都能处理事件,只有继承了UIResponder
的对象才能接受并处理事件,我们称之为“响应者对象”。
以下都是继承自UIResponder
的,所以都能接收并处理事件:
- UIApplication
- UIViewController
- UIView
那么为什么继承自UIResponder的类就能够接收并处理事件呢?因为UIResponder中提供了以下4个对象方法来处理触摸事件。1
2
3
4- (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;
UITouch
- 一个手指一次触摸屏幕,就对应生成一个UITouch对象。多个手指同时触摸,生成多个UITouch对象。
- 每个UITouch对象记录了触摸的一些信息,包括触摸时间、位置、阶段、所处的视图、窗口等信息。
UITouch 提供了获取相对于视图的坐标的方法:
1 | - (CGPoint)locationInView:(UIView *)view; |
UIEvent
- 触摸的目的是生成触摸事件供响应者响应,一个触摸事件对应一个UIEvent对象
- UIEvent对象中包含了触发该事件的触摸对象的集合,可以通过allTouches 属性获取。
例1:使用 UITouch 实现 UIView 的拖拽
通过 UIResponder 和 UITouch 的视图定位,可以实现拖拽 UI 的功能
1 | - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{ |
例2:配合 CAShapeLayer 实现一个画板
简单说就是记录下收拾的贝塞尔曲线,然后将这个曲线的 path 设置给 CAShapeLayer:
1 | - (CAShapeLayer *)shapeLayer{ |
向下寻找响应者的过程
自上而下的寻找过程
- UIApplication首先将事件传递给 UIWindow,多个窗口优先最上层的窗口。
- 调用
hitTest
方法,询问是否可以响应。视图若不能响应,则将事件传递给上一个同级子视图;若能响应,则从后往前询问当前视图的子视图。 - 重复步骤2。视图若没有能响应的子视图了,则自身就是最合适的响应者。
无法响应的情况
- 不允许交互:
userInteractionEnabled = NO
- 隐藏:如果把父控件隐藏,那么子控件也会隐藏,隐藏的控件不能接受事件
- 透明度:如果设置一个控件的透明度<0.01,会直接影响子控件的透明度。alpha:0.0~0.01为透明。
注意:
- 默认UIImageView不能接受触摸事件,因为不允许交互,即
userInteractionEnabled = NO
,所以如果希望UIImageView可以交互,需要userInteractionEnabled = YES
。
判断是否可以响应的逻辑
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ |
pointInside:withEvent:
这个方法,用于判断触摸点是否在自身坐标范围内
例1:超出父视图区域的点击响应
开发中可能会遇到一种情况:Tabbar 中的一个按钮超出了 tabbar 显示的区域。默认情况下,点击超出部分是无法响应点击事件的。
因为触摸点不在TabBar的坐标范围内,因此TabBar无法响应该触摸事件,hitTest:withEvent:
直接返回了nil。整个过程,事件根本没有传递到圆形按钮。所以我们需要做的是扩大 tabbar 的点击范围。
我们需要重写 tabbar 的 pointInside:withEvent:
方法,先把位置转换到按钮上判断一下是否点击了按钮:
1 | //TabBar |
例2:按钮扩大响应区域
和上面类似,还是重写 pointInside:withEvent:
方法:
1 | - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { |
事件响应的向上传递
在找到最佳响应者之后,UIApplication将事件通过 sendEvent:
传递给事件所属的window,window同样通过 sendEvent:
再将事件传递给最佳响应者:
1 | UIApplication ——> UIWindow ——> hit-tested view |
响应者接收到时间之后,会回调 UIResponder
中的 touchesBegan:withEvent:
方法。响应者对于接收到的事件有3种操作:
- 不拦截,默认操作事件会自动沿着默认的响应链往下传递
- 拦截,不再往下分发事件。重写
touchesBegan:withEvent:
进行事件处理,不调用父类的touchesBegan:withEvent:
- 拦截,继续往下分发事件。重写
touchesBegan:withEvent:
进行事件处理,同时调用父类的touchesBegan:withEvent:
将事件往下传递
每一个响应者对象(UIResponder对象)都有一个 nextResponder
方法,用于获取响应链中当前对象的下一个响应者。调用父类的 touchesBegan:withEvent:
会默认调用 nextResponder
的 touchesBegan:withEvent:
。因此,就把响应事件向上传递了。
我们可以通过响应链
nextResponder
找到下一级响应者,直到找到 UIViewController 的子类
UIGestureRecognizer
手势简介
UIGestureRecognizer
是手势的基类,可以创建其派生类实例来满足不同需求。
初始化方法
UIGestureRecognizer
类为其子类准备好了一个统一的初始化方法:
1 | - (instancetype)initWithTarget:(nullable id)target action:(nullable SEL)action; |
手势状态
UIgestureRecognizer类中有如下一个属性,里面枚举了一些手势的当前状态:
1 | @property(nonatomic,readonly) UIGestureRecognizerState state; |
枚举值如下:
1 | typedef NS_ENUM(NSInteger, UIGestureRecognizerState) { |
可以在手势的处理方法中,判断手势的状态,区分不同的处理方式。
常用属性和方法
1 | //设置代理,具体的协议后面会说 |
其中,UITouch
也有一个方法是locationInView:
可以获取触摸点在view中的位置。
代理方法 UIGestureRecognizerDelegate
1 | //手指触摸屏幕后回调的方法,返回NO则不再进行手势识别,方法触发等 |
手势互斥
同一个View上是可以添加多个手势对象的,默认这个手势是互斥的,并且触发是随机的,一个手势触发了就会默认屏蔽其他相似的手势动作。
如果我们想设置一下当手势互斥时要优先触发的手势,可以使用如下的方法:
1 | [ges requireGestureRecognizerToFail:ges2]; |
表示如果ges2
匹配,那么不会执行ges
。只有当ges2
不匹配的时候,才会执行ges
。这个方法还适用于识别双击手势时屏蔽单击手势。只有确定不是双击手势后再识别为单击手势
UIGestureRecoginzer 子类
点击手势——UITapGestureRecognizer
1 | //设置点击次数,默认为单击 |
捏合手势——UIPinchGestureRecognizer
1 | //设置缩放比例 |
在设置完缩放后,一定要把recognizer.scale
设置为1
1 | - (void)handlePinch:(UIPinchGestureRecognizer*)recognizer{ |
拖拽手势——UIPanGestureRecognzer
1 | //设置触发拖拽的最少触摸点,默认为1 |
拖动过程中处理方法会被多次调用,但是在一次拖拽结前,translationInView:
方法参照的点都是最开始按下的点。这就导致增量的拖动,越拖越快。所以我们必须使用setTranslation
设置为CGPointZero
,就能将手指的当前位置设置为拖移手势的起始位置:
1 | -(void)handlePan:(UIPanGestureRecognizer*)recognizer{ |
滑动手势——UISwipeGestureRecognizer
滑动手势和拖拽手势的不同之处在于滑动手势更快,拖拽比较慢
1 | //设置触发滑动手势的触摸点数 |
旋转手势——UIRotationGestureRecognizer
1 | //设置旋转角度 |
在设置完旋转后,recognizer.rotation
一定要清零.
1 | - (void)handleRotate:(UIRotationGestureRecognizer*) recognizer{ |
长按手势——UILongPressGestureRecognizer
1 | //设置触发前的点击次数 |
手势与响应链
结论
event 绑定的 touch 对象上维护了一个手势识别器数组。在响应链通过 hit-test 寻找最佳响应视图的时候,会收集响应链上每一个视图上施加的手势:
Window先将事件传递给这些手势识别器,再传给hit-tested view。一旦有手势识别器成功识别了手势,Application就会取消hit-tested view对事件的响应。因此可以理解为手势识别器比UIResponder具有更高的事件响应优先级。
但是,识别手势是需要时间的,所以具体的表现就是当有点击事件的时候,会先触发
touchBegan:withEvent:
方法,然后当手势识别到的时候,就会触发touchCancelled:withEvent:
比如在视图上添加一个 UIPanGestureRecognizer 手势。打印日志可以看到,会先触发 UIResponder 的回调,直到 UIPanGestureRecognizer 识别成功:
手势识别器的两个属性
1 | @property(nonatomic) BOOL cancelsTouchesInView; |
cancelsTouchesInView
默认为YES。表示当手势识别器成功识别了手势之后,会通知Application取消响应链对事件的响应,并不再传递事件给hit-test view。若设置成NO,表示手势识别成功后不取消响应链对事件的响应,事件依旧会传递给hit-test view。
如果设置 pan.cancelsTouchesInView = NO
,那么上面的 UIPanGestureRecognizer 的日志会变为:
delaysTouchesBegan
默认为NO。默认情况下手势识别器在识别手势期间,当触摸状态发生改变时,Application都会将事件传递给手势识别器和hit-tested view;若设置成YES,则表示手势识别器在识别手势期间,截断事件,即不会将事件发送给hit-tested view。
如果设置 pan.delaysTouchesBegan = NO
,那么上面的 UIPanGestureRecognizer 的日志会变为:
UITableView 的点击和手势的冲突
UIScrollView 的滑动其实是因为系统加了一个 UIPanGesture的缘故。UITableView 点击 Cell 其实是调用了 touchBegan:withEvent:
方法。
因此,当有这样一个需求:cell 支持左滑,并且当一个 cell 左滑固定的时候,点击 UITableView 任意位置会关闭 cell。这种时候,需要给 UITableView 添加一个 UITapGesture。而这个 UITapGesture 就会导致 UITableView 本身 Cell 点击的不响应。
因此,我们需要在通常情况下设置这个 tapGesture.enabled = NO;
。只有在 Cell 展开的情况下,设置 tapGesture.enabled = YES
手势与UIControl
UIControl 继承于 UIResponder。像 UIButton 就是继承于 UIControl
结论
系统派生于 UIControl 的类像 UIButton 之类,处理事件的优先级比父视图上的 UIGestureRecognizer高。UIControl会阻止父视图上的手势识别器行为。
注意,自己继承于 UIControl 实现的类不存在优先级比手势高的情况。
另外,UIControl 的优先级是比父视图上的手势高,如果当前视图也有手势,那么 UIControl 无法阻止手势响应。
一些技巧
几个坐标转换的方法
UITouch
1 | // 返回值表示触摸在view上的位置 |
UIGestureRecognizer
1 | // 返回坐标点,第一个参数为tauch数组的索引 |
UIView
1 | // UIView 转换到另一个 UIView 坐标系,在 hittest 中判断子控件是否是 responder 的时候会使用 |