阅读 2743

由手势与 UIControl 冲突引发的「事件处理全家桶」探索

2019.11.1

去年开发需求的时候做的思考学习,后知后觉发现没有发出来,补一份。

欢迎交流指正。

零、场景复现

1 开发问题

在写可横滑的 SlideActionSheet 时,要达成这样的效果:父 UIScrollView 的横滑手势在子 UIButton 上时也可以触发横滑,只有点击 UIButton 时才由它响应 target-action 的点击事件。

不设置任何属性时,效果是这样的:只有横滑非子 UIButton 的部分才会触发父 UIScollView 的横滑事件,操作体验很怪,不符合用户直觉和需求。

2 解决方法

在父UIScrollView的初始化中设置这个属性:

self.panGestureRecognizer.delaysTouchesBegan = YES;
复制代码

就可以完美实现这个效果。But why?

3 不明觉厉,先读文档

UIPanGestureRecognizer是滑动手势的识别器,继承UIGestureRecognizer

UIGestureRecognizer中的这个属性文档如下:

@property(nonatomic) BOOL delaysTouchesBegan;
// default is NO.  causes all touch or press events to be delivered to the target view only after this gesture has failed recognition. set to YES to prevent views from processing any touches or presses that may be recognized as part of this gesture
/*
直译:默认为 NO。这个属性用来决定是否要在这个手势识别失败后才把事件分发给响应 view。设置它为 YES 来防止响应 view 处理了可能被识别为该手势的事件。
*/
复制代码

根据看文档产生的问题:

  • 描述很绕,根据这两句话不能很好理解手势与响应链调用之间的顺序问题
  • 根据自己以前的认知,手势处理的优先级应视图响应的优先级,那么为什么会出现上面“父 View 的手势被子 Button 的响应拦截”的情况?

以前只是对响应链 UIResponder 有所了解,但对其他的问题(手势的影响、 UIControl 的事件处理特殊性)不甚清楚,因此对上面的问题没有很好的答案。

4 结论先行

这次遇到的问题看起来是 UIResponder 之间(UIScrollView 与 UIButton)的优先级问题,但实际上是「作为父视图的 UIGestureRecognizer」与「作为子视图的 UIControl(UIButton)」的事件处理优先级问题。

事件处理优先级:UIControl 子视图(特殊 Responder) > 父视图手势识别器 > UIView子视图(普通 Responder)

因此 UIButton 在收到事件时默认自己掌控事件处理的能力而不允许 UIScrollView 继续做手势识别,导致了在 Button 上无法识别横滑手势。 而解决方式【设置delaysTouchesBegan属性为YES】延迟了 UIButton 收到事件的时机,只有 UIScrollView 的横滑识别失败才会让 UIButton 收到事件。

要明白结论的来由,就要了解「事件处理全家桶」,包括:UITouch、UIEvent、UIResponder、UIGestureRecognizer以及UIControl。


一、事件来由:UITouch 触摸

1.1 UITouch 创建与销毁

  • 创建:每个手指每一次触摸屏幕,对应生成一个 UITouch 对象。

多个手指同时触摸,生成多个 UITouch 对象

多个手指先后触摸,会根据触摸位置判断。若是同一位置先后触摸(双击),那么更新同个 UITouch 对象的 tap count 属性为2;若是不同位置先后触摸,将生成两个 UITouch 对象。

  • 销毁:手指离开屏幕一段时间后被释放

1.2 UITouch 属性与方法

  • 属性:
    • 触摸时间(timestamp)
    • 阶段(phase):Began、Moved、Stationary、Ended、Cancelled
    • 类型(type):Direct、Indirect、Pencil、Stylus
    • 接触面积的半径(majorRadius)
    • 触摸压力值(force)
    • 角度(altitudeAngle…)
    • 所处 View&Window
  • 方法:
    • - (CGPoint)locationInView:(nullable UIView *)view;触摸在view上的位置
    • - (CGPoint)previousLocationInView:(nullable UIView *)view;前一个触摸在view的位置

二、事件本人:UIEvent 事件

2.1 UIEvent 概念

UIEvent 对象包含了所有触发该事件的对象(UITouch 等)集合(因为一个事件可能由多个动作对象产生)

也就是说,UIEvent 与 UITouch 的关系是一对多的关系。

2.2 UIEvent 类型

  • Touch Events 触摸事件
  • Motion Events 运动事件(重力感应、摇一摇…)
  • Remote Events 远程事件(蓝牙耳机控制…)

本篇中主要讲触摸事件的过程

2.3 UIEvent 事件历程

手指触摸屏幕

  1. IOKit.framework 封装事件为 IOHIDEvent对象
  2. 端口通信:通过 mach port 转发到 APP
  3. 主线程 Runloop 进行回调(Source1回调 -> Source0)
  4. Source0 的回调将触摸事件添加到事件队列(FIFO)
  5. 出队列时 UIApplication 开始寻找最佳响应者(Hit-testing)
  6. 事件被发送至最佳响应者,进行响应或传递

三、事件处理者:UIResponder 响应者

3.1 UIResponder 概念

每个响应者都是一个 UIResponder 对象(UIView、UIViewController、UIApplication、AppDelegate)

3.2 处理阶段1:寻找事件的最佳响应者 (Hit-Testing)

  • 视图能够作为最佳响应者响应事件的条件(在hitTest方法的内部判断)
    • 允许交互:userInteractionEnabled = YES
    • 禁止隐藏:hidden = NO
    • 透明度:alpha > 0.01
    • 触摸点的位置是否在视图的坐标范围内:通过pointInside: withEvent:方法返回的 BOOL 值判断
  • 寻找最佳响应者的顺序: 0. UIApplication 接收到触摸事件
    1. 从 UIWindow 开始,递归调用hitTest: withEvent:判断当前视图能否响应事件
    2. 若自己能响应事件,按子视图添加顺序的倒序遍历,并继续递归调用子视图的hitTest: withEvent:;若自己不能响应事件,直接返回 nil(表示自己无法处理事件),也不会继续调用子视图的 hitTest;
    3. 如果自己能响应事件,且子视图遍历的过程中找到了能够响应事件的视图,则返回遇到的第一个能响应的子视图作为最佳响应者;如果自己能响应事件,且子视图遍历的过程中没找到能响应事件的视图,则返回自己作为最佳响应者;

hitTest: withEvent:的本质:递归调用直到找到最佳响应者

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; 
///< 返回的UIView对象即为当前视图层次中的响应者
复制代码

例:视图层级如下,同一层级的视图越在下面,表示越后添加

A
├── B
│   └── D
└── C
    ├── E <- 点击
    └── F
复制代码

寻找响应者的顺序:A -> C -> F -> E ✅

3.3 处理阶段2:事件的发送、响应与传递

在 UIApplication 通过 Hit-Testing 寻找到事件的最佳响应者后

  1. 优先将事件传递给最佳响应者响应
  • 传递事件:UIApplication 和 UIWindow 的(void)sendEvent:(UIEvent *)event方法
  • 顺序:UIApplication -> UIWindow -> 最佳响应者
  1. 最佳响应者调用 UIResponder 的响应触摸事件四兄弟
- (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; 
复制代码

若响应成功:touchesBegan -> (touchesMoved) -> touchesEnded

若响应失败:touchesBegan -> (touchesMoved) -> touchesCancelled

  • 响应触摸事件方法参数:触摸对象集合、事件对象。
  • touchesBegan 默认实现:不对事件做处理,单纯将事件沿着默认的响应链传递
    • 如果要拦截事件并不再继续传递:重写方法,进行事件处理,不调用父类的该方法
    • 如果要拦截事件并继续传递给 nextResponder:重写方法,进行事件处理,调用父类的该方法
  1. 若最佳响应者没有拦截事件,则默认将事件沿着响应链往下一个响应者 (nextResponder) 传递
  • nextResponder:
    • UIView:父视图或父VC
    • UIViewController:父 Window 或父VC(present出来的话)
    • UIWindow:UIApplication
    • UIApplication: UIApplicationDelegate

3.4 处理阶段总结

事件由父视图 -> 子视图的查找顺序【寻找】最佳响应者

然后将事件按子视图 -> 父视图的方式在响应链上【传递】

验证:Demo1

问题:为什么 HitTesting 过程会调两遍? Apple - Lists.apple.com Apple官方解答:两次调用 hitTest 之间系统可能会调整point 为了避免不必要的问题,开发时需要意识到 hitTest 是可能调用多次的,不要进行错误方式的重写。

3.5 应用:使在父视图外的子视图A部分能够响应事件

  • 不做处理的情况:(Demo1-Test5)

原因:父视图的 hitTest 中的 pointInside 返回 NO,因此 hitTest 返回 nil,父视图认为自己(及其子视图)均不能响应

  • 解决方案1:重写父视图的 hitTest,如果点在 A 视图(且满足另几个响应的条件)里就返回 A 视图
  • 解决方案2:重写父视图的 pointInside,如果点在 A 视图里就返回 YES

四、事件处理的执行方式:UIGestureRecognizer 手势

4.1 手势分类

  • 离散型手势 Discrete gestures:点按(Tap)、轻扫(Swipe)
    • 识别成功:Possible -> Recognized
    • 识别失败:Possible -> Failed
  • 持续型手势 Continuous gesture:滑动(Pan)
    • 完整识别:Possible -> Began -> Changed -> Recognized
    • 不完整识别:Possible -> Began -> Changed -> Cancel
    • 识别失败:Possible -> Failed

4.2 手势处理过程:调用方法与状态变化

手势处理同样有响应事件四兄弟,和Responder事件响应的四兄弟类似:

- (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; 
复制代码

以离散型手势「点击」为例:

没有点击屏幕时,所有手势都处于 Possiable 状态。

点击屏幕并寻找完最佳响应者之后,手势会收到 Touch Began Message,调用手势的 touchBegan,手势开始记录点击位置和时间,但仍然处于 Possible 状态。

如果识别失败(如按住不放),手势状态变为 Failed,并在下一个 runloop 重新变为 Possiable。

4.3 不同手势之间的混合处理

根据官方文档,不同的 UIGestureRecognizer 收到事件(手势的touchesBegan)没有固定的顺序,建议使用方法来控制它们之间的顺序和相互关系。

与手势之间关系有关的方法:

///< UIGestureRecognizer 的方法
- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;
/// 调用这个方法将该手势置于另一手势的优先级之下,只有另一手势识别失败才会识别该手势;如果另一手势识别成功,则该手势的状态变为识别失败。
/// 适用于同一个View中创建多个UIGestureRecognizer,要调整优先级的情况。
/// 例:单击手势中调用此方法,参数是双击手势,判断双击失败后才会响应单击。

///< UIGestureRecognizerDelegate 协议里的Optional方法
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer API_AVAILABLE(ios(7.0)); ///返回YES第一个手势失效
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer API_AVAILABLE(ios(7.0)); ///返回YES第二个手势失效
/// Note: 返回YES能保证失效,但返回NO并不能保证生效(单一控制优先级)
/// 适用于不同层级的手势优先级处理
复制代码

4.4 加入手势的事件处理流程图

验证:Demo2

  • 事件怎样被发送给手势识别器?

手势上下文 UIGestureEnvironment 收到 UIEvent,并负责通知给相关的 UIGestureRecognizer,同时会通过 UIGestureRecognizer 的 delegate 方法来判断其是否能够对触摸事件进行响应。

  • 哪些手势识别器会接受事件?

系统通过 Hit-Testing 过程得到最佳响应者和响应链 View 数组,遍历这个数组去依次判断每个 View 上的手势识别器是否要接收触摸事件。 只有绑定到响应链内 View 数组的手势识别器才可以处理事件。

手势相关的底层原理与Demo强烈推荐阅读干货:深入理解 iOS 事件机制 - 掘金

4.5 手势属性

@property(nonatomic) BOOL cancelsTouchesInView;
// default is YES. causes touchesCancelled:withEvent: or pressesCancelled:withEvent: to be sent to the view for all touches or presses recognized as part of this gesture immediately before the action method is called.
/*
直译:默认为 YES。这个属性用来决定在成功识别事件为手势时是否立刻让响应 view 取消它的响应。
解读:默认 YES 说明在成功识别事件为手势时会取消响应 view 对事件的响应,触发响应 view 的 touchesCancelled 方法。如果设为 NO 则会在成功识别为手势后仍然让响应 view 处理事件。
*/

@property(nonatomic) BOOL delaysTouchesBegan;
// default is NO.  causes all touch or press events to be delivered to the target view only after this gesture has failed recognition. set to YES to prevent views from processing any touches or presses that may be recognized as part of this gesture
/*
直译:默认为 NO。这个属性用来决定是否要在这个手势识别失败后才把事件分发给响应 view。设置它为 YES 来防止响应 view 处理了可能被识别为该手势的事件。
解读:默认为 NO 说明默认在手势识别过程中就把事件分发给响应 view 了,会立刻触发响应 view 的 touchBegin 方法。如果修改为 YES,也就是在手势识别过程中不向目标 view 分发事件,不会调用 view 的 touchBegin 方法,直到识别失败才会分发事件给响应 view。
*/

@property(nonatomic) BOOL delaysTouchesEnded;
// default is YES. causes touchesEnded or pressesEnded events to be delivered to the target view only after this gesture has failed recognition. this ensures that a touch or press that is part of the gesture can be cancelled if the gesture is recognized
/*
直译:默认为 YES。这个属性用来决定是否要在这个手势识别失败后才让响应 view 结束事件处理,(设置为 YES)保证了当手势识别成功时能够及时取消响应view的处理。
解读:这个属性确保了识别手势成功时响应 view 的事件处理一定没有结束,从而保证了取消响应的始终可行。
*/
复制代码

五、UIResponder 的超能力者:UIControl

根据官方文档,只有官方提供的「部分UIControl」(如UIButton、UISwitch)才会做以下的防冲突和事件拦截处理,自写的 UIControl 是不具有这样的能力的。

5.1 能力1:阻断响应链

UIControl 将手势与view进行绑定,并对 UIResponder 的事件响应四兄弟进行了重写,在touchBegin、Moved、Ended、Cancel中实际上调用了以下四个方法:

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (void)cancelTrackingWithEvent:(UIEvent *)event
复制代码

与普通的 UIResponder 不同的是,UIControl 在事件处理的封装内并不会默认向nextResponder 传递事件,而是自己拦截了这个事件进行处理。

另外可以注意到:UIControl 处理的不是 touch 数组而是单个 touch。 也就是说:UIControl 只处理单点触控事件。

5.2 能力2:比父视图手势识别器的优先级高

正如前文所讲,普通 UIResponder 的事件响应优先级小于父视图的手势识别器,由手势识别器来决定普通 UIResponder 是否有能力处理事件。

而 UIResponder 的“超能力者” UIControl 则在内部重写了 UIGestureRecognizer的代理方法gestureRecognizerShouldBegin:,使其不能够进行手势识别的状态转换,从而达到了「比父视图的 UIGestureRecognizer 优先级更高」的效果。

验证:Demo3

5.3 回归开发场景问题:UIButton 与父视图手势识别器的纠葛

这也就解决了前言中我所遇到的开发问题:当手势在 UIButton 上时,它已经先手开始beginTrackingWithTouch并不允许父视图(UIScrollView)开始判断手势。即使这个滑动手势没有让UIButton触发动作,但它仍然不会给父视图手势识别器机会。

而当设置父视图手势识别器的delayTouchesBegan = YES后,手势先发制人地将自己的优先级提高,延迟了 UIButton 接收事件的beginTrackingWithTouch,也就避免了 UIButton 反制裁自己。

文章分类
iOS
文章标签