浅谈事件的分发与响应

在 iOS 开发中,当用户用手指点击了一下屏幕,会发生什么呢?系统是怎么判断用户点击的位置呢?我们开发者又如何做出“没有bug”的交互呢?带着这些疑问,我们一起谈谈事件的分发与响应。

铺垫

事件

顾名思义,事件就是发生的一件事,对于APP来说,就是发生的一个操作。具体的就是用户点击一下屏幕就会出现一个事件(体现为一个UIEvent),即一个触摸事件。其实,对于 iOS 设备的用户来说,他们操作设备的方式主要有四种方式:触摸屏幕、晃动设备、通过遥控设施控制设备、按压屏幕。 对应的事件类型UIEventType有以下三种:

  1. 触屏事件(Touch Event)
  2. 运动事件(Motion Event)
  3. 远端控制事件(Remote-Control Event)
  4. 按压事件(Presses Event)

我们的主题是探索用户用手指点击屏幕会发生什么,所以我们将注意力放在触摸事件上。

响应者对象

上面我们了解到,当我们点击了屏幕,就会出现一个事件。既然事件出现了,那么就需要一个一个响应和处理这个事件的对象,那就是我们的响应者对象。这些响应者对象都有一个共同的特征,就是他们都继承自UIResponder。我们熟知的响应者对象有UIApplicationUIWindowUIViewController和所有继承自UIView的 UIKit 类

UIResponder

  • 所有响应对象的基类
  • 定义了处理上述各种事件的接口;

第一响应者

在触摸屏幕的事件中:

  • 指的是当前接受触摸的响应者对象(通常是一个UIView对象);
  • 即表示当前该对象正在与用户交互,它是响应者链的开端;
  • 整个响应者链和事件分发的使命都是找出第一响应者。

响应者链条

上面介绍了响应者对象,也知道了UIApplicationUIWindowUIViewControllerUIView这些都是响应者。那么一个 APP 会存在很多响应者对象。由这一系列的响应者对象就构成了一个层次结构,那就是响应者链条

响应者链条

从上图中可以看到,响应者链条有以下特点

  1. 响应者链头部通常是由视图(UIView)构成的;
  2. 如果该视图是属于视图控制器(UIViewController)的,那么下一个响应者是该视图控制器,然后再将事件响应到它的父视图(Super View)中;
  3. 如果该视图没有视图控制器(UIViewController),那么下一个响应者就直接是它的父视图(Super View);
  4. 一直响应直至其对象是单例的窗口(UIWindow
  5. 再下一个响应者就是单例的应用(UIApplication),也是响应者链条的终点
  6. 下一个响应者指向 nil ,结束整个循环

事件分发

回到开篇的情况,当用户点击了一下屏幕。系统检测到用户的触摸事件,就会将其打包成一个事件(即UIEvent对象),并将这个UIEvent对象放入 Application 的事件队列中。这时系统只是知道有这么一个事件发生,虽然响应者链条中有很多有处理事件能力的响应者,但是它不知道谁才是响应这个事件的最佳人选。 因此,系统会从UIApplication开始,顺着响应者链条向上寻找那个最佳的人选。这个寻找的过程就是事件的分发过程

扫描二维码关注公众号,回复: 4212045 查看本文章

传递过程

  • 第一步UIApplication将这个事件从事件队列中拿出来,从顶部开始询问谁才是最佳人选;
  • 第二步UIWindow会最先获取到事件,并开始使用hitTest:withEvent:来判断下面他的子控件中谁才是最佳人选;
  • \ldots\ldots\ldots
  • 第 N - 1 步:当前UIView继续询问他的子视图是不是最佳人选;
  • 第 N 步:当前UIView不是被点击的的视图,orz,上一个UIView就是最佳人选了。

从用户视角来看,系统通过hitTest:withEvent:方法,从视图的底部一直向表面寻找最佳人选。因为是一直查找,只有在所有的查找都完成了,判断出当前视图没有子视图或者他的子视图都不适合了,那么当前视图就是最佳人选了。(所以你只是点了一个你一眼就看中的视图,其实系统是从底部开始,一顿连续操作才找到你想要的东西[汗颜])

hitTest:withEvent:

上面的事件分发过程中,大量使用了hitTest:withEvent:这个方法,它的处理流程如下:

  • 首先调用当前视图的pointInside:withEvent:方法,判断触摸点是否在当前视图内
    • 若返回NO,则hitTest:withEvent:返回nil
    • 若返回YES,则向当前视图的所有子视图发送hitTest:withEvent:消息,所有子视图的遍历顺序是从最顶层视图一直到到最底层视图,即从subviews数组的末尾向前遍历。
  • 若有子视图返回非空对象,则hitTest:withEvent:方法返回此对象,处理结束;
  • 若所有子视图都返回nil,则hitTest:withEvent:方法返回自身,即self,处理结束。

下面我们用一个图解来理解一下这个hitTest:withEvent:

Demo

假如用户点击了View D,结合上图详细介绍一下hitTest:withEvent:过程: (hitTest:withEvent:简称hitTestpointInside:withEvent:简称pointInsideView X简称X

  1. A 是 UIWindow 的根视图,因此,UIWindow 对象会首先对 A 进行hitTest
  2. 显然用户点击的范围是在 A 的范围内,这时会继续检查 A 的子视图;
  3. 这时候会有 B 和 C 两个分支,由于 C 是后添加的子视图,因此先对 C 进行hitTest
    • 显然点击的范围在 C 内;
  4. 这时候有 D 和 E 两个分支,按顺序先检查 E
    • 显然点击的范围不在 E 内,对应的hitTest:withEvent:返回 nil;
    • 显然点击的范围在 D 内,由于 D 没有子视图(也可以理解成对 D 的子视图进行hitTest时返回了 nil);
  5. 因此,D 的hitTest会将 D 返回,再往回回溯,就是 C 的hitTest返回 D,A 的hitTest返回 D。

至此,本次点击事件的第一响应者就通过响应者链的事件分发逻辑成功找到了

除了使用pointInside:withEvent:判断是否是响应者,还有下面三种情况会使hitTest:withEvent:返回 nil:

  • 隐藏hidden=YES的视图;
  • 禁止用户操作userInteractionEnabled=YES的视图;
  • 透明度小于0.01alpha<0.01的视图。

因此hitTest:withEvent:的实现可能是:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}
复制代码

事件响应

前面说了一大堆事件的分发,其实就是为了找到响应事件的最佳人选,这个最佳人选就是在介绍响应者链条的时候,最底下的那个View。从这个 View 开始我们沿着响应者链条的方向进行响应。

开篇我们的说的是用户点击屏幕的场景,因此,响应者会按照当前UITouch的所处阶段使用下面的方法进行响应:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
复制代码
  • 在响应方法内部,我们也可以调用调用[super touches...]将这个触摸事件继续分发给父控件的对应方法处理。然后父控件还可以将该事件继续向上传递,直到传递给UIApplication对象。这一系列的响应者对象就构成了一个响应者链条
  • 如果不调用[super toucher...]事件不会继续沿着响应者链条进行响应

小结

事件的分发响应都是在响应者链条上进行的,只不过是两者传递的方向不同。

传递方向
上面的图片中省略了 UIViewController,这里说明一下他的位置:

  • 事件分发过程中没有ViewController的事
  • 事件响应的过程中,传递的方向如下:

UIController情况

至此,我们已经大概了解了当用户用手指点击了一下屏幕,会发生什么。

通过对这些的了解,我们可以通过使用下面两种方式来实现一些特殊需求:

  • 重写 UIView 中的hitTest:withEvent:来影响事件分发
  • 重写 UIResponder 中的touches系列方法来影响事件响应

问题

我在测试hitTest:withEvent:的过程中,通过运行时给每个hitTest:withEvent:都添加了打印方法,在点击绿色的B View的时候出现了下面的重复寻找的情况(不单只点击B View时候有出现)

这个现象我不太会解释...希望有人可以解答一下。

问题
Demo地址

猜你喜欢

转载自juejin.im/post/5bf952bde51d4552ba0c7033