浅谈事件的分发与响应

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

铺垫

事件

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

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

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

响应者对象

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

UIResponderide

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

第一响应者

在触摸屏幕的事件中:测试

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

响应者链条

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

响应者链条

从上图中能够看到,响应者链条有如下特色spa

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

事件分发

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

传递过程

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

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

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地址
相关文章
相关标签/搜索