iOS-事件传递及响应链

880 阅读10分钟

谁来响应事件

在UIKit中我们使用响应者对象(UIResponder)来接收和处理事件

一个响应者对象一般是UIResponder类的实例,它的子类包括UIView,UIViewController和UIApplication

因此可以知道我们日常使用的控件都是响应者,如UIButton,UILabel等

在UIResponder及其子类中,我们是通过有关触摸(UITouch)的方法来处理和传递事件(UIEvent),包含下列方法

- (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;

注意,参数UITouch传递的是UITouch类型的一个集合(而不是一个UITouch),这对应了两根手指及以上同时触摸一个视图的情况,及一个手指对应一个UITouch


确定第一响应者

确定第一响应者:找到在这次触摸事件中,用户最想要哪个控件发起响应

在触摸发生后,UIApplication会触发- (void)sendEvent:(UIEvent *)event;将一个封装好的UIEvent传给UIWindow,通常情况下会传给当前展示的UIViewController,传给UIViewController的根视图

img

UIKit为我们提供了命中测试(hit-testing)来确定触摸事件的响应者

img
  • 无法接收事件的情况
  1. 不接受交互:view.userInteractionEnabled = false
  2. 透明:view.alpha <= 0.01
  3. 隐藏:view.hidden = true
  • 检查坐标是否在自身内部使用了 pointInside 方法来判断,该方法可以被重写
  • 从后往前遍历子视图,FILO,保证系统会优先测试最后添加的视图
  • 按顺序查看平级的兄弟视图时,若发现已经没有未检查的视图了,则返回没有子视图符合要求
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   
// recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   
// default returns YES if point is in bounds

举例

下图中灰色视图 A 可以看作是当前 UIViewController 的根视图,右侧表示了各个视图的层级结构,用户在屏幕上的触摸点是🌟处,并且这 5 个视图都可以正常的接收事件。⚠️并且注意,D 比 B 更晚添加到 A 上。

img

具体流程如下:

  1. 我们先对A进行命中测试,按照流程检查:🌟是否在A内部(显然在),A是否有子视图
  2. A有两个子视图B和D,按照FILO原则进行遍历子视图,因此对D进行命中测试,再对B进行命中测试
  3. 对D进行命中测试:🌟不在D内部,说明D及其子视图不是第一响应者
  4. 然后对B进行命中测试:🌟是否在B内部(在),B是否有子视图
  5. B有一个子视图C,因此需要再对C进行命中测试
  6. 对C进行命中测试:🌟不在C内部,说明C及其子视图不是第一个响应者
  7. 因此可以得到,触摸点在B内部,但不在B的任何子视图内
  • 即B是第一响应者,结束命中测试
  • 命中测试流程:A✅ --> D❎ --> B✅ --> C❎ >>>> B
class HitTestExampleView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
            return nil // 此处指视图无法接受事件
        }
        if self.point(inside: point, with: event) { // 判断触摸点是否在自身内部
            for subview in subviews.reversed() { // 按 FILO 遍历子视图
                let convertedPoint = subview.convert(point, from: self)
                let resultView = subview.hitTest(convertedPoint, with: event) 
                // ⬆️这句是判断触摸点是否在子视图内部,在就返回视图,不在就返回nil
                if resultView != nil { return resultView }
            }
            return self // 此处指该视图的所有子视图都不符合要求,而触摸点又在该视图自身内部
        }
        return nil // 此处指触摸点是否不在该视图内部
    }
}

提一个问题

有A、B两个视图,A先添加,B后添加,但是点击屏幕时,我希望是A称为第一响应者,应该怎么办?

  • 可以通过修改B视图的hit test方法,例如将 !isUserInteractionEnabled 修改为 isUserInteractionEnabled,这样就会判断就成立,返回nil
 if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
            return nil // 此处指视图无法接受事件
        }

小心越界

img

对于这个例子我们可以看到D有子视图E,命中测试的过程(省略写)

  1. 对D进行命中测试:🌟不在D内部,因此D及其子视图不是第一响应者
  2. 对B进行命中测试:然后对B进行命中测试:🌟是否在B内部(在),B是否有子视图
  3. B有一个子视图C,因此需要再对C进行命中测试
  4. 对C进行命中测试:🌟不在C内部,说明C及其子视图不是第一个响应者因此可以得到,触摸点在B内部,但不在B的任何子视图内

最终获得第一响应者仍然是 B,甚至整个命中测试的走向和之前是一样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B

这个例子告诉我们:要注意可点击的子视图是否会超出父视图的范围

如果真的存在这个情况,我们可以通过重写 - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;方法来扩大点击有效范围


通过响应链传递事件

确定响应链成员

在找到第一响应者后,整个响应链也确定下来了

响应链:由响应者组成的一个链表,链表的头部是第一响应者

其实响应链就是在命中测试中,走通的路径。用上个章节的例子,整个命中测试的走向是:A✅ --> D❎ --> B✅ --> C❎,我们把没走通的❎的去掉,以第一响应者 B 作为头,依次连接,响应链就是:B -> A。(实际上 A 后面还有控制器等,但在该例子中没有展示控制器等,所以就写到 A)

默认来说,若该节点是UIView类型的话,那么.next就是该节点的父视图,但也有几个例外:

  1. 如果是UIViewController的根视图,那下一个响应者是UIViewController
  2. 如果是UIViewController
    1. 如果UIViewController的根视图是UIWindow的根视图,则下一个响应者是UIWindow对象
    2. 如果UIViewController是由另一个UIViewController呈现的,则下一个响应者是第二个UIViewController
  3. UIWindow的下一个响应者是UIApplication
  4. UIApplication的下一个响应者是AppDelegate

举例

下面举个例子来说明。如下图所示,触摸点是🌟,那根据命中测试,B 就成为了第一响应者。由于 C 是 B 的父视图、A 是 C 的父视图、同时 A 是 Controller 的根视图,那么按照规则,响应链就是这样的:

视图 B` -> `视图 C` -> `根视图 A` -> `UIViewController 对象` -> `UIWindow 对象` -> `UIApplication 对象` -> `App Delegate

![image-20210318205712058](/Users/juice/Library/Application Support/typora-user-images/image-20210318205712058.png)

沿响应链传递事件

触摸事件首先会由第一响应者进行响应,触发其 touchesBegan等方法

若第一响应者在这个方法中不处理这个事件,则会传递给响应链的下一个响应者触发该方法,以此类推

若最后还没有人响应,则会被丢弃

举例

如下图,A B C 都是 UIView,我们将手指按照🌟的位置和箭头的方向在屏幕上移动一段距离,然后松开手。我们应该能在控制台看到下右图的输出。我们可以看到,A B C 三个视图都积极的响应了每一次事件,每次触摸的发生后,都会先触发 B 的响应方法,然后传递给 C,在传递给 A。但是这种「积极」的响应其实意味着在我们这个例子中,A B C 都不是这个触摸事件的合适接受者。他们之所以「积极」的将事件传递下去,是因为他们查看了这个事件的信息之后,认为自己并不是这个事件的合适处理者。(当然了,我们这边放的是三个 UIView,他们本身确实也不应该能处理事件)

![image-20210318210153196](/Users/juice/Library/Application Support/typora-user-images/image-20210318210153196.png)

如果我们把C换成UIControl类,打印如下。我们会发现响应链传递到C处就停止了,也就是A的touches方法没有被触发

这意味着响应链中,UIControl及其子类默认来说是不会把事件传递下去的,这就做到了当某个控件接受了事件之后,事件的传递就会终止(UISCrollView也会这样的工作机制)

![image-20210318210231844](/Users/juice/Library/Application Support/typora-user-images/image-20210318210231844.png)


总结

总的来说,触摸屏幕幕后事件的传递可分为以下四个步骤:

  1. 通过 命中测试 来找到 第一响应者
  2. 第一响应者 来确定 响应链
  3. 将事件沿 响应链 传递
  4. 事件被某个响应者接收,或没有响应者接收从而丢弃

参考博客:juejin.cn/post/689451…

响应链与手势识别

当手势识别参与响应链

在上面讨论的情况,是不使用UIGestureRecognizer的情况(蓝色部分)下面来讨论在UIGestureRecognizer参与的情况下,事件的处理和接收是如何运作的

![image-20210318213908090](/Users/juice/Library/Application Support/typora-user-images/image-20210318213908090.png)

在通过命中测试找到第一响应者后,会将UITouch分发给UIResponder的touches方法,同时也会分发给手势识别系统,让这两个处理系统同时工作

注意,蓝色部分的流程并不会只执行一次,举例来说:当我们用一根手指在视图上缓慢滑动时,会产生一个UITouch对象,这个UITouch会随着你手指的滑动,不断的更新自身,同时也不断出发touches系列方法,例如类似这样的触发顺序

touchesBegan     // 手指触摸屏幕
touchesMoved     // 手指在屏幕上移动
touchesMoved     // ...
...
touchesMoved     // ...
touchesMoved     // 手指在屏幕上移动
touchesEnded     // 手指离开屏幕

UITouch的gestureRecognizer属性中存储了在寻找第一响应者的过程中收集到的手势,而在不断触发touches系列方法的过程中,手势识别系统也会不停的判断当前这个UITouch是否符合收集到的某个手势

当手势识别成功:第一响应者会收到touchesCancelled的消息,并且该视图不会再收到来自该UITouch的touches事件,同时也让该UITouch关联的其他手势也能收到touchesCancelled,并且不会再收到来自该UITouch的touches事件,这样做就实现了该识别到的手势能够独占UITouch

touchesBegan     // 手指触摸屏幕
touchesMoved     // 手指在屏幕上移动
touchesMoved     // ...
...
touchesMoved     // ...
touchesMoved     // 手指在屏幕上移动
touchesCancelled // 手势识别成功,touches 系列方法被阻断
// 现在手指💅并没有离开屏幕
// 但如果继续滑动🛹的话
// 并不会触发 touches 系列方法

当手势识别未成功:暂未识别成功不代表以后不会识别成功,因此不会阻断响应链。手势的内部状态大部分时候是.possible,即UITouch暂时不与其匹配,但之后有可能会识别成功,而.fail是真的识别失败

举例

现在用一根手指在一个视图上触摸并滑动一段距离

img
  • 在不带手势的情况下,手指按下去的时候,响应者的touchbegan会被触发,手指移动时touchmoved被触发,手指结束抬起时touchended触发,在这个过程中,我们接收到的一直是一个不断更新的UITouch

  • 在视图中添加手势的情况下,我们多了一条响应链同时工作,手势识别系统前半段也处于识别状态,手指滑动一段距离(手指还未抬起前),手势识别系统确定了该UITouch所做的动作是符合UIPanGestureRecognizer的特点的,于是给该视图发送touchesCancelled消息,从而阻止这个UITouch继续触发这个视图的touches系列方法。在这之后,被调用的只有与手势关联的target-action方法(墨绿色节点)

进一步理解

  1. 手势识别器不是响应者,但也有touches系列方法,比它所添加的视图的touches方法更早那么一点触发
  2. 手势识别器的状态在图中未标出:
    • 手势在图中 recognizing 的橙色节点处和 recognized 棕色节点处都处于 .possible 状态
    • 手势在图中绿色节点处的状态变化是 .began -> [.changed] -> ended

更多选择

  1. cancelsTouchesInView:默认是true;如果设置成false,当手势识别成功时,不会发送touchesCancelled给视图,即不会打断视图本身方法的触发,最后的结果时手势和本身方法同时触发
  2. delaysTouchesBegan:该属性默认是 false;如果的设置为true,那么视图的touches方法会被延迟到手势识别成功或者失败之后再开始(其实如果手势识别成功了,视图的touches方法不会被触发...)
  3. delaysTouchesEnded:该属性默认是 true。该属性为 true 时,视图的 touchesEnded 将会延迟大约 0.15s 触发,该属性常用于连击。如果设置为false,就不会延迟视图的touchesEnded方法,将会立即触发,那么我们的双击就会被识别为两次单击

UIControl与手势识别

对于自定义的 UIControl 来说,手势识别的优先级比 UIControl 自身处理事件的优先级高。

举个例子来说:当我们给一个 UIControl 添加了一个 .touchupInside 的方法,又添加了一个 UITapGestureRecognizer 之后。点击这个 UIControl,会看到与手势关联的方法触发了,并且给 UIControl 发送了 touchCancelled,导致其自身的处理时间机制被中断,从而也没能触发那个 .touchupInside 的方法。

那么会导致一个问题:当我们给一个已经拥有点击手势的视图,添加一个UIControl作为子视图,那么无论我们怎么给UIControl添加点击类型的target-action方法,最后结果都是触发其父视图的手势,并且中断UIControl的处理,导致target-action方法永远无法触发

APPLE解决方案:UIKit对部分控件做了特殊处理,当这些控件的父视图上有与该控件冲突功能的手势,会优先触发控件自身的方法,不会触发其父视图上的那个手势

那如果你就想触发手势呢?那就应当将手势添加到目标控件上而不是视图上(就像上面那个 UIControl 添加了一个 .touchupInside 的方法例子),这样的话生效的就是手势了