控件知识程序员IOS面试专题

iOS触摸事件处理

2017-12-06  本文已影响429人  闫仕伟

在开发过程中,大家或多或少的都会碰到令人头疼的手势冲突问题,正好前两天碰到一个类似的bug,于是借着这个机会了解了iOS中的事件传递与处理的相关内容,整理出来方便以后查阅。

iPhone的成功,很大的一部分在于用户可以以多种方式操纵他们的设备。大体上iOS的事件分为三类:触摸事件(手势操作),运动事件(摇一摇),远程控制事件(耳机线控),本文主要整理的是触摸事件,对其它两种就不多做介绍了,感兴趣的同学可以自己查阅资料。

事件的生命周期

从手指触摸屏幕,触摸事件的传递大概经历了3个阶段,系统响应阶段-->SpringBoard.app处理阶段-->前台App处理阶段,大致的流程如下图: uitouchflow.png
起始阶段
系统响应阶段
SpringBoard.app处理阶段

SpringBoard.app是一个系统进程,可以理解为桌面系统,可以统一管理和分发系统接收到的触摸事件。

App内部响应阶段

Source0和Source1都可用于线程(或进程)交互,但交互的形式有所不同,Source1监听端口,当端口有消息到达时,响应的Source1就会被触发回调,完成响应的操作;而Source0并不监听端口,让Source0执行回调需要手动标记Source0为待处理状态,还需要呼醒Source0所在的Runloop。从Source1和Source0的交互方式了解到,Source1的交互会主动呼醒所在的Runloop,而Source0的交互则需要依赖其他线程来呼醒Source0所在的Runloop。一次Runloop只能执行一个Source1的回调,但可以执行多个待处理的Source0的回调。

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

能够响应触摸事件的例如UIView,UIButton,UIViewController,UIApplication,Appdelegate等都继承自UIResponder类,一个页面上通常会有许许多多个这种类型的对象,都可以对点击事件作出响应。为了避免冲突,这就需要有一个先后顺序,也就是响应的优先级。Hit-Testing的目的就是找到具有最高优先级的响应对象。
寻找的具体流程如下:

  1. UIApplication首先将事件队列中的事件取出,传递给窗口对象。如果有多个窗口,则优先询问windows数组的最后一个窗口。
  2. 如果窗口不能响应事件,则将事件传递给倒数第二个窗口,以此类推。如果窗口能够响应事件,则再依次询问该窗口的子视图。
  3. 重复步骤2。
  4. 若视图的所有子视图均不是最佳响应者,则自身就是最合适的响应者。
    另外需要注意的是,一下几种状态的视图无法响应事件:

怎么样验证一下上面所说的Hit-Testing的顺序呢,看一下UIView的API,里面会有一个hitTest:withEvent:方法,这个方法的主要作用就是查询并返回事件在当前视图中的响应者,每个被询问到的视图对象都会调用这个方法来返回当前视图层的响应者。

所以我们可以根据通过观察该方法的调用顺序,来确定Hit-Testing的顺序。


屏幕快照 2017-11-27 下午2.32.23.png

如图所示,A视图上面添加了子视图B和C,B上面添加了子视图D,C上面添加了子视图E和F。创建一个继承自UIView的类HTView,重写hitTest:withEvent:方法:

@interface HTView : UIView
@property (nonatomic, strong) NSString *name; //视图的名字
@end

@implementation HTView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"进入%@视图-%s", self.name, __func__);
    UIView *view = [super hitTest:point withEvent:event];
    NSLog(@"离开%@视图-%s", self.name, __func__);
    [图片上传中...(屏幕快照 2017-11-27 下午3.58.14.png-334090-1511769555186-0)]

return view;
}
@end

在ViewController中添加如下代码:

#import "ViewController.h"
#import "HTView.h"
@interface ViewController ()
@property (weak, nonatomic) IBOutlet HTView *aView;
@property (weak, nonatomic) IBOutlet HTView *bView;
@property (weak, nonatomic) IBOutlet HTView *cView;
@property (weak, nonatomic) IBOutlet HTView *dView;
@property (weak, nonatomic) IBOutlet HTView *eView;
@property (weak, nonatomic) IBOutlet HTView *fView;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    self.aView.name = @"A";
    self.bView.name = @"B";
    self.cView.name = @"C";
    self.dView.name = @"D";
    self.eView.name = @"E";
    self.fView.name = @"F";
}
@end

点击E视图,打印的结果如下:


屏幕快照 2017-11-27 下午4.16.08.png

由打印的结果可知:

  1. 事件首先传递给视图A。
  2. A判断自身能响应事件,继续从后向前遍历A的子视图,因为C比B后添加,因此首先传递给C。
  3. C判断自身能响应事件,继续从后向前遍历C的子视图,因为F比E后添加,因此首先传递给F。
  4. F判断自身不能响应事件,C又将事件传递给E。
  5. E判断自身能响应事件,同时E已经没有子视图,因此最终E就是最佳响应者。

(这里有一个问题,为什么遍历视图的时候需要从后往前遍历呢?为什么B和C都是A的子视图,判断出了C视图能响应事件之后,B视图没有继续调用hitTest:withEvent:方法呢?)

那么视图又是怎么判断自身是否可以响应事件的呢?答案是通过poingInside:withEvent这个方法来判断触摸点是否在视图的坐标范围内。那么结合上面的hitTest调用的相关知识来看,hitTest:withEvent方法的大概实现已经呼之欲出了:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // 先判断视图是否处于不能响应事件的3种状态
    if (self.userInteractionEnabled == NO || self.hidden || self.alpha < 0.01) {
        return nil;
    }
    // 判断触摸点是否在视图的坐标范围内
    if ([self pointInside:point withEvent:event] == NO) {
        return nil;
    }
    // 从后向前遍历视图的子视图
    for (int i = (int)self.subviews.count - 1; i >= 0; i--) {
        UIView *subView = self.subviews[i];
        // 坐标转换,把触摸点的位置转换为子视图坐标系下的坐标
        CGPoint subPoint = [self convertPoint:point toView:subView];
        // 对子视图进行Hit-Testing
        UIView *subHTView = [subView hitTest:subPoint withEvent:event];
        // 如果子视图有最佳响应者,返回该最佳响应者视图,结束循环
        if (subHTView) {
            return subHTView;
        }
    }
    // 如果子视图中没有最佳响应者,返回自己
    return self;
}

重新点击,发现视图仍然可以正常响应点击事件,证明我们所写的实现与系统的方法基本相同。这里我们就可以回答上面括号里面的问题了,为什么要从后往前遍历呢?因为数组里面后面的视图是后添加的,后添加的视图一般都是在视图的上层,会把先添加的视图遮挡,我们自然不会想要去点击被遮挡住的位置。为什么B视图没有调用hitTest:withEvent:方法呢?因为已经确定触摸点在C视图上了。如果B和C没有重叠部分,自然不用再判断B视图能否响应,如果有重叠部分,后添加的C自然是在上层,所以C优先响应,也不会再对B视图进行判断。
我们通过这段代码还可以解释另外一种现象,子视图超出了父视图的范围,点击子视图在父视图之外的部分没有反应。这是因为在进行Hit-Testing的时候,父视图就已经判断自己不能响应事件了,自然不会再去询问子视图是否能够响应事件。

如果碰到这种需求怎么办?比如说tabBar中间的按钮凸起


FD64ADCA-17B2-496B-AC09-5A7AEEDFF183.png

这时候就需要重写父视图的pointInside:withEvent:方法了,在tabBar中判断当前触摸位置是否在中间凸起的按钮的坐标范围内,如果在,就返回YES。这样得以让触摸事件传递到中间的按钮上,并确定按钮为最佳响应者。代码如下:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    //将触摸点坐标转换到在circleButton上的坐标
    CGPoint subPoint = [self convertPoint:point toView:self.circleButton];
    //若触摸点circleButton上则返回YES
    if ([self.circleButton pointInside:subPoint withEvent:event])       {
        return YES;
    }
    //否则返回默认的操作
    return [super pointInside:point withEvent:event];
}

这里还有另一个问题,为什么Hit-Testing过程进行了两次?
刚开始的猜想是两次执行时的参数event不一样,点击A视图的父视图,打印event对象:


1A71FACD-C456-4EA5-8290-FD9FA5805341.png 1A71FACD-C456-4EA5-8290-FD9FA5805341.png

从结果可以看到,两次的event对象地址一样,且allTouches集合里面没有UITouch对象(看到有的简友提到两次调用的原因是UITouch对象的状态不同,一次是begin,一次是end,这一点我持怀疑态度,因为没法验证。看上面提到的App响应阶段的操作,source0把IOHIDEvent对象封装成了UIEvent对象,再结合这里hitTest:withEvent:方法里面没有UITouch对象,然后我们还可以看一下touchesBegan方法里面是有touches对象的,我猜测UITouch对象是依据UIEvent对象的某些属性生成的,这个过程发生在Hit-Testing过程之后。既然UITouch对象都没有生成,那就更谈不上UITouch状态的变化了,当然这里是我的臆想,希望有大神看到之后进行指正)。然后我在hitTest:withEvent:里面打了一个断点,在event里面发现了这么一个东西:


20248863-DD94-418F-889F-1C94BABCF5F9.png

类型是_IOHIDEvent。这不就是最开始封装成UIEvent那个类吗,触摸事件的相关信息应该就储存在这个成员变量里面。这是一个私有成员变量,直接取是取不出来的,不过这难不倒我们,我们有runtime可以取。在hitTest:withEvent:里面添加如下代码:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    Ivar ivar = class_getInstanceVariable([event class], "_hidEvent");
    NSLog(@"%@", object_getIvar(event, ivar));
    return [super hitTest:point withEvent:event];
}

点击A视图的父视图(这里只会对A进行l两次hitTest,方便我们查看),第一次打印:

0B0483B1-E4A8-4EBB-859F-2A34159A7E4D.png

第二次打印:

7646AC0D-018F-4E5A-9D87-2F45ACE6F813.png

然后满怀希望的一项一项比对里面的信息,貌似除了Total Latency(总延时)之外都是一样的,而且这里面好多字段我也不知道什么意思,找了半天也没有找到_IOHIDEvent的API(有这方面资料的同学请不吝赐教)。按照这种方式把event里面的成员变量一个个打印,大部分都是空数组或者空字典。看来这两次hitTest:withEvent:的区别不在于参数上面。那么会不会在方法的调用顺序上面有区别呢?依然是点击A视图的父视图,看一下方法调用栈:

第一次调用栈 第二次调用栈

可以看到两次调用的不同就在于红框圈出来的部分。第一次有对UIWindow进行Hit-Testing,第二次没有,而是直接对UIView进行了Hit-Testing。至于其中的原因,我也不知道。。。。。扯了那么多,也没得出个结果,不要打我。

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

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

  1. 将事件传递给最佳响应者响应。
  2. 事件沿着响应链传递,直到有UIResponder对象对此事件负责。
事件响应的前奏

因为最佳响应者具有最高的事件响应优先级,因此UIApplication会先将事件传递给它供其响应。首先,UIApplication将事件通过sendEvent:传递给事件所属的window,window同样通过sendEvent:再将事件传递给最佳响应者。在自定义的View里面重写touchesBegan:方法,打上断点,可以看到调用栈如下:

touchesBegan调用栈

那么UIApplication和UIWindow又是怎么知道应该把事件发送给哪个视图的呢?我们可以看一下touches里面的UITouch对象的属性。里面有window和view两个字段,分别代表事件分发的UIWindow和最佳响应者的地址(需要注意的是这个地址并不代表最终响应事件的UIResponder地址,只是会最先分发给它,它也可以不对事件作出响应)。还有一个字段gestureRecongnizers,这里面存储了响应链上的视图上面添加的手势(这里面的东西在后面讲到的手势识别器会有涉及)。


FEA6ED47-036A-4086-94D5-802D11DFF86D.png
事件的响应

响应者链上面的每个响应者都是继承于UIResponder的对象,每个UIResponder对象都默认实现了4个响应触摸事件的方法:

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

系统默认的方法不对事件做任何处理,只是将事件沿着响应者链传递。如果想要截获事件进行自定义的响应操作,就要重写相关的方法。例如:重写touchesMoved方法实现简单的视图拖动。

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self.view];
    CGPoint prePoint = [touch previousLocationInView:self.view];
    self.aView.transform = CGAffineTransformTranslate(self.aView.transform, point.x - prePoint.x, point.y - prePoint.y);
}

该方法在父视图和子视图中重写会有不同的效果,如果在父视图中重写,点击位置在你要移动的子视图之外也可以移动。如果在子视图中重写,则点击位置需要在子视图的范围之内才可以移动。

事件在响应者链上的传递

前面一直在提最佳响应者,之所以称为"最佳",是因为其具备响应事件的最高优先权(响应链顶端的男人)。最佳响应者首先接收到事件,然后便拥有了对事件的绝对控制权:它既可以选择独吞这个事件,也可以将这个事件往下传递给其它响应者,这个由响应者构成的链就称之为响应者链。
需要注意的是,上面也说到了事件的传递,这与此处所说的事件的传递有本质的区别。上面所说的事件的传递的目的是为了寻找事件的最佳响应者,是自下而上的传递。而这里的事件传递的目的是响应者对事件作出响应,这个过程是自上而下的,前者为"寻找",后者为"响应"。
响应者对于事件的操作方式:
响应者对于事件的拦截以及传递都是通过touchesBegan:withEvent:方法控制的,该方法的默认实现是将事件沿着默认的响应链往下传递。(如果你在不同的UIResponder对象上面都声明了touchMoved方法,那么这些对象都可以执行该方法,因为touchesBegan方法默认是把事件沿着响应链传递的。如果只想让一个对象响应touchesMoved方法,需要重写touchesBegan方法以拦截事件)
响应者对于接收到的事件有3种操作:

响应链中的事件传递规则:

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

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

792F789A-7322-4E73-A92D-513340F7AFB4.png
上图是官网对于响应链的示例展示,若触摸发生在UITextField上,则事件的传递顺序是:
UITextField->UIView->UIView->UIViewController->UIWindow->UIApplication->UIApplicationDelegate
图中虚线箭头是指若该UIView是作为UIViewController根视图存在的,则其nextResponder为UIViewController对象;若是直接add在UIWindow上的,则其nextResponder为UIWindow对象。

可以通过重写UIResponder对象的- (nullable UIResponder*)nextResponder;方法改变响应者链,但是UIResponder对象的nextResponder属性是只读属性,不能直接赋值。

UIGestureRecognizer

在iOS中有六种手势操作:
UITapGestureRecognizer 点按手势
UIPinchGestureRecognizer 捏合手势
UIPanGestureRecognizer 拖动手势
UISwipeGestureRecognizer 轻扫手势,支持四个方向的轻扫,但是不同的方向要分别定义轻扫手势
UIRotationGestureRecognizer 旋转手势
UILongPressGestureRecognizer 长按手势
所有的手势操作都继承于UIGestureRecognizer,这个类本身不能直接使用。这个类中定义了这几种手势公有的一些属性和方法。


CFFB9CE8-59D3-4DDB-B9D7-70D4E77702A8.png
手势状态

这里着重解释一下上表中手势状态这个对象。在六种手势识别中,只有一种手势是离散手势,它就是UITapGestureRecognizer。离散手势的特点就是一旦识别就无法取消,而且只会调用一次手势操作(初始化手势时指定的触发方法)。其它五种手势是连续手势,连续手势的特点就是会多次调用手势操作事件,而且在连续手势识别后可以取消手势。从下面两图中可以看出两者调用操作事件的次数是不同的:


471B0C89-9B09-4A78-8607-2916464989B6.png

在iOS中将手势状态分为如下几种:


2D616318-E45F-43B0-B651-CB70C5037F76.png

为了大家更好的理解这个状态的变化,不妨在操作事件中打印事件状态,会发现在操作事件中的状态永远不可能为0(默认状态),因为只要调用此事件说明已经被识别了。前面也说过,手势识别从根本还是调用触摸事件而完成的,连续手势之所以会发生状态转换完全是由于触摸事件中的移动事件造成的,没有移动事件也就不存在这个过程中状态变化。
大家通过苹果官方的分析图再理解一下上面说的内容:


313E155C-7FD0-4685-A297-812FB3870CC4.png

手势的具体使用这里就不赘述了,先来看一下下面几种使用手势时产生冲突的情况。

手势和UIResponder之间的冲突

先看一个简单的例子:


BA814FD0-7F14-4BE1-A902-9C1C1F52BB6C.png

控制器的视图上add了一个View记为YellowView,并绑定了一个单击手势识别器。

@interface ViewController ()
@property (weak, nonatomic) IBOutlet YellowView *yellowView;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tap:)];
    [self.yellowView addGestureRecognizer:tap];
}

- (void)tap:(id)sender {
    NSLog(@"tap");
}
@end

点击YellowView,日志打印如下:

FAD8DBA7-D76E-4C4C-AA7A-A71CE1D648BA.png
从日志上看出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, delaysTouchesEnded properties:

这段描述的意思是:UIWindow会先把touch事件分发给手势识别器,然后再分发给hit-tested view,如果一个手势识别器分析了这一系列的点击事件之后没有识别出该手势,hit-tested view将会接收完整的点击事件。如果手势识别器识别了该手势,hit-tested view将会取消这次点击。由此可以看出:手势识别器比UIResponder具有更高的事件响应优先级

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

然而看日志,却是YellowView的touchesBegan:withEvent:先调用了,既然手势识别器先响应,不应该上面的action先执行吗?实际上这个认知是错误的。手势识别器的action的调用时机并不是手势识别器接收到事件的时机,而是手势识别器成功识别事件后的时机,即手势识别器的状态变为UIGestureRecognizerStateRecognized。要证明UIWindow先将事件传递给了手势识别器,还是需要看手势识别器中这四个熟悉的方法的调用结果。

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

不过不要误会,UIGestureRecognizer并不继承于UIResponder类,他们只是方法名相同而已。
这样,我们就可以自定义一个继承自UITapGestureRecognizer的子类,重写这四个方法,观察事件分发的顺序。上面的四个分发声明在UIGestureRecognizerSubclass.h中,所以想要重写的话需要引入头文件#import <UIKit/UIGestureRecognizerSubclass.h>。

#import "TapGestureRecognizer.h"
#import <UIKit/UIGestureRecognizerSubclass.h>
@implementation TapGestureRecognizer

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    // 这里需要调用一下父类的touchesBegan方法,否则事件会被拦截消耗掉
    [super touchesBegan:touches withEvent:event];
    NSLog(@"%s", __func__);
}

@end

点击YellowView,输出以下内容:


F5295756-A948-4B7D-9F7E-EDB5A7B3B488.png

可以看到,确实是手势识别器先接收到了事件,然后hit-tested view接收到事件。接着手势识别器识别了手势,执行action,再由Application取消了YellowView对事件的响应。

那么UIWindow是怎么知道要把事件传递给哪些手势识别器的呢?我们上面有一张图提到过,这些手势识别器的相关信息都储存在UITouch对象的gestureRecognizers里面,这是一个数组,可以储存多个手势识别器。

UIGestureRecognizer分为离散型手势和持续型手势,我们上面的demo用的是离散型手势,那么如果是持续型手势又会有什么样的结果呢?我们把UITapGestureRecognizer用UIPanGestureRecognizer替换,然后在YellowView上面执行一次滑动,输出结果如下:


4D526623-3469-45E1-9166-C49F74A7F581.png

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

手势识别器的3个属性
@property(nonatomic) BOOL cancelsTouchesInView;
@property(nonatomic) BOOL delaysTouchesBegan;
@property(nonatomic) BOOL delaysTouchesEnded;

在介绍这三个属性之前,先来总结一下手势识别器与UIResponder对于事件响应的联系:
当触摸发生或者触摸的状态发生变化时,UIWindow都会传递事件寻求响应。

cancelsTouchInView

默认为YES。表示当手势识别器成功识别了手势之后,会通知Application取消响应链对事件的响应,并不再传递事件给hit-tested view。若设置成NO,表示手势识别成功后不取消响应链对事件的响应,事件依旧会传递给hit-test view。把上面的demo中手势的cancelsTouchInView属性设置为NO,打印输出结果:


2AFFDDBD-4425-40EE-882D-6DA7BC4F5138.png

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

delaysTouchesBegan

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


36D3BB79-B0A9-499D-8BEE-B857914B7062.png

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

delaysTouchesEnded

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

同一个视图中的不同手势之间的冲突

如果在同一个视图上添加不同的手势时,也有可能会发生冲突。照例先上代码:

#import "ViewController.h"
@interface ViewController ()
@property (weak, nonatomic) IBOutlet UIImageView *imageView;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
    [self.imageView addGestureRecognizer:pan];

    UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(swipe:)];
    [self.imageView addGestureRecognizer:swipe];
}

- (void)pan:(UIPanGestureRecognizer *)gestureRecognizer
{
    if (gestureRecognizer.state == UIGestureRecognizerStateChanged) {
        CGPoint point = [gestureRecognizer translationInView:self.imageView];
        self.imageView.transform = CGAffineTransformMakeTranslation(point.x, point.y);
    }else if (gestureRecognizer.state == UIGestureRecognizerStateEnded){
        [UIView animateWithDuration:0.3 animations:^{
            self.imageView.transform = CGAffineTransformIdentity;
        }];
    }
    NSLog(@"%s", __func__);
}

- (void)swipe:(UISwipeGestureRecognizer *)gestureRecognizer
{
    static BOOL flag = NO;
    self.imageView.image = flag ? [UIImage imageNamed:@"b4723daa4e8f8fc434bc2e79a1bc4d8c"] : [UIImage imageNamed:@"a96703f39203a4e650f9e24655dc4864"];
    flag = !flag;
    NSLog(@"%s", __func__);
}

@end

代码很简单,最初我们的目的是在imageView上面添加一个拖动手势,一个轻扫手势。拖动的时候改变图片的位置,轻扫的时候切换图片。


QQ20171205-111141.gif
D6004995-02E2-46CE-A53E-F686D6784DF6.png

可以看到,尽管我减小了轻扫的幅度,加快了速度,轻扫手势依然没有起作用,就是因为轻扫和拖动这两个手势起了冲突。冲突的原因很简单,拖动手势的操作事件是在手势的开始状态(状态1)识别执行的,而轻扫手势的操作事件只有在手势结束状态(状态3)才能执行,因此轻扫手势就作为了牺牲品没有被正确识别。要解决这个冲突可以利用requireGestureRecognizerToFail:方法来完成,这个方法可以指定某个手势执行的前提是另一个手势识别失败。

这里我们把拖动手势设置为轻扫手势识别失败之后执行,这样一来我们手指轻轻滑动时系统会优先考虑轻扫手势,如果最后发现该操作不是轻扫,那么就会执行拖动。

只需要添加代码:

[pan requireGestureRecognizerToFail:swipe];

运行效果:


QQ20171205-135252.gif
不同视图上的手势冲突

在上面响应者链的学习中,我们知道了UIResponder响应事件的时候是有优先级的,上层触摸事件执行后就不再向下传播。默认情况下手势也是类似的,先识别的手势会阻断手势识别操作继续传播。下面我们用代码验证一下:

我们在控制器的视图上面添加一个黄色的子视图,然后在黄色视图上面添加一个自定义的滑动手势,在控制器的view上面也添加一个自定义的滑动手势。在自定义的滑动手势里面重写touchBegan:withEvent:这四个相关的方法.

@interface GestureRecognizer : UIPanGestureRecognizer
@property (nonatomic, strong) NSString *panName;
@end

@implementation GestureRecognizer

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesBegan:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesMoved:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesEnded:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesCancelled:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

@end

// ViewController

@interface ViewController ()
@property (weak, nonatomic) IBOutlet YellowView *yellowView;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    GestureRecognizer *pan = [[GestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
    pan.panName = @"第一个";
    [self.yellowView addGestureRecognizer:pan];

    GestureRecognizer *panBottom = [[GestureRecognizer alloc] initWithTarget:self action:@selector(panBottom:)];
    panBottom.panName = @"第二个";
    [self.view addGestureRecognizer:panBottom];
}

- (void)pan:(UIPanGestureRecognizer *)gestureRecognizer
{
    NSLog(@"%s", __func__);
}

- (void)panBottom:(UIGestureRecognizer *)gestureRecognizer{
    NSLog(@"%s", __func__);
}

在黄色视图上滑动,输出以下结果:


ACB1FA17-AC4B-4B57-ACB1-84BBBF83221F.png

可以看到,在手势识别期间,UIWindow会依次向两个手势识别器和hit-test view发送事件,而手势识别成功后,UIWindow停止向控制器视图上面添加的滑动手势发送事件,导致其action无法被调用,从而产生冲突。为什么停止接收事件的是第二个滑动手势呢?还记得我们上面提到过的UITouch里面的数组gestureRecognizers吗,手势识别的优先级跟数组的数据是保持一致的,和响应者链的响应顺序也有点类似。

4AE1F41A-62F1-4DB3-8CF4-D71F21AF2547.png

我们可以看到,第一个手势储存在数组的最前面,他的优先级比较高,所以会首先被响应。

那么如何让两个有层次关系并且都添加了手势的控件都能正确识别手势呢?答案就是利用手势代理的gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:代理方法。
苹果官方是这么描述这个方法的:

Asks the delegate if two gesture recognizers should be allowed to recognize gestures simultaneously.
This method is called when recognition of a gesture by either gestureRecognizer or otherGestureRecognizer would block the other gesture recognizer from recognizing its gesture. Note that returning YES is guaranteed to allow simultaneous recognition; returning NO, on the other hand, is not guaranteed to prevent simultaneous recognition because the other gesture recognizer's delegate may return YES.

这个方法主要是为了询问手势的代理,是否允许两个手势识别器同时识别该手势。返回YES可以确保允许同时识别手势,返回NO的话不能保证一定不能同时识别,因为其他手势的代理也有可能返回YES。

在上面demo的ViewController中遵循UIGestureRecognizerDelegate协议,设置第一个手势的代理为self,添加如下代码:

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(nonnull UIGestureRecognizer *)otherGestureRecognizer
{
    return YES;
}

滑动黄色视图,输出以下结果:


45EB01B5-9148-4B7A-B53B-D3FAA63BFD7B.png

可以看到两个手势的action同时被调用了。

UIControl

UIControl是系统提供的能够以target-action模式处理触摸事件的控件,iOS中UIButton、UISegmentControl、UISwitch等控件都是UIControl的子类。值得注意的是,UIControl是UIView的子类,因此本身也具有UIResponder的属性。UIControl是一个抽象基类,我们不能直接使用UIControl类来实例化控件,它只是为控件子类定义一些通用的接口,并提供一些基础实现。

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;

乍一看,这四个方法和UIResponder那四个方法几乎吻合,只不过UIControl只能接收单点触控,因此这四个方法的参数是单个的UITouch对象。这四个方法的智能也和UIResponder一致,用来跟踪触摸的开始、滑动、结束、取消。不过,UIControl本身也是UIResponder,因此同样有touches系列的4个方法。事实上,UIControl的Tracking系列方法是在touch系列方法内部调用的。比如beginTrackingWithTouch是在touchesBegan方法内部调用的。这个我们也可以验证:

自定义一个继承于UIControl的子类,重写beginTrackingWithTouch和touchesBegan:withEvent:方法。
代码如下:

@implementation Control

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"进入%s", __func__);
    [super touchesBegan:touches withEvent:event];
    NSLog(@"离开%s", __func__);
}

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
    return [super beginTrackingWithTouch:touch withEvent:event];
}

@end

点击自定义的Control,输出以下结果:


{2BE316C1-94A7-4C85-8CCD-68FBEB8170CE}.png

可以看出,touchesBegan方法调用了beginTrackingWithTouch方法。这也说明了另外一个问题,UIControl的touchesBegan方法的实现与UIResponder的touchesBegan方法是有区别的。

当UIControl跟踪事件的过程中,识别出事件交互符合响应条件,就会触发target-action进行响应。UIControl控件通过addTarget:action:forControlEvents:添加事件处理的target和action,当事件发生时,UIControl会调用sendAction:to:forEvent:来将event发送给UIApplication对象,再由UIApplication对象调用其sendAction:to:fromSender:forEvent:方法来将消息分发到指定的target上。

C8509D7E-767F-45F8-9F4F-2012288348AA.png

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

If you specify nil for the target object, the control searches the responder chain for an object that defines the specified action method.

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

UIControl里面还有一个方法叫做sendActionsForControlEvents:这个方法的作用是发送与指定类型相关的所有行为消息。我们可以在任意位置(包括控件内部和外部)调用控件的这个方法来发送参数controlEvents指定的消息。

UIControl、UIResponder、UIGestureRecognizer之间的优先级关系

上面我们已经分析过了,UIGestureRecognizer的优先级是比UIResponder的优先级高的,那么如果再加上一个UIControl呢?
我们先来比较一下UIControl和UIResponder之间的优先级关系,这里的UIResponder我们用UIView来代替

首先如果UIControl添加在UIView上面的时候,毋庸置疑,UIControl会首先响应,参照button添加在视图上。那么如果把UIView添加在UIControl上面的时候,谁会响应事件呢?我们用代码来验证一下:

自定义一个UIView,重写touchesBegan:方法

@implementation YellowView

- (void)touchesBegan:(NSSet<UITouch *> *)touches   withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
}

@end

自定义一个UIControl,里面什么都不用写。

在ViewController里面添加如下代码:

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    Control *control = [[Control alloc] initWithFrame:CGRectMake(100, 100, 100, 200)];
    control.backgroundColor = [UIColor redColor];
    [control addTarget:self action:@selector(clickControl:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:control];

    YellowView *yellowView = [[YellowView alloc] initWithFrame:CGRectMake(10, 10, 50, 50)];
    yellowView.backgroundColor = [UIColor yellowColor];
    [control addSubview:yellowView];
}

- (void)clickControl:(UIControl *)control
{
    NSLog(@"click Control");
}

@end

点击上层的UIControl,输出结果如下:


E0B59E0B-0468-46E4-91F4-0B723E2BDD95.png

可以看到UIControl的action并没有响应。看来自定义的UIControl与UIResponder之间的优先级还是遵循响应链的层级的,这就表示UIResponder和UIControl的优先级是相同的,而UIGestureRecognizer的优先级比UIControl高,由此推断的话,UIGestureRecognizer的优先级好像是比UIControl高的,具体是什么样子的,我们还是来验证一下。

现在我们把层级改变一下,把UIControl添加到yellowView上面,然后给yellowView添加一个tap手势,代码如下:

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    YellowView *blueView = [[YellowView alloc] initWithFrame:CGRectMake(100, 100, 100, 200)];
    blueView.backgroundColor = [UIColor blueColor];
    [self.view addSubview:blueView];

    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tap:)];
    [blueView addGestureRecognizer:tap];

    Control *control = [[Control alloc] initWithFrame:CGRectMake(10, 10, 50, 50)];
    control.backgroundColor = [UIColor redColor];
    [control addTarget:self action:@selector(clickControl:) forControlEvents:UIControlEventTouchUpInside];
    [blueView addSubview:control];
}

- (void)clickControl:(UIControl *)control
{
    NSLog(@"click Control");
}

- (void)tap:(UIGestureRecognizer *)gestureRecognizer
{
    NSLog(@"tap");
}

@end

这里为了让我们的代码更严谨,我们把上面YellowView中重写的touchesBegan方法注掉。现在点击自定义的Control,打印结果如下:

1A500D7E-69C1-4BA4-B537-660B4D4F90D1.png

可以看到,系统执行了手势的action,并没有执行UIControl的action,这好像跟我们上面预测的手势的优先级比UIControl高是一致的。但是真的是这样吗?我们把自定义的UIControl替换成UIButton,其它地方不变,再点击一次button,打印结果变成了这样:


A4E97A1F-19D8-4B80-BCEB-625FC3EA2D1F.png

同样都是继承于UIControl,这control和control的差别咋就那么大捏???

别急,苹果爸爸已经给了我们合理的解释:

In iOS 6.0 and later, default control actions prevent overlapping gesture recognizer behavior. For example, the default action for a button is a single tap. If you have a single tap gesture recognizer attached to a button’s parent view, and the user taps the button, then the button’s action method receives the touch event instead of the gesture recognizer.This applies only to gesture recognition that overlaps the default action for a control, which includes:

A single finger single tap on a UIButton, UISwitch, UIStepper, UISegmentedControl, and UIPageControl.
A single finger swipe on the knob of a UISlider, in a direction parallel to the slider.
A single finger pan gesture on the knob of a UISwitch, in a direction parallel to the switch.

在iOS6以后,默认的control actions会阻止与该action操作相同的手势的识别。例如:UIButton的默认操作是单击,如果你在这个button的父视图上面添加了一个tap手势,用户单击button,系统会调用button的action而不是手势的action。这种规则仅仅适用于手势操作和UIcontrol的默认操作相同的情况下,包含以下几种情况:

这里提到了两点,第一是手势和UIControl的默认操作相同,也就是说如果UIControl没有默认操作(比如我们自定义的UIControl)或者是默认操作和添加的手势不同,那么手势识别器的识别优先级高,UIControl不会阻止手势识别。第二是在UIButton的父视图上添加手势,如果你把一个添加了手势的视图添加在UIButton上面,那么UIButton是不能阻止该手势识别的。这两点读者可以自行验证。

总结:自定义的UIControl和UIResponder的优先级相同,都比UIGestureRecognizer低,有默认操作的UIControl会组织添加在父视图上面的有相同操作的手势的识别。

参考文章:
iOS触摸事件全家桶
手势识别(四)多手势间的交互与共存
iOS触摸事件传递响应之被忽视的手势识别器工作原理
iOS开发系列--触摸事件、手势识别、摇晃事件、耳机线控
UIKit: UIControl
iOS触摸事件的流动

上一篇下一篇

猜你喜欢

热点阅读