IOS中触摸事件学习

本文学习的内容大致包括:

  • 触摸事件由触屏生成后是如何传递到当前应用的
  • 应用接收触摸事件后如何寻找最佳响应者?实现原理?
  • 触摸事件如何沿着响应链流动?
  • 响应链、手势识别器、UIControl之间对于触摸事件的响应有着什么样的关系?

1. 事件的声明周期

当指尖触摸屏幕的那一刻,一个触摸事件就在系统中生成了。经过IPC进程通信,事件最终被传递到合适的应用。在经历过一些过程之后,最终被释放,流程如下:
请添加图片描述

2. 系统相应阶段

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

mach port 进程端口,各进程之间通过它进行通信。
SpringBoad.app 是一个系统进程,可以理解为桌面系统,可以统一管理和分发系统接收到的触摸事件。(学过逆向的应该知道)

  1. SpringBoard进程因接收到触摸事件,触发了主线程runloop的source1事件源的回调。

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

3. APP响应阶段

  1. APP进程的mach port接受到SpringBoard进程传递来的触摸事件,主线程的runloop被唤醒,触发了source1回调。
  2. source1回调又触发了一个source0回调,将接收到的IOHIDEvent对象封装成UIEvent对象,此时APP将正式开始对于触摸事件的响应
  3. source0回调将触摸事件添加到UIApplication的事件队列,当触摸事件出队后UIApplication为触摸事件寻找最佳响应者。
  4. 寻找到最佳响应者之后,接下来的事情便是事件在响应链中传递和响应。
  5. 触摸事件历经坎坷后要么被某个响应对象捕获后释放,要么致死也没能找到能够响应的对象,最终释放。至此,这个触摸事件的使命就算终结了。runloop若没有其他事件需要处理,也将重归于眠,等待新的事件到来后唤醒。

触摸事件从触屏产生后,由IOKit将触摸事件传递给SpringBoard进程,再由SpringBoard分发给当前前台APP处理。

4. 触摸、事件、响应者

4.1 UITouch(触摸)

  • 一个手指一次触摸屏幕,就对应生成一个UITouch对象。多个手指同时触摸,生成多个UITouch对象。
  • 多个手指先后触摸,系统会根据触摸的位置判断是否更新同一个UITouch对象。若两个手指一前一后触摸同一个位置(即双击),那么第一次触摸时生成一个UITouch对象,第二次触摸更新这个UITouch对象(UITouch对象的 tap count 属性值从1变成2);若两个手指一前一后触摸的位置不同,将会生成两个UITouch对象,两者之间没有联系
  • 每个UITouch对象记录了触摸的一些信息,包括触摸时间、位置、阶段、所处的视图、窗口等信息
//触摸各个阶段的状态
//例如:当手指移动时,会更新UITouchPhase状态到UITouchPhaseMoved,手指离开屏幕后会更新到UITouchPhaseEnded
typedef NS_ENUM(NSInteger, UITouchPhase) {
    UITouchPhaseBegan,             // whenever a finger touches the surface.
    UITouchPhaseMoved,             // whenever a finger moves on the surface.
    UITouchPhaseStationary,        // whenever a finger is touching the surface but hasn't moved since the previous event.
    UITouchPhaseEnded,             // whenever a finger leaves the surface.
    UITouchPhaseCancelled,         // whenever a touch doesn't end but we need to stop tracking (e.g. putting device to face)
    UITouchPhaseRegionEntered   API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos),  // whenever a touch is entering the region of a user interface
    UITouchPhaseRegionMoved     API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos),  // when a touch is inside the region of a user interface, but hasn’t yet made contact or left the region
    UITouchPhaseRegionExited    API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos),  // when a touch is exiting the region of a user interface
};
  • 当手指离开屏幕后,确定一段时间UITouch不会再更新之后将被释放

4.2 UIEvent(事件真身)

  • 触摸的目的是生成触摸事件供响应者响应,一个触摸事件对应一个UIEvent对象,其中的type标识了事件的类型
typedef NS_ENUM(NSInteger, UIEventType) {
    UIEventTypeTouches,
    UIEventTypeMotion,
    UIEventTypeRemoteControl,
    UIEventTypePresses API_AVAILABLE(ios(9.0)),
    UIEventTypeScroll      API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos) = 10,
    UIEventTypeHover       API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos) = 11,
    UIEventTypeTransform   API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos) = 14,
};

// UIEvent类
UIKIT_EXTERN API_AVAILABLE(ios(2.0)) @interface UIEvent : NSObject

@property(nonatomic,readonly) UIEventType     type API_AVAILABLE(ios(3.0));
@property(nonatomic,readonly) UIEventSubtype  subtype API_AVAILABLE(ios(3.0));

@property(nonatomic,readonly) NSTimeInterval  timestamp;

@property (nonatomic, readonly) UIKeyModifierFlags modifierFlags API_AVAILABLE(ios(13.4), tvos(13.4)) API_UNAVAILABLE(watchos);
@property (nonatomic, readonly) UIEventButtonMask buttonMask API_AVAILABLE(ios(13.4)) API_UNAVAILABLE(tvos, watchos);

@property(nonatomic, readonly, nullable) NSSet <UITouch *> *allTouches;
- (nullable NSSet <UITouch *> *)touchesForWindow:(UIWindow *)window;
- (nullable NSSet <UITouch *> *)touchesForView:(UIView *)view;
- (nullable NSSet <UITouch *> *)touchesForGestureRecognizer:(UIGestureRecognizer *)gesture API_AVAILABLE(ios(3.2));

// An array of auxiliary UITouch’s for the touch events that did not get delivered for a given main touch. This also includes an auxiliary version of the main touch itself.
- (nullable NSArray <UITouch *> *)coalescedTouchesForTouch:(UITouch *)touch API_AVAILABLE(ios(9.0));

// An array of auxiliary UITouch’s for touch events that are predicted to occur for a given main touch. These predictions may not exactly match the real behavior of the touch as it moves, so they should be interpreted as an estimate.
- (nullable NSArray <UITouch *> *)predictedTouchesForTouch:(UITouch *)touch API_AVAILABLE(ios(9.0));

@end

//事件除了触摸事件,还有其他的事件
  • UIEvent对象中包含了触发该事件的触摸对象集合,因为一个触摸事件可能由多个手指同时触摸产生的,触摸对象集合通过allTouches属性获取

4.3 UIResponder(响应者)

每一个响应者都是一个UIResponder对象,及所有派生自UIResponder的对象,本身都具备响应事件的能力,因此一下类的实例都是响应者都是实例:

  • UIView
  • UIViewController
  • UIApplication
  • AppDelegate

响应者之所以能够响应事件,是因为提供了4个触摸事件的方法

//手指触碰屏幕,触摸开始
- (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;

这个几个方法在响应者对象接收到事件的调用,用于做出对事件的响应。关于响应者何时接收到事件以及事件如何沿着响应链传递

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

每个事件的理想宿命是被能够响应它的对象响应后释放,然而响应者诸多,事件一次只有一个,谁都想把事件抢到自己碗里来,为避免纷争,就得有一个先后顺序,也就是得有一个响应者的优先级。因此这就存在一个寻找事件最佳响应者(又称第一响应者 first responder)的过程,目的是找到一个具备最高优先级响应权的响应对象(the most appropriate responder object),这个过程叫做Hit-Testing,那个命中的最佳响应者称为hit-tested view。

当APP通过mach port得到这个触摸事件时,APP中有那么多UIView或者UIViewController,到底应该给谁去响应呢?寻找最佳响应者就是找出这个优先级最高的响应对象。

5.1 事件自下而上的传递

应用接收到事件后先将其置入事件队列中以等待处理。出队后,application首先将事件传递给当前应用最后显示的窗口(UIWindow)询问其能否响应事件,若窗口能响应事件,则传递给子视图询问是否能响应,子视图若能响应则继续询问子视图。子视图询问的顺序是优先询问后添加的子视图,即子视图数组中靠后的视图。事件传递顺序如下

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

事实上吧UIwindow也看成是视图即可,这个过程就是一个递归询问子视图能否响应事件的过程,且后添加的子视图优先级高(对于window而言就是后显示的window优先级高)

事件的具体流程:

  1. UIApplication首先将事件传递给窗口对象(UIWindow),若存在多个窗口,则优先询问后显示的窗口
  2. 若窗口不能响应事件,则将事件传递其他窗口;若窗口能响应事件,则从后往前询问窗口的子视图
  3. 重复步骤2。即视图若不能响应,则将事件传递给上一个同级子视图;若能响应,则从后往前询问当前视图的子视图。
  4. 视图若没有能响应的子视图了,则自身就是最合适的响应者

在这里插入图片描述

视图层级如下(同一层级的视图越在下面,表示越后添加):

A
├── B
│   └── D
└── C
    ├── E
    └── F

现在假设在E视图所处的屏幕位置触发一个触摸,应用接收到这个触摸事件事件后,先将事件传递给UIWindow,然后自下而上开始在子视图中寻找最佳响应者。事件传递的顺序如下所示:
在这里插入图片描述

  1. UIWindow将事件传递给其子视图A
  2. A判断自身能响应该事件,继续将事件传递给C(因为视图C比视图B后添加,因此优先传给C)
  3. C判断自身能响应事件,继续将事件传递给F(同理F比E后添加)
  4. F判断自身不能响应事件,C又将事件传递给E
  5. E判断自身能响应事件,同时E已经没有子视图,因此最终E就是最佳响应者

5.2 Hit-Testing的本质

上面讲了事件在响应者之间传递的规则,视图通过判断自身能否响应事件来决定是否继续向子视图传递。那么问题来了:视图如何判断能否响应事件?以及视图如何将事件传递给子视图?

那么我们首先需要知道,以下几种状态是无法响应事件:

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

hitTest:withEvent:
每个UIView对象都有一个 hitTest:withEvent: 方法,这个方法是Hit-Testing过程中最核心的存在,其作用是询问事件在当前视图中的响应者,同时又是作为事件传递的桥梁

hitTest:withEvent: 返回一个UIView对象,作为当前视图层次中的响应者。
其方法默认实现:

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

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

系统对于视图能否响应事件的判断逻辑除了之前提到的3种限制状态,默认能响应的条件就是触摸点在当前视图的坐标系范围内。因此,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。

现在我们在上述示例的视图层次中的每个视图类中添加下面3个方法来验证一下之前的分析(注意 :hitTest:withEvent:pointInside:withEvent: 方法都要调用父类的实现,否则不会按照默认的逻辑来执行Hit-Testing)

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
    return [super hitTest:point withEvent:event];
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
    return  [super pointInside:point withEvent:event];
}

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

那么单击E视图,调用过程如下:
在这里插入图片描述

可以看到最终是视图E对事件进项了响应, 同时事件的传递过程也和上面分析的一致,事实上单击从[AView hitTest:withEvent:][EView pointInside:withEvent:]的过程会执行两边,两次传的是同一个Touch,区别在Touch的状态不同,第一次是begin状态,第二阶段是end阶段。也就是说对于事件的传递起源与触摸状态的变化

5.3 Hit-Testing过程中的事件拦截(自定义事件流向)

实际开发过程中可能会遇到一些特殊的交互需求,需要定制视图对于事件的响应,例如下面
在这里插入图片描述

G和H视图都是跟控制器视图的子视图,I视图是添加在H视图上,当我们触摸I视图在G视图中的那部分时,我们看打印结果:
在这里插入图片描述

通过打印结果我们可以发现,事件根本没有传递到I视图这里,这是为什么了?
原来触摸事件最早传递到H视图,然后调用H视图的[HView hitTest:withEvent:],在这个方法中会调用[HView pointInside:withEvent:],判断触摸点是否在视图范围内,这里由于触摸点在G视图的那部分,所以不在H视图,因此该方法返回NO,这样H视图的事件传递就结束了,于是事件就传递到了G视图内,由于G视图可以响应触摸事件,而且G视图内没有子视图,所以G视图就是事件的最佳响应者。

那么这显示不是我们想要的结果,我们希望当触摸I视图时,不管触摸I视图的哪里,I视图都能成为最佳响应者响应事件

要解决这个问题也很简单, 我们可以重写H视图中的pointInside:withEvent:方法

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
    
    NSLog(@"%s", __func__);
    CGPoint tmpPoint = [self convertPoint:point toView:_iView];
    
    if([_iView pointInside:tmpPoint withEvent:event]){
        
        return YES;
    }
    
    return [super pointInside:point withEvent:event];
}

我们判断触摸点位置是否在视图I范围内,如果在视图I的范围内,则直接返回YES,这样一来I视图就可以响应触摸事件了

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

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

  • 将事件传递给最佳响应者
  • 事件沿着响应链传递

5.4.1 事件响应的前奏

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

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

事件自下而上的传递结点中为例,我们在E视图中的touchesBegan:withEvent:断点,然后查看调用栈就能看清除这一过程
在这里插入图片描述

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

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

在这里插入图片描述

至于这两个属性是什么时候绑定到touch对象上的,必然是在hit-testing的过程中呗,仔细想想hit-testing干的不就是这个事儿吗~

5.4.2 事件的响应

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

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

每个响应触摸事件的方法会接收两个参数,分别对应触摸对象集合和事件对象。通过监听触摸对象中保存触摸点位置的变动,可以时时修改视图的位置。视图(UIView)作为响应者对象,本身已经实现了 touchesMoved: withEvent: 方法,因此要创建一个自定义视图(继承自UIView),重写该方法。

//MovedView
//重写touchesMoved方法(触摸滑动过程中持续调用)
- (void)touchesMoved:(NSSet<UITouch *> *)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);
}

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

5.4.3事件的传递(响应链)

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

需要注意的是,上一节中也说到了事件的传递,与此处所说的事件的传递有本质区别。上一节所说的事件传递的目的是为了寻找事件的最佳响应者,是自下而上的传递;而这里的事件传递目的是响应者做出对事件的响应,这个过程是自上而下的。前者为“寻找”,后者为“响应”。

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

响应者对于事件的拦截以及传递都是通过touchesBegan:withEvent:方法控制的,该方法默认实现是将事件沿着默认的响应链往下传递。

响应者对于接收事件有一下3种操作:

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

5.4.5 响应链中的事件规则

每一个响应者(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。
    请添加图片描述

上图是官网对于响应链的展示,若触摸发生在UITextField上,则事件的传递顺序是:

UITextField ——> UIView ——> UIView ——> UIViewController ——> UIWindow ——> UIApplication ——> UIApplicationDelegation

图中虚线箭头是指若该UIView是作为UIViewController根视图存在的,则其nextResponder为UIViewController对象;若是直接add在UIWindow上的,则其nextResponder为UIWindow对象

可以使用一下方式打印一个响应链上每一个响应者对象, 在最佳响应者的touchBegin:withEvent:方法中调用即可(别忘了调用父类的方法)

我们以上面事件拦截中的案例来看看:
在这里插入图片描述

上面结果打印出了完整的响应链,另外如果有需要,完全可以重写响应者的nextResponder方法来自定义响应链

6 UIResponder、UIGestureRecognizer、UIControl

IOS中,除了UIResponder能够响应事件,手势识别器、UIControl同样具备对事件的处理能力。

6.1 UIGestureRecognizer(手势识别器)

我们首先来看一个场景,给下图中的黄色View(AxsView)添加一个点击手势:
在这里插入图片描述

查看运行结果:
在这里插入图片描述

从日志上看出YellowView最后Cancel了对触摸事件的响应,而正常应当是触摸结束后,AxsView的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.

大致理解是,Window在将事件传递给hit-tested view之前,会先将事件传递给相关的手势识别器并由手势识别器优先识别。若手势识别器成功识别了事件,就会取消hit-tested view对事件的响应;若手势识别器没能识别事件,hit-tested view才完全接手事件的响应权。

一句话概括:手势识别器比UIResponder具有更高的事件响应优先级!!!!

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

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

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

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

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

#import "AxsTapGesture.h"
#import <UIKit/UIGestureRecognizerSubclass.h>

@implementation AxsTapGesture

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

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

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

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

在次点击AxsView,查看打印结果:
在这里插入图片描述

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

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

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

在这里插入图片描述

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

6.2 持续行手势(UIPanGestureRecognizer)

将上面demo中的单击手势改成滑动手势,然后我们看下打印结果
在这里插入图片描述

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

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

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

6.3 手势识别器中的三个属性

@property(nonatomic) BOOL cancelsTouchesInView;
@property(nonatomic) BOOL delaysTouchesBegan;
@property(nonatomic) BOOL delaysTouchesEnded;

6.3.1 cancelsTouchesInView

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

上述demo中设置cancelsTouchesInView = NO,滑动打印结果如下:
在这里插入图片描述

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

6.3.2 delaysTouchesBegan

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

设置delaysTouchesBegan = YES
打印结果如下:
在这里插入图片描述

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

6.3.3 delaysTouchesEnded

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

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

6.4 UIControl

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

关于UIControl此处介绍两点:

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

6.4.1 target-action

  • target: 处理交互事件的对象
  • action:处理交互事件的方式

UIControl作为能够响应事件的控件,必然也需要待事件交互符合条件时才去响应,因此也会跟踪事件发生的过程。不同于UIResponder以及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: 添加事件处理的targetaction,当事件发生时,UIControl通知target执行对应的action。说是“通知”其实很笼统,事实上这里有个action传递的过程。当UIControl监听到需要处理的交互事件时,会调用 sendAction:to:forEvent:target、action以及event对象发送给全局应用,Application对象再通过 sendAction:to:from:forEvent:target发送action

在这里插入图片描述

因此,可以通过重写UIControl的 sendAction:to:forEvent:sendAction:to:from:forEvent: 自定义事件执行的targetaction

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

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

6.5 触摸事件的优先级

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.

简单理解:UIControl会阻止父视图上的手势识别器行为,也就是UIControl处理事件的优先级比UIGestureRecognizer高,但前提是相比于父视图上的手势识别器

在这里插入图片描述

预设场景:在AxsView上添加一个button(AxsButton),同时给button添加一个target-action事件

  • 示例一:在AxsView上添加点击手势识别器
  • 示例二:在button上添加手势识别器
    操作方式: 单击button

测试结果:示例一中button的target-action响应了单击事件;示例二中:AxsView上的手势识别器响应了事件。过程日志打印如下:
在这里插入图片描述

在这里插入图片描述

  • 原因分析: 点击button后,事件先传递给手势识别器,再传递给作为hit-tested view存在的button(UIControl本身也是UIResponder,这一过程和普通事件响应者无异)。示例一中,由于button阻止了父视图BlueView中的手势识别器的识别,导致手势识别器识别失败,button完全接手了事件的响应权,事件最终由button响应;示例二中:button未阻止其本身绑定的手势识别器的识别,因此手势识别器先识别手势并识别成功,而后通知Application取消响应链对事件的响应,因为 touchesCancelled 被调用,同时 cancelTrackingWithEvent 跟着调用,因此button的target-action得不到执行。
  • 其他:经测试,若示例一中的手势识别器设置 cancelsTouchesInView 为NO,手势识别器和button都能响应事件。也就是说这种情况下,button不会阻止父视图中手势识别器的识别。
  • UIControl会阻止父视图上的手势识别器的行为,也就是UIControl比其父视图上的手势识别器具有更高的事件响应优先级(但是仅限于系统提供的有默认action操作的UIControl,例如UIbutton、UISwitch等的单击,而对于自定义的UIControl,经验证,响应优先级比手势识别器低。)但是比UIControl自身的UIGestureRecognizer优先级要低。

7. 总结

  • 触摸发生时,系统内核生成触摸事件,先由IOKit处理封装成IOHIDEvent对象,通过IPC传递给系统进程SpringBoard,而后再传递给前台APP处理
  • 事件传递到APP内部时被封装成开发者可见的UIEvent对象,先经过hit-testing寻找第一响应者,而后由Window对象将事件传递给hit-tested view,并开始在响应链上的传递。
  • UIRespnder、UIGestureRecognizer、UIControl,笼统地讲,事件响应优先级依次递增。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值