iOS中触摸事件详解

触摸事件的生命周期

当我们手指触碰屏幕的那一刻,一个触摸事件便产生了。经过进程间通信,触摸事件被传递到合适的应用之中,在该应用内部触摸事件历经坎坷,最终被释放掉。
整个过程如下图所示:


iOS中触摸事件详解_第1张图片
触摸事件生命周期 图片来源(http://qingmo.me/2017/03/04/FlowOfUITouch/)

如上图所示,触摸事件分为两大阶段:

系统响应阶段

  1. 手指触摸屏幕,屏幕感应到触碰后,将事件交给IOKit处理
  2. IOKit将触摸事件封装成一个IOHIDEvent对象,并通过mach port传递给SpringBord进程

mach port是各个进程的端口,各进程通过它来进行进程间通信
SpringBord是一个系统进程,可以理解为桌面系统。它用来统一管理系统接收到的触摸事件

  1. SpringBoard进程因接收到触摸事件,触发了其主线程runloop的source1事件源的回调。
    此时SpringBoard会根据当前桌面的状态,判断应该由谁处理此次触摸事件。因为事件发生时,你可能正在桌面上翻页,也可能正在刷微博。若是前者(即前台无APP运行),则触发SpringBoard本身主线程runloop的source0事件源的回调,将事件交由桌面系统去消耗;若是后者(即有app正在前台运行),则将触摸事件通过IPC传递给前台APP进程,接下来的事情便是APP内部对于触摸事件的响应了。

app响应阶段

  1. APP进程的mach port接受到SpringBoard进程传递来的触摸事件,主线程的runloop被唤醒,触发了source1回调
  2. source1回调又触发了一个source0回调,将接收到的IOHIDEvent对象封装成UIEvent对象,此时APP将正式开始对于触摸事件的响应
  3. source0回调内部将触摸事件添加到UIApplication对象的事件队列中。事件出队后,UIApplication开始一个寻找最佳响应者的过程,这个过程又称hit-testing
  4. 寻找到最佳响应者后,接下来的事情便是事件在响应链中的传递及响应。事实上,事件除了被响应者消耗,还能被手势识别器或是target-action模式捕捉并消耗掉,其中涉及到事件响应的优先级问题
  5. 触摸事件历经坎坷,要么被某个响应对象捕获后释放,要么最终也没能找到能够响应的对象,然后释放

触摸对象UITouch、事件UIEvent、响应者UIResponder

UITouch

简单理解,一根手指对应一个UITouch对象

  • 更准确一点是,一根手指触摸一次屏幕,就产生一个UITouch对象。多根手指同时触摸屏幕,会产生多个UITouch对象
  • 多个手指先后触摸,系统会根据触摸的位置判断是否更新同一个UITouch对象。若两个手指一前一后触摸同一个位置(即双击),那么第一次触摸时生成一个UITouch对象,第二次触摸更新这个UITouch对象(UITouch对象的 tap count 属性值从1变成2);若两个手指一前一后触摸的位置不同,将会生成两个UITouch对象,两者之间没有联系。
  • 每个UITouch对象记录了触摸的一些信息,包括触摸时间、位置、阶段、所处的视图、窗口等信息

UIEvent

触摸事件

  • 触摸的目的是生成触摸事件供响应者响应,一个触摸事件对应一个UIEvent对象,其中的 type 属性标识了事件的类型(之前说过事件不只是触摸事件)。
  • UIEvent对象中包含了触发该事件的触摸对象的集合,因为一个触摸事件可能是由多个手指同时触摸产生的。触摸对象集合通过 allTouches 属性获取。

UIResponder

每个响应者都是一个UIResponder对象,所有派生自UIResponder的对象,都具有响应事件的能力:

  • UIApplication
  • UIViewController
  • UIView(包含UIWindow)
  • AppDelegate

响应者之所以能够响应事件,是因为UIResponder提供了四个响应事件的方法:

- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;

这几个方法在响应者对象接收到事件的时候会被调用,方法内部用来做出响应。默认的实现是不做处理,并将事件传递给响应链中的上一个结点。

寻找最佳响应者(Hit-Testing)

触摸事件产生后,会经历一个寻找最佳响应者的过程,目的是找到一个具备最高优先级响应权的响应对象,这个过程叫做Hit-Testing,那个命中的最佳响应者称为hit-tested view。

事件自上而下的传递

如文章开头的图所示,触摸事件被添加到UIApplication的事件队列中等待处理,出队后,应用首先将该事件交给自己的主窗口(当前应用最后显示的窗口),询问其能否响应事件。若窗口能响应事件,则传递给子视图询问是否能响应,子视图若能响应则继续询问子视图。子视图询问的顺序是优先询问后添加的子视图,即子视图数组中靠后的视图(因为后添加的视图在层级的上面,可以减少遍历次数)。整体过程如下所示:

UIApplication ——> UIWindow ——> 子视图 ——> ... ——> 子视图 

Hit-Testing本质

视图如何判断能否响应事件?以及视图如何将事件传递给子视图呢?

首先要知道的是,以下几种状态的视图无法响应事件:

  • 不允许交互:userInteractionEnabled = NO
  • 隐藏:hidden = YES 如果父视图隐藏,那么子视图也会隐藏,隐藏的视图无法接收事件
  • 透明度:alpha < 0.01 如果设置一个视图的透明度<0.01,会直接影响子视图的透明度。alpha:0.0~0.01为透明。

每个UIView对象都有一个方法hitTest: withEvent: ,这个方法是Hit-Testing过程中最核心的存在,其作用是返回触摸事件的最佳响应者,同时又作为事件传递的桥梁。
该方法返回一个UIView对象,默认实现为:

  • 若当前视图无法响应事件,则返回nil
  • 若当前视图可以响应事件,但无子视图可以响应事件,则返回自身作为当前视图层次中的事件响应者
  • 若当前视图可以响应事件,同时有子视图可以响应,则返回子视图层次中的事件响应者

一开始UIApplication将事件UIEvent作为参数传递给UIWindow的 hitTest:withEvent: 方法,UIWindow的 hitTest:withEvent: 方法在执行时若判断本身能响应事件,则调用子视图的 hitTest:withEvent: 将事件传递给子视图并询问子视图上的最佳响应者。最终UIWindow返回一个视图层次中的响应者视图给UIApplication,这个视图就是hit-testing的最佳响应者。

注意理解,这里最佳响应者还是返回给了UIApplication的,整个流程就是事件先从UIApplication分发下来,得到最佳响应者之后又将该响应者返回给UIApplication。

根据上面的结论,hitTest:withEvent:方法实现如下

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    //3种状态无法响应事件
     if (self.userInteractionEnabled == NO || self.hidden == YES ||  self.alpha <= 0.01) return nil; 
    //触摸点若不在当前视图上则无法响应事件
    if ([self pointInside:point withEvent:event] == NO) return nil; 
    //从后往前遍历子视图数组 
    int count = (int)self.subviews.count; 
    for (int i = count - 1; i >= 0; i--) 
    { 
        // 获取子视图
        UIView *childView = self.subviews[i]; 
        // 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
        CGPoint childP = [self convertPoint:point toView:childView]; 
        //询问子视图层级中的最佳响应视图
        UIView *fitView = [childView hitTest:childP withEvent:event]; 
        if (fitView) 
        {
            //如果子视图中有更合适的就返回
            return fitView; 
        }
    } 
    //没有在子视图中找到更合适的响应视图,那么自身就是最合适的
    return self;
}

值得注意的是 pointInside:withEvent: 这个方法,用于判断触摸点是否在自身坐标范围内。默认实现是若在坐标范围内则返回YES,否则返回NO。
我们可以修改这个方法来完成一些特殊的需求,如下:


iOS中触摸事件详解_第2张图片
特殊需求

中间的原型按钮是底部Tabbar上的控件,而Tabbar是添加在控制器根视图中的。默认情况下我们点击图中红色方框中按钮的区域,会发现按钮并不会得到响应。
上图对应的视图层级结构大致为:

RootView
└── TableView
└── TabBar
    └── CircleButton

点击红色方框区域后,生成的触摸事件首先传到UIWindow,然后传到控制器的根视图即RootView。RootView经判断可以响应触摸事件,而后将事件传给了子控件TabBar。问题就出在这里,因为触摸点不在TabBar的坐标范围内,因此TabBar无法响应该触摸事件,hitTest:withEvent: 直接返回了nil。而后RootView就会询问TableView是否能够响应,事实上是可以的,因此事件最终被TableView消耗。整个过程,事件根本没有传递到圆形按钮。

根据hitTest:withEvent:方法内部的实现,事件传递到TabBar时,TabBar的 hitTest:withEvent: 被调用,但是 pointInside:withEvent: 会返回NO,如此一来 hitTest:withEvent: 返回了nil。既然如此,可以重写TabBard的 pointInside:withEvent: ,判断当前触摸坐标是否在子视图CircleButton的坐标范围内,若在,则返回YES,反之返回NO。这样一来点击红色区域,事件最终会传递到CircleButton,CircleButton能够响应事件,最终事件就由CircleButton响应了。同时点击红色方框以外的非TabBar区域的情况下,因为TabBar无法响应事件,会按照预期由TableView响应。代码如下:

//TabBar中重写pointInside方法
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    //将触摸点坐标转换到在CircleButton上的坐标
    CGPoint pointTemp = [self convertPoint:point toView:_CircleButton];
    //若触摸点在CricleButton上则返回YES
    if ([_CircleButton pointInside:pointTemp withEvent:event]) {
        return YES;
    }
    //否则返回默认的操作
    return [super pointInside:point withEvent:event];
}

事件的响应及在响应链中的传递

经历Hit-Testing后,UIApplication已经知道事件的最佳响应者是谁了,接下来要做的事情就是:

  1. 将事件传递给最佳响应者响应
  2. 事件沿着响应链传递

事件响应之前

因为最佳响应者具有最高的事件响应优先级,因此UIApplication会先将事件传递给它供其响应。首先,UIApplication将事件通过 sendEvent: 传递给事件所属的window,window同样通过 sendEvent: 再将事件传递给hit-tested view,即最佳响应者。过程如下:

UIApplication ——> UIWindow ——> hit-tested view

假设视图结构为:

rootView
└── redView
└── blueView

在blueView的touchBegan方法打断点,点击blueView后函数调用栈如下:


iOS中触摸事件详解_第3张图片
函数调用

那么问题又来了。这个过程中,假如应用中存在多个window对象,UIApplication是怎么知道要把事件传给哪个window的?window又是怎么知道哪个视图才是最佳响应者的呢?

这两个过程都是传递事件的过程,涉及的方法都是 sendEvent: ,而该方法的参数(UIEvent对象)是唯一贯穿整个经过的线索,那么就可以大胆猜测必然是该触摸事件对象上绑定了这些信息。事实上之前在介绍UITouch的时候就说过touch对象保存了触摸所属的window及view,而event对象又绑定了touch对象,如此一来,就说得通了。怎么验证猜测是否为真呢?
自定义一个Window类,重写 sendEvent: 方法,捕捉该方法调用时参数event的状态,如下图所示:


iOS中触摸事件详解_第4张图片
事件

这两个属性是什么时候绑定到touch对象上的呢?应该是在hit-testing过程中。其实hit-testing本质上做的事情,也就是将这些信息绑定到touch对象上。

事件的响应

前面介绍UIResponder的时候说过,每个响应者必定都是UIResponder对象,通过4个响应触摸事件的方法来响应事件。每个UIResponder对象默认都已经实现了这4个方法,但是默认不对事件做任何处理,单纯只是将事件沿着响应链传递。若要截获事件进行自定义的响应操作,就要重写相关的方法。例如,通过重写 touchesMoved: withEvent: 方法实现简单的视图拖动。

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;

每个响应触摸事件的方法都会接收两个参数,分别对应触摸对象集合事件对象。视图(UIView)作为响应者对象,本身已经实现了 touchesMoved: withEvent: 方法,具体怎么实现的苹果没有开源给我们。因此如果想定义自己的响应行为,必须创建一个自定义视图(继承自UIView),然后重写那几个方法。
这里以touchMoved方法为例,实现视图随着手指移动而移动的响应事件:

//MovedView
//重写touchesMoved方法(触摸滑动过程中持续调用)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    //获取触摸对象
    UITouch *touch = [touches anyObject];
    //获取前一个触摸点位置
    CGPoint prePoint = [touch previousLocationInView:self];
    //获取当前触摸点位置
    CGPoint curPoint = [touch locationInView:self];
    //计算偏移量
    CGFloat offsetX = curPoint.x - prePoint.x;
    CGFloat offsetY = curPoint.y - prePoint.y;
    //相对之前的位置偏移视图
    self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);
}

每个响应者都有权决定是否执行对事件的响应,只要重写相关的触摸事件方法即可。

事件沿着响应链传递

前面一直在提最佳响应者,之所以称之为“最佳”,是因为其具备响应事件的最高优先权。最佳响应者首先接收到事件,然后便拥有了对事件的绝对控制权:即它可以选择独吞这个事件,也可以将这个事件往下传递给其他响应者,这个由响应者构成的视图链就称之为响应链。

响应者对于事件的操作方式:

响应者对于接收到的事件有3种操作:

  • 不拦截,默认操作:事件会自动沿着默认的响应链往下传递
  • 拦截,不再往下分发事件:重写 touchesBegan:withEvent: 进行事件处理,不调用父类的 touchesBegan:withEvent:
  • 拦截,继续往下分发事件:重写 touchesBegan:withEvent: 进行事件处理,同时调用父类的 touchesBegan:withEvent: 将事件往下传递
响应链中的事件传递规则

每一个响应者对象(UIResponder对象)都有一个 nextResponder 方法,用于获取响应链中当前对象的下一个响应者。因此,一旦事件的最佳响应者确定了,这个事件所处的响应链就确定了。

对于响应者对象,默认的 nextResponder 实现如下:

  • UIView
    若视图是控制器的根视图,则其nextResponder为控制器对象;否则,其nextResponder为父视图。
  • UIViewController
    若控制器的视图是window的根视图,则其nextResponder为窗口对象;若控制器是从别的控制器present出来的,则其nextResponder为presenting view controller。
  • UIWindow
    nextResponder为UIApplication对象。
  • UIApplication
    若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewController或app本身,则UIApplication的nextResponder为app delegate。

事件的三徒弟UIResponder、UIGestureRecognizer、UIControl

iOS中,除了UIResponder能够响应事件,手势识别器、UIControl同样具备对事件的处理能力。当这几者同时存在于某一场景下的时候,事件又是怎么处理的呢?

测试场景

app界面如下


iOS中触摸事件详解_第5张图片
测试场景

界面代码如下

- (void)viewDidLoad {
    [super viewDidLoad];
    //底部是一个绑定了单击手势的backView
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTapView)];
    [_backView addGestureRecognizer:tap];
    //上面是一个常规的tableView
    _tableMain.tableFooterView = [UIView new];
    //还有一个和tableView同级的button
    [_button addTarget:self action:@selector(buttonTap) forControlEvents:UIControlEventTouchUpInside];
}

- (void)actionTapView{
    NSLog(@"backview taped");
}

- (void)buttonTap {
    NSLog(@"button clicked!");
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    NSLog(@"cell selected!");
}

这个时候我们点击cell,会发现点不动,或者说点了没有任何反应。但是如果我们长按cell一段时间,发现cell又可以被点击。点击下面的button,一切如常。为什么会发生这种情况呢?

为了测试结果,我们自定义相关的控件类,均重写4个响应触摸事件的方法用来打印日志(每个重写的触摸事件方法都调用了父类的方法以保证事件默认传递逻辑)。

观察各种情况下的日志现象:
  • 现象一 快速点击cell
backview taped
  • 现象二 短按cell
-[GLTableView touchesBegan:withEvent:]
backview taped
-[GLTableView touchesCancelled:withEvent:]
  • 现象三 长按cell
-[GLTableView touchesBegan:withEvent:]
-[GLTableView touchesEnded:withEvent:]
cell selected!
  • 现象四 点击button
-[GLButton touchesBegan:withEvent:]
-[GLButton touchesEnded:withEvent:]
button clicked!

想要了解到底发生了什么,就不得不提到触摸事件响应的二徒弟,手势识别器UIGestureRecognizer

手势识别器UIGestureRecognizer

事实上,手势分为离散型手势(discrete gestures)和持续型手势(continuous gesture)。系统提供的离散型手势包括点按手势([UITapGestureRecognizer]和轻扫手势([UISwipeGestureRecognizer],其余均为持续型手势。

两者主要区别在于状态变化过程:

  • 离散型:
    识别成功:Possible —> Recognized
    识别失败:Possible —> Failed
  • 持续型:
    完整识别:Possible —> Began —> [Changed] —> Ended
    不完整识别:Possible —> Began —> [Changed] —> Cancel
离散型手势

先抛开上面的场景,看一个简单的demo。


iOS中触摸事件详解_第6张图片
离散型手势

控制器的根视图上添加了一个YellowView,并给根视图绑定了一个单击手势识别器。

// HuViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTap)];
    [self.view addGestureRecognizer:tap];
}
- (void)actionTap{
    NSLog(@"View Taped");
}

单击YellowView,日志打印如下:

-[YellowView touchesBegan:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]

从日志上看出YellowView最后Cancel了对触摸事件的响应,而正常应当是触摸结束后,YellowView的 touchesEnded:withEvent: 的方法被调用才对。另外,期间还执行了手势识别器绑定的action 。
官方文档给出了这样的解释:

A window delivers touch events to a gesture recognizer before it delivers them to the hit-tested view attached to the gesture recognizer. Generally, if a gesture recognizer analyzes the stream of touches in a multi-touch sequence and doesn’t recognize its gesture, the view receives the full complement of touches. If a gesture recognizer recognizes its gesture, the remaining touches for the view are cancelled.The usual sequence of actions in gesture recognition follows a path determined by default values of the cancelsTouchesInView, delaysTouchesBegan, delaysTouchesEndedproperties.

大致含义是,Window在将事件传递给hit-tested view(最佳响应者)之前,会先将事件传递给相关的手势识别器并由手势识别器优先识别。若手势识别器成功识别了事件,就会取消hit-tested view对事件的响应;若手势识别器没能识别事件,hit-tested view才完全接手事件的响应权。即手势识别器比UIResponder具有更高的事件响应优先级!

按照这个解释,Window在将事件传递给hit-tested view即YellowView之前,先传递给了控制器根视图上的手势识别器。手势识别器成功识别了该事件,通知Application取消YellowView对事件的响应。

然而看日志,却是YellowView的 touchesBegan:withEvent: 先调用了,既然手势识别器先响应,不应该上面的action先执行吗?其实这里存在一个认知错误,手势识别器的action的调用时机并不是手势识别器接收到事件的时机,而是手势识别器成功识别事件后的时机,即手势识别器的状态变为UIGestureRecognizerStateRecognized。因此从该日志中并不能看出事件是优先传递给手势识别器的,那该怎么证明Window先将事件传递给了手势识别器?

要解决这个问题,只要知道手势识别器是如何接收事件的,然后在接收事件的方法中打印日志对比调用时间先后即可。说出来你可能不信,手势识别器对于事件的响应也是通过这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;

注意,虽然手势识别器通过这几个方法来响应事件,但它并不是UIResponder的子类,这几个方法的声明在 UIGestureRecognizerSubclass.h 中,跟UIResponder中的方法实现并不是一回事。

这样一来,我们便可以自定义一个单击手势识别器的类,重写这几个方法来监听手势识别器接收事件的时机。创建一个UITapGestureRecognizer的子类,重写响应事件的方法,每个方法中调用父类的实现,并替换demo中的手势识别器。另外需要在.m文件中引入 import ,因为相关方法声明在该头文件中。

// HuTapGestureRecognizer (继承自UITapGestureRecognizer)
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
    NSLog(@"%s",__func__);
    [super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{
    NSLog(@"%s",__func__);
    [super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event{
    NSLog(@"%s",__func__);
    [super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event{
    NSLog(@"%s",__func__);
    [super touchesCancelled:touches withEvent:event];
}

现在,再次点击YellowView,日志如下:

-[HuTapGestureRecognizer touchesBegan:withEvent:]
-[YellowView touchesBegan:withEvent:]
-[HuTapGestureRecognizer touchesEnded:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]

很明显,确实是手势识别器先接收到了事件。之后手势识别器成功识别了手势,执行了action,再由Application取消了YellowView对事件的响应。

Window怎么知道要把事件传递给哪些手势识别器?

之前探讨过Application怎么知道要把event传递给哪个Window,以及Window怎么知道要把event传递给哪个hit-tested view的问题,答案是这些信息都保存在event所绑定的touch对象上。手势识别器也是一样的,event绑定的touch对象上维护了一个手势识别器数组,里面的手势识别器是在hit-testing的过程中收集的。打个断点看一下touch上绑定的手势识别器数组:

iOS中触摸事件详解_第7张图片
手势识别器捕捉

Window先将事件传递给这些手势识别器,再传给hit-tested view。一旦有手势识别器成功识别了手势,Application就会取消hit-tested view对事件的响应。

持续型手势

将上面Demo中视图绑定的单击手势识别器用滑动手势识别器(UIPanGestureRecognizer)替换。

- (void)viewDidLoad {
    [super viewDidLoad];
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(actionPan)];
    [self.view addGestureRecognizer:pan];
}
- (void)actionPan{
    NSLog(@"View panned");
}

在YellowView上执行一次滑动,日志打印如下:

-[YellowView touchesBegan:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesCancelled:withEvent:]
View panned
View panned
View panned
...

在一开始滑动的过程中,手势识别器处在识别手势阶段,滑动产生的连续事件既会传递给手势识别器又会传递给YellowView,因此YellowView的 touchesMoved:withEvent: 在开始一段时间内会持续调用;当手势识别器成功识别了该滑动手势时,手势识别器的action开始调用,同时通知Application取消YellowView对事件的响应。之后仅由滑动手势识别器接收事件并响应,YellowView不再接收事件。

另外,在滑动的过程中,若手势识别器未能识别手势,则事件在触摸滑动过程中会一直传递给hit-tested view,直到触摸结束。

总结一下手势识别器与UIResponder对于事件响应的联系:

当触摸发生或者触摸的状态发生变化时,Window都会传递事件寻求响应。

  • Window先将绑定了触摸对象的事件传递给触摸对象上绑定的手势识别器,再发送给触摸对象对应的hit-tested view。
  • 手势识别器识别手势期间,若触摸对象的触摸状态发生变化,事件都是先发送给手势识别器再发送给hit-test view。
  • 手势识别器若成功识别了手势,则通知Application取消hit-tested view对于事件的响应,并停止向hit-tested view发送事件;
  • 若手势识别器未能识别手势,而此时触摸并未结束,则停止向手势识别器发送事件,仅向hit-test view发送事件。
  • 若手势识别器未能识别手势,且此时触摸已经结束,则向hit-tested view发送end状态的touch事件以停止对事件的响应。
手势识别器的3个属性
@property(nonatomic) BOOL cancelsTouchesInView;
@property(nonatomic) BOOL delaysTouchesBegan;
@property(nonatomic) BOOL delaysTouchesEnded;

cancelsTouchesInView

默认为YES。表示当手势识别器成功识别了手势之后,会通知Application取消响应链对事件的响应,并不再传递事件给hit-test view。若设置成NO,表示手势识别成功后不取消响应链对事件的响应,事件依旧会传递给hit-test view。

demo中设置: pan.cancelsTouchesInView = NO

滑动时日志如下:

-[YellowView touchesBegan:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesMoved:withEvent:]
View panned
View panned
-[YellowView touchesMoved:withEvent:]
View panned
-[YellowView touchesMoved:withEvent:]
...

即便滑动手势识别器识别了手势,Application也会依旧发送事件给YellowView。

delaysTouchesBegan

默认为NO。默认情况下手势识别器在识别手势期间,当触摸状态发生改变时,Application都会将事件传递给手势识别器和hit-tested view;若设置成YES,则表示手势识别器在识别手势期间,截断事件,即不会将事件发送给hit-tested view。

设置 pan.delaysTouchesBegan = YES

日志如下:

View panned
View panned
View panned
View panned
...

因为滑动手势识别器在识别期间,事件不会传递给YellowView,因此期间YellowView的 touchesBegan:withEvent:touchesMoved:withEvent: 都不会被调用;而后滑动手势识别器成功识别了手势,也就独吞了事件,不会再传递给YellowView。因此只打印了手势识别器成功识别手势后的action调用。

delaysTouchesEnded

默认为NO。默认情况下当手势识别器未能识别手势时,若此时触摸已经结束,则会立即通知Application发送状态为end的touch事件给hit-tested view以调用 touchesEnded:withEvent: 结束事件响应;若设置为YES,则会在手势识别失败时,延迟一小段时间(0.15s)再调用响应者的 touchesEnded:withEvent:

总结:手势识别器比响应链具有更高的事件响应优先级。

大徒弟—UIControl

UIControl是系统提供的能够以target-action模式处理触摸事件的控件,iOS中UIButton、UISegmentedControl、UISwitch等控件都是UIControl的子类。当UIControl跟踪到触摸事件时,会向其上添加的target发送事件以执行action。值得注意的是,UIConotrol是UIView的子类,因此本身也具备UIResponder应有的身份。

关于UIControl,此处介绍两点:

  • target-action执行时机及过程
  • 触摸事件优先级

UIControl作为能够响应事件的控件,必然也需要待事件交互符合条件时才去响应,因此也会跟踪事件发生的过程。不同于UIControl以及UIGestureRecognizer通过 touches 系列方法跟踪,UIControl有其独特的跟踪方式:

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)cancelTrackingWithEvent:(nullable UIEvent *)event;

乍一看,这4个方法和UIResponder的那4个方法几乎吻合,只不过UIControl只能接收单点触控,因此接收的参数是单个UITouch对象。这几个方法的职能也和UIResponder一致,用来跟踪触摸的开始、滑动、结束、取消。不过,UIControl本身也是UIResponder,因此同样有 touches 系列的4个方法。
事实上,UIControl的 Tracking 系列方法是在 touch 系列方法内部调用的。比如 beginTrackingWithTouch 是在 touchesBegan 方法内部调用的, 因此它虽然也是UIResponder,但 touches 系列方法的默认实现和UIResponder本类还是有区别的。

当UIControl跟踪事件的过程中,识别出事件交互符合响应条件,就会触发target-action进行响应。UIControl控件通过 addTarget:action:forControlEvents: 添加事件处理的target和action,当事件发生时,UIControl通知target执行对应的action。说是“通知”其实很笼统,事实上这里有个action传递的过程。当UIControl监听到需要处理的交互事件时,会调用 sendAction:to:forEvent: 将target、action以及event对象发送给全局应用,Application对象再通过 sendAction:to:from:forEvent: 向target发送action。


iOS中触摸事件详解_第8张图片
target-action过程

因此,可以通过重写UIControl的 sendAction:to:forEvent: 或 sendAction:to:from:forEvent: 自定义事件执行的target及action。

另外,若不指定target,即 addTarget:action:forControlEvents: 时target传空,那么当事件发生时,Application会在响应链上从上往下寻找能响应action的对象。

触摸事件优先级

当原本关系已经错综复杂的UIGestureRecognizer和UIResponder之间又冒出一个UIControl,触摸事件的优先级又成了什么样呢?
UIControl会阻止父视图上的手势识别器行为,也就是UIControl处理事件的优先级比UIGestureRecognizer高,但前提是相比于父视图上的手势识别器。

测试场景:在BlueView上添加一个button,同时给button添加一个target-action事件。

  • 示例一:在BlueView上添加点击手势识别器
  • 示例二:在button上添加手势识别器

测试结果:示例一中,button的target-action响应了单击事件;示例二中,BlueView上的手势识别器响应了事件。过程日志打印如下:

//示例一
-[CLTapGestureRecognizer touchesBegan:withEvent:]
-[CLButton touchesBegan:withEvent:]
-[CLButton beginTrackingWithTouch:withEvent:]
-[CLTapGestureRecognizer touchesEnded:withEvent:] after called state = 5
-[CLButton touchesEnded:withEvent:]
-[CLButton endTrackingWithTouch:withEvent:]
按钮点击行为
//示例二
-[CLTapGestureRecognizer touchesBegan:withEvent:]
-[CLButton touchesBegan:withEvent:]
-[CLButton beginTrackingWithTouch:withEvent:]
-[CLTapGestureRecognizer touchesEnded:withEvent:] after called state = 3
手势触发行为
-[CLButton touchesCancelled:withEvent:]
-[CLButton cancelTrackingWithEvent:]

原因分析:点击button后,事件先传递给手势识别器,再传递给作为hit-tested view存在的button(UIControl本身也是UIResponder,这一过程和普通事件响应者无异)。示例一中,由于button阻止了父视图BlueView中的手势识别器的识别,导致手势识别器识别失败(状态为failed 枚举值为5),button完全接手了事件的响应权,事件最终由button响应;示例二中,button未阻止其本身绑定的手势识别器的识别,因此手势识别器先识别手势并识别成功(状态为ended 枚举值为3),而后通知Application取消响应链对事件的响应,因为 touchesCancelled 被调用,同时 cancelTrackingWithEvent 跟着调用,因此button的target-action得不到执行。

结论:UIControl比其父视图上的手势识别器具有更高的事件响应优先级。

解惑

回到本节开始时的测试例子中来,四种现象得到了解释。
先看现象二,短按 cell无法响应,日志如下:

-[GLTableView touchesBegan:withEvent:]
backview taped
-[GLTableView touchesCancelled:withEvent:]

这个日志和上面离散型手势Demo中打印的日志完全一致。短按后,BackView上的手势识别器先接收到事件,之后事件传递给hit-tested view,作为响应者链中一员的GLTableView的 touchesBegan:withEvent: 被调用;而后手势识别器成功识别了点击事件,action执行,同时通知Application取消响应链中的事件响应,GLTableView的 touchesCancelled:withEvent: 被调用。

因为事件被取消了,因此Cell无法响应点击。

再看现象三,长按cell能够响应,日志如下:

-[GLTableView touchesBegan:withEvent:]
-[GLTableView touchesEnded:withEvent:]
cell selected!

长按的过程中,一开始事件同样被传递给手势识别器和hit-tested view,作为响应链中一员的GLTableView的 touchesBegan:withEvent: 被调用;此后在长按的过程中,手势识别器一直在识别手势,直到一定时间后手势识别失败,才将事件的响应权完全交给响应链。当触摸结束的时候,GLTableView的 touchesEnded:withEvent: 被调用,同时Cell响应了点击。

现在回到现象一。按照之前的分析,快速点击cell,讲道理不管是表现还是日志都应该和现象二一致才对。然而日志仅仅打印了手势识别器的action执行结果。分析一下原因:GLTableView的 touchesBegan 没有调用,说明事件没有传递给hit-tested view。那只有一种可能,就是事件被某个手势识别器拦截了。目前已知的手势识别器拦截事件的方法,就是设置 delaysTouchesBegan为YES,在手势识别器未识别完成的情况下不会将事件传递给hit-tested view。然后事实上并没有进行这样的设置,那么问题可能出在别的手势识别器上。

其实是苹果内部的机制造成了这种现象,细节请查看此处。大概理解为:某一个类拦截了事件并延迟了0.15s发送。又因为点击时间比0.15s短,在发送事件前触摸就结束了,因此事件没有传递到hit-tested view,导致TableView的 touchBegin 没有调用。而现象二,由于短按的时间超过了0.15s,手势识别器拦截了事件并经过0.15s后,触摸还未结束,于是将事件传递给了hit-tested view,使得TableView接收到了事件。因此现象二的日志虽然和离散型手势Demo中的日志一致,但实际上前者的hit-tested view是在触摸后延迟了约0.15s左右才接收到触摸事件的。

总结

  • 触摸发生时,系统内核生成触摸事件,先由IOKit处理封装成IOHIDEvent对象,通过IPC传递给系统进程SpringBoard,而后再传递给前台APP处理。
  • 事件传递到APP内部时被封装成开发者可见的UIEvent对象,先经过hit-testing寻找第一响应者,而后由Window对象将事件传递给hit-tested view,并开始在响应链上的传递。
  • UIRespnder、UIGestureRecognizer、UIControl都可以对触摸事件作出响应,UIGestureRecognizer优先级高于UIRespnder,而UIControl又高于父控件的UIGestureRecognizer。如果是同级控件,那么UIControl可以看做是一个普通的UIRespnder来对待,即优先级低于UIRespnder。

你可能感兴趣的:(iOS中触摸事件详解)