iOS 事件(UITouch、UIControl、UIGestu

作者: 果哥爸 | 来源:发表于2019-02-15 18:19 被阅读130次

    gitHub地址 : 响应链Demo
    文章有点长,如果只是想了解大概过程的,可以直接看后面的总结

    一.触摸、事件、响应者

    1. UITouch

    源起触摸

    • 一个手指一次触摸屏幕,就对应生成一个UITouch对象。多个手指同时触摸屏幕,生成多个UITouch对象。

    • 多个手指先后触摸,系统会根据触摸的位置判断是否更新同一个UITouch对象。若两个手指一前一后触摸同一个位置(即双击),那么第一次触摸时生成一个UITouch对象第二次触摸会更新这个UITouch对象,这是该UITouch对象Tap Count属性值从1变成2,若两个手指一前一后触摸的位置不同,将会生成两个UITouch对象,两者之间没有联系。

    • 每个UITouch对象记录了触摸的一些信息,包括触摸时间、位置、阶段、所处的视图、窗口等信息。

    // 触摸的各个阶段状态 
    // 例如当手指移动时,会更新phase属性到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)
    };
    
    • 手指离开屏幕一段时间后,确定该UITouch对象不会再被更新,就释放。

    2.UIEvent

    事件的真身

    • 触摸的目的是生成触摸事件供响应者响应,一个触摸事件对应一个UIEvent对象,其中的type属性标识了事件的类型,事件有如下几种类型:
    typedef NS_ENUM(NSInteger, UIEventType) {
        UIEventTypeTouches,
        UIEventTypeMotion,
        UIEventTypeRemoteControl,
        UIEventTypePresses NS_ENUM_AVAILABLE_IOS(9_0),
    };
    

    这里我们所说的事件具体指的是触摸事件。

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

    3.UIResponder

    UIResponderiOS中用于处理用户事件API,可以处理触摸事件、按压事件(3D touch)、远程控制事件、硬件运动事件。可以通过touchesBegan、pressesBegan、motionBegan、remoteControlReceivedWithEvent等方法,获取到对应的回调消息。UIResponder不只用来接收事件,还可以处理和传递对应的事件,如果当前响应者不能处理,则转发给其他合适的响应者处理。

    应用程序通过响应者来接收和处理事件,响应者可以是继承自UIResponder的任何子类,例如UIView、UIViewController、UIApplication等。当事件来到时,系统会将事件传递给合适的响应者,并且将其成为第一响应者

    第一响应者未处理的事件,将会在响应者链中进行传递,传递规则由UIRespondernextResponder决定,可以通过重写该属性来决定传递规则。当一个事件到来时,第一响应者没有接收消息,则顺着响应者链向后传递。

    二.寻找事件的第一响应者

    App接收到触摸事件后,会被放入当前应用程序的UIApplication维护的事件队列中。

    由于事件一次只有一个,但是能够响应的事件的响应者众多,所以这就存在一个寻找第一响应者的过程。

    1. 事件自下而上传递

    查找第一响应者时,有两个非常关键的API,查找第一响应者就是通过不断调用子视图的这两个API完成的。

    调用方法,获取到被点击的视图,也就是第一响应者

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
    

    hitTest:withEvent:方法内部会通过调用pointInside:这个方法,来判断点击区域是否在视图上,是则返回YES,不是则返回NO

    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
    

    具体流程:

    • 应用程序接收到触摸事件后,将事件放入UIApplication的事件队列,等到处理该事件时,将该事件出队列,UIApplication将事件传递给窗口对象(UIWindow),如果存在多个窗口,则优先询问后显示的窗口

    • 如果窗口UIWindow不能响应事件,则将事件传递给其他窗口;若窗口能响应事件,则从后往前询问窗口的子视图。

    • 以此类推,如果视图不能响应事件,则将事件传递给同级的上一个子视图;如果能响应,就从后往前遍历当前视图子视图

    • 如果当前视图子视图都不能响应事件,则当前视图就是最合适响应者

    举个🌰 :

    如图所示:

    image.png

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

    image.png

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

    • UIWindow将事件传递给UIViewController的视图UIView,UIView判断自身能响应事件,将事件传递给子视图A

    • A判断自身能响应该事件,继续将事件传递给C(因为视图C视图B后添加,因此优先传给C)。

    • C判断自身能响应事件,继续将事件传递给F(同理FE后添加)。

    • F判断自身不能响应事件,C又将事件传递给E

    • E判断自身能响应事件,同时E已经没有子视图,因此最终E就是第一响应者

    2. hitTest函数本质

    上面讲到了事件在响应者之间传递的规则,视图通过判断自身能否响应事件来决定是否继续想子视图传递。

    这里涉及到两个问题:

    • 视图判断自身能否响应事件的判断依据是什么?

    • 如果能响应,视图是如何将事件传递给子视图的?

    针对第一个问题:

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

    • 不允许交互:userInteractionEnabled = NO

    • 隐藏:hidden = YES 如果父视图隐藏,那么子视图也会隐藏,隐藏的视图无法接收事件

    • 透明度:alpha < 0.01 如果设置一个视图的透明度<0.01,会直接影响子视图的透明度。alpha:0.0~0.01为透明。

    其次,如果当前视图可以响应事件,还必须通过pointInside函数判断,触摸点是否在当前视图的坐标范围内,如果不在当前视图的坐标范围内,则无法响应,如果在坐标范围内,并且该视图可以响应事件,就进入下一步事件的传递。

    针对第二个问题:

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

    • 当前视图无法响应事件,则返回nil

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

    • 当前视图可以响应事件,同时有子视图可以响应,则从后往前遍历子视图,返回子视图层次中的事件响应者

    • 以此类推,直到找到的当前视图可以响应事件,并且当前视图没有子视图,那么当前视图就是第一响应者

    依据以上的描述我们可以推测出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;
    }
    

    我们分别在上述示例的视图层次中的每个视图实现文件添加如下方法:

    #pragma mark -------------------------- Override Methods
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"%s",__func__);
        [super touchesBegan:touches withEvent:event];
    }
    
    - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"%s",__func__);
        [super touchesMoved:touches withEvent:event];
    }
    
    - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"%s",__func__);
        [super touchesEnded:touches withEvent:event];
    }
    
    - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"%s",__func__);
        [super touchesCancelled:touches withEvent:event];
    }
    
    - (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];
    }
    

    然后单点E视图,打印如下:

     -[AView hitTest:withEvent:]
     -[AView pointInside:withEvent:]
     -[CView hitTest:withEvent:]
     -[CView pointInside:withEvent:]
     -[FView hitTest:withEvent:]
     -[FView pointInside:withEvent:]
     -[EView hitTest:withEvent:]
     -[EView pointInside:withEvent:]
     -[AView hitTest:withEvent:]
     -[AView pointInside:withEvent:]
     -[CView hitTest:withEvent:]
     -[CView pointInside:withEvent:]
     -[FView hitTest:withEvent:]
     -[FView pointInside:withEvent:]
     -[EView hitTest:withEvent:]
     -[EView pointInside:withEvent:]
     -[EView touchesBegan:withEvent:]
     -[CView touchesBegan:withEvent:]
     -[AView touchesBegan:withEvent:]
     -[EView touchesEnded:withEvent:]
     -[CView touchesEnded:withEvent:]
     -[AView touchesEnded:withEvent:]
     
    

    从打印结果我们可以看到最终EView视图先对事件进行了响应,同时将事件沿着响应链进行传递。

    以上打印结果我们会发现单机E视图后,从[AView hitTest:withEvent:][EView pointInside:withEvent:] 的过程会执行两遍,这个问题我查找了一些资料,但都没有好的答案,苹果那边的回复是这样的:

    Yes, it’s normal. The system may tweak the point being hit tested between the calls. Since hitTest should be a pure function with no side-effects, this should be fine.

    具体详见:https://lists.apple.com/archives/cocoa-dev/2014/Feb/msg00118.html

    意思就是说hitTest是一个没有副作用纯函数,进行多次调用也不会对外产生影响,因此系统可以多次调整调用之间被测试的点。

    这里并没有给出具体的调用两次的原因,你也可以理解为系统为了精确触摸的点,而进行了多次调用,但为什么是两次,我也没找到相关答案。

    3.事件拦截

    实际开发中我们经常会遇到如下需求

    事件拦截.gif

    TabbarItem上面添加提示视图tipView,当点击提示视图tipview,对应的Item也进行响应,并且提示视图tipView消失。

    很明显,这里的提示视图tipView是添加在Tabbar上面的,但是提示视图tipView的位置又超出了Tabbar的区域,这时我们点击提示视图tipView,会发现提示视图tipView得不到响应。

    我们看一下调用的堆栈:

    image.png image.png image.png

    从堆栈中我们得出如下分析:

    • 生成的触摸事件首先传到了UIWindow,然后UIWindow将事件传递给控制器的根视图UILayoutContainerView,

    • UILayoutContainerView判断自己可以响应触摸事件,然后将事件传递给子视图Tabbar

    • 子视图Tabbar判断触摸点并不在自己的坐标范围内,因此返回nil

    • 这时UILayoutContainerView将事件传递其他子视图UINavigationTransitionView,UINavigationTransitionView判断自己可以响应事件,就将事件时间传递给其子视图UIViewControllerWrapperView

    • UIViewControllerWrapperView判断自己可以响应事件,就将事件传递给子视图FJFFirstViewController控制器View

    • FJFFirstViewController控制器的View判断自己可以响应事件,然后就将事件传递给子视图AViewAView判断点击位置不在自己的坐标范围,返回nil,所以FJFFirstViewController控制器的View就是第一响应者。

    从这边的分析我们可以看出事件没有传递到提示视图tipView,在Tabbar这里就直接返回了,因为Tabbar判断点击位置不在自己的坐标范围内。

    因此我们需要做的就是修改TabbarhitTest:withEvent:函数里面判断点击位置是否在Tabbar坐标范围的的判断条件,也就是需要重写TabBardpointInside:withEvent:方法,判断如果当前触摸坐标子视图tipView上面,就返回YES,否则返回NO;这样一来时间就会最终传递到tipView上面,最终事件就会由tipView来响应。

    代码如下:

    #import "FJFTabbar.h"
    
    @implementation FJFTabbar
    
    //TabBar
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
        //将触摸点坐标转换到在CircleButton上的坐标
        CGPoint pointTemp = [self convertPoint:point toView:self.indicateView];
        //若触摸点在CricleButton上则返回YES
        if ([self.indicateView pointInside:pointTemp withEvent:event]) {
            return YES;
        }
        //否则返回默认的操作
        return [super pointInside:point withEvent:event];
    }
    @end
    

    三.事件的响应及传递

    经过Hit-Testing的过程后,UIApplication已经知道了第一响应者是谁,接下来要做的事情就是:

    • 将事件传递给第一响应者
    • 将事件沿着响应链传递

    A. 将事件传递给第一响应者:

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

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

    以点击EView视图为例,在EViewtouchesBegan:withEvent:上断点查看调用栈就能看清这一过程:

    image.png

    从这调用堆栈我们可以看出,UIApplication对于将事件传递给那个UIWindow是很明确的,UIWindow对于将事件传递给哪个视图也是很明确的。因为这些信息都放在了UIEventTouch事件里面。

    但是这些信息又是什么时候放入到UIEvent内部的呢?

    可想而知因为Hit-TestingSendEvent两者中的UIEvent是同一个UIEvent,所以这应该是在Hit-Testing寻找第一响应者的过程中,填入UIEvent内部的。

    B.将事件沿着响应链传递:

    因为每个响应者必定都是UIResponder对象,通过4个响应触摸事件的方法来响应事件。每个UIResponder对象默认都已经实现了这4个方法,但是默认不对触摸事件做任何处理,单纯只是将事件沿着响应链传递。若要截获事件进行自定义的响应操作,就要重写相关的方法。

    第一响应者接收到触摸事件后,就具有对触摸事件的处理权,它可以选择自己处理这个事件,也可以将这个事件沿着响应链传递给下一个响应者,这个由响应者之间构成的视图链就称之为响应链

    需要注意的是,上一节所说的事件传递的目的是为寻找事件的最佳响应者,是自下而上的传递;这里的事件传递目的是响应者做出对事件的响应,这个过程是自上而下的。前者为“寻找”,后者为“响应”。

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

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

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

    • 不拦截,默认操作
      事件会自动沿着默认的响应链往下传递

    • 拦截,不再往下分发事件
      重写 touchesBegan:withEvent:进行事件处理,不调用父类的 touchesBegan:withEvent:

    • 拦截,继续往下分发事件
      重写 touchesBegan:withEvent:进行事件处理,同时调用父类的 touchesBegan:withEvent:将事件往下传递

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

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

    • UIView
      若视图是控制器根视图,则其nextResponder为控制器对象;否则,其nextResponder为父视图。

    • UIViewController
      若控制器的视图是window的根视图,则其nextResponder为窗口对象;若控制器是从别的控制器present出来的,则其nextResponderpresenting view controller

    • UIWindow
      nextResponderUIApplication对象。

    • UIApplication
      若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewControllerapp本身,则UIApplicationnextResponderapp delegate

    举个🌰:

    事件响应示例.png

    如上图所示,响应者链如下:

    • 如果点击UITextField后其会成为第一响应者

    • 如果textField未处理事件,则会将事件传递给下一级响应者链,也就是其父视图

    • 父视图未处理事件则继续向下传递,也就是UIViewControllerView

    • 如果控制器的View未处理事件,则会交给控制器处理。

    • 控制器未处理则会交给UIWindow

    • 然后会交给UIApplication

    • 最后交给UIApplicationDelegate,如果其未处理则丢弃事件。

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

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

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

    - (void)printResponderChain {
        UIResponder *responder = self;
        printf("%s",[NSStringFromClass([responder class]) UTF8String]);
        while (responder.nextResponder) {
            responder = responder.nextResponder;
            printf(" --> %s",[NSStringFromClass([responder class]) UTF8String]);
        }
    }
    

    以点击EView为例,重写EViewtouch Begin:WithEvent:

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

    响应链如下:

    EView --> CView --> AView --> UIView --> FJFFirstViewController --> 
    UIViewControllerWrapperView --> UINavigationTransitionView --> 
    UILayoutContainerView --> UINavigationController --> 
    UIViewControllerWrapperView --> UITransitionView --> 
    UILayoutContainerView --> FJFTabBarViewController --> FJFWindow --> 
    FJFApplication --> AppDelegate
    

    另外如果有需要,完全可以重写响应者的 nextResponder 方法来自定义响应链。

    四.UIGestureRecognizer、UIControl

    上面我们讲述了UIResponder响应触摸事件的过程,但除了UIResponder之外,UIGestureRecognizerUIControl同样具备对事件的处理能力。

    以下将通过结合具体的示例来讲解UIGestureRecognizerUIControl是如何处理触摸事件的。

    举个例子:

    image.png

    代码:

    #pragma mark -------------------------- Life Circle
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        self.title = @"分类";
        
        // view tap
        FJFTapView *tmpContainerView = [[FJFTapView alloc] initWithFrame:CGRectMake(50, 80, 260, 300)];
        tmpContainerView.backgroundColor = [UIColor redColor];
        FJFTapGestureRecognizer *tapGesture = [[FJFTapGestureRecognizer alloc] initWithTarget:self action:@selector(viewTap:)];
        [tmpContainerView addGestureRecognizer:tapGesture];
        [self.view addSubview:tmpContainerView];
        
        // view longPress
        FJFLongPressView *tmpLongPressView = [[FJFLongPressView alloc] initWithFrame:CGRectMake(50, 400, 260, 200)];
        tmpLongPressView.backgroundColor = [UIColor grayColor];
        FJFLongPressGestureRecognizer *longPressGesture = [[FJFLongPressGestureRecognizer alloc] initWithTarget:self action:@selector(viewlongPress:)];
        [tmpLongPressView addGestureRecognizer:longPressGesture];
        [self.view addSubview:tmpLongPressView];
        
        // button
        FJFButton *tmpButton = [[FJFButton alloc] initWithFrame:CGRectMake(100, 50, 120, 80)];
        tmpButton.backgroundColor = [UIColor greenColor];
        [tmpButton setTitle:@"UIButton" forState:UIControlStateNormal];
        [tmpButton setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
        [tmpButton addTarget:self action:@selector(tmpButtonClicked:) forControlEvents:UIControlEventTouchUpInside];
        [tmpContainerView addSubview:tmpButton];
        
        // imageControl
        FJFImageControl *imageControl = [[FJFImageControl alloc] initWithFrame:CGRectMake(100, 150, 120, 80) title:@"imageControl" iconImageName:@"ic_red_box.png"];
        imageControl.backgroundColor = [UIColor blueColor];
        [imageControl addTarget:self action:@selector(imageControlTouch:) forControlEvents:UIControlEventTouchUpInside];
        [tmpContainerView addSubview:imageControl];
    }
    
    #pragma mark -------------------------- Response Event
    
    // tap
    - (void)viewTap:(UITapGestureRecognizer *)tap {
        NSLog(@"%s", __FUNCTION__);
    }
    
    // longPress
    - (void)viewlongPress:(UILongPressGestureRecognizer *)longPress {
        NSLog(@"%s", __FUNCTION__);
    }
    
    // buttonClicked
    - (void)tmpButtonClicked:(UIButton *)sender {
        NSLog(@"%s", __FUNCTION__);
    }
    
    // controlTouch
    - (void)imageControlTouch:(FJFImageControl *)imageControl {
         NSLog(@"%s", __FUNCTION__);
    }
    

    如代码所示:

    • FJFTapView 添加了继承自UITapGestureRecognizerFJFTapGestureRecognizer 单击手势

    • FJFLongPressView 添加了继承自UILongPressGestureRecognizerFJFLongPressGestureRecognizer 长按手势

    • UIButton 添加 点击事件

    • FJFImageControl 继承自UIControl,也添加了点击事件,且UIButtonFJFImageControl都是FJFTapView子视图

    观察各种情况的日志:

    1.点击FJFTapView:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFTapView touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewTap:]
    [FJFTapView touchesCancelled:withEvent:]
    

    2.长按FJFLongPressView:

    [FJFLongPressGestureRecognizer touchesBegan:withEvent:]
    [FJFLongPressView touchesBegan:withEvent:]
    [FJFThreeViewController viewlongPress:]
    [FJFLongPressView touchesCancelled:withEvent:]
    [FJFLongPressGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewlongPress:]
    

    3.点击UIButton:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFButton touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFButton touchesEnded:withEvent:]
    [FJFThreeViewController tmpButtonClicked:]
    

    4.点击FJFImageControl:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFImageControl touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewTap:]
    [FJFImageControl touchesCancelled:withEvent:]
    

    接下来我们一一解释这些现象:

    1. UIGestureRecognizer:

    手势分为离散型手势(discrete gestures)和持续型手势(continuous gesture)。系统提供的离散型手势包括点按手势([UITapGestureRecognizer](apple-reference-documentation://hcmEtJ0eLp))轻扫手势([UISwipeGestureRecognizer](apple-reference-documentation://hcKMJKvz5T)),其余均为持续型手势

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

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

    A. 离散型手势

    从点击FJFTapView的日志可以分析:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFTapView touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewTap:]
    [FJFTapView touchesCancelled:withEvent:]
    
    • UIWindow在将事件传递给第一响应者FJFTapView之前,先将事件传递给相关的手势识别器FJFTapGestureRecognizer

    • 若手势成功识别事件,就会取消第一响应者FJFTapView对事件的响
      应;

    • 若手势没能识别事件,第一响应者FJFTapView就会接手事件的处理。

    这里我们可以得出:UIGestureRecognizerUIResponder具有更高的事件响应的优先级

    这个结论我们也可以从官方文档中得出:

    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.

    还有一点需要注意的是:

    UIGestureRecognizer对事件的响应也是通过touch相关的4个方法来实现的,而这4个方法声明在UIGestureRecognizerSubclass.h中。

    image.png

    而这里UIWindow之所以知道要把事件传递给哪些手势识别器,主要还是通过UIEvent里面的gestureRecognizers数组来获取的,而数组里面的手势识别器是在Hit-Test View寻找第一响应者过程中填充的。

    image.png

    这里UIWindow会取出UIEvent里面的gestureRecognizers数组的手势识别器,将事件传递给各个手势识别器,如果有一个手势识别器识别了事件,其他的手势识别器就不会响应该事件

    注意:这里取出gestureRecognizers数组手势识别器,没有按照特定的顺序,比如说从前往后或是从后往前,可以通过hookUIGestureRecognizertouch相关方法,去追踪得出。

    因此我们可以分析日志:

    • UIWindow 先将事件传递给gestureRecognizers数组里的手势识别器,然后再传递给第一响应者FJFTapView.

    • 因为手势识别器识别事件,需要一定时间,因此FJFTapView先调用了touchesBegan,这是因为FJFTapGestureRecognizer成功识别了事件,UIApplication就会取消FJFTapView对事件的响应。

    B. 持续型手势

    从点击FJFLongPressView日志分析:

    [FJFLongPressGestureRecognizer touchesBegan:withEvent:]
    [FJFLongPressView touchesBegan:withEvent:]
    [FJFThreeViewController viewlongPress:]
    [FJFLongPressView touchesCancelled:withEvent:]
    [FJFLongPressGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewlongPress:]
    

    从日志我们可以看出长按手势回调了两次,我们通过分析两次调用的堆栈:

    第一次调用堆栈:

    第一次调用.png

    第二次调用堆栈

    第二次调用.png

    我们可以看出第一次调用是在runloop中通知监听的手势识别器的观察者,来通知长按手势识别器对长按事件进行响应,此时手势识别器stateUIGestureRecognizerStateBegan

    第二次调用是UIWindow 先将事件传递给UIEventgestureRecognizers数组里的手势识别器,然后长按手势识别器FJFLongPressGestureRecognizer识别成功进行回调,此时手势识别器stateUIGestureRecognizerStateEnded

    这里的调用逻辑其实跟单击手势识别器FJFTapGestureRecognizer相似,主要区别在于长按手势识别器FJFLongPressGestureRecognizer调用了两次

    C. 总结

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

    -UIWindow先将触摸事件传递给响应链上绑定的手势识别器,再发送给触摸对象对应的第一响应者

    • 手势识别器识别手势期间,若触摸对象的触摸状态发生变化,事件都是先发送给手势识别器,再发送给第一响应者

    • 手势识别器如果成功识别手势,则通知UIApplication取消第一响应者对于事件的响应,并停止向第一响应者发送事件。

    • 如果手势识别器未能识别手势,而此时触摸并未结束,则停止向手势识别器发送事件,仅向第一响应者发送事件。

    • 如果手势识别器未能识别手势,且此时触摸已经结束,则向第一响应者发送end状态的touch事件,以停止对事件的响应。

    D. 拓展

    手势识别器的3个属性:

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

    a. cancelsTouchesInView:

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

    以点击FJFTapView为例,将tapGesture.cancelsTouchesInView = NO;输出日志如下:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFTapView touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewTap:]
    [FJFTapView touchesEnded:withEvent:]
    

    从日志我们可以看出,即便FJFTapGestureRecognizer识别了点击手势,UIApplication也依旧将事件发送给FJFTapView.

    b. delaysTouchesBegan:

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

    以点击FJFTapView为例,将tapGesture.delaysTouchesBegan = YES;输出日志如下:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewTap:]
    

    从日志可以看出,手势识别器识别手势期间,事件不会传递给FJFTapView,因此FJFTapViewtouchesBegan:withEvent:不会被调用;而手势识别器成功识别手势后,独吞了事件,不会再传递给FJFTapView,因此只打印手势识别器识别成功后手势的绑定函数

    c. delaysTouchesEnded:

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

    2.UIControl

    UIControl是系统提供的能够以target-action模式处理触摸事件的控件,iOSUIButton、UISegmentedControl、UISwitch等控件都是UIControl的子类。

    值得注意的是,UIConotrolUIView的子类,因此本身也具备UIResponder应有的身份。

    UIControl作为控件类的基类,它是一个抽象基类,我们不能直接使用UIControl类来实例化控件,它只是为控件子类定义一些通用的接口,并提供一些基础实现,以在事件发生时,预处理这些消息并将它们发送到指定目标对象上。

    关于UIControl,此处介绍两点:

    • target-action机制
    • 触摸事件优先级

    Target-Action机制

    Target-action是一种设计模式,直译过来就是”目标-行为”。当我们通过代码为一个按钮添加一个点击事件时,通常是如下处理:

    [button addTarget:self action:@selector(tapButton:) forControlEvents:UIControlEventTouchUpInside];
    
    
    image.png

    注:图片来源于官方文档Cocoa Application Competencies for iOS – Target Action

    即当事件发生时,事件会被发送到控件对象中,然后再由这个控件对象去触发target对象上的action行为,来最终处理事件。因此,Target-Action机制由两部分组成:即目标对象Target行为Selector目标对象指定最终处理事件的对象,而行为Selector则是处理事件的方法。

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

    - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event {
        NSLog(@"%s",__func__);
        return YES;
    }
    
    
    - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event {
        NSLog(@"%s",__func__);
        return YES;
    }
    
    
    - (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event {
        NSLog(@"%s",__func__);
    }
    
    
    - (void)cancelTrackingWithEvent:(nullable UIEvent *)event {
        NSLog(@"%s",__func__);
    }
    

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

    我们来分析下FJFButton的日志输出以及调用堆栈:

    日志输出:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFButton touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFButton touchesEnded:withEvent:]
    [FJFThreeViewController tmpButtonClicked:]
    

    调用堆栈:

    FJFButton调用堆栈.png

    从以上信息,我们可以分析:

    • UIWindow 首先将事件传递给响应链上绑定的手势识别器FJFTapGestureRecognizer,再传递给第一响应者FJFButton

    • 手势识别器FJFTapGestureRecognizer第一响应者FJFButton分别调用touch相关方法对事件进行识别,

    • 最终第一响应者FJFButton对事件进行响应调用 sendAction:to:forEvent:target、action以及event对象发送给UIApplicationUIApplication对象再通过 sendAction:to:from:forEvent:target发送action

    通过这个结果,我们会疑问:UIControl比其父视图上的手势识别器具有更高的事件响应优先级?

    接下来我们看下继承自UIControlFJFImageControl的日志和调用堆栈:

    日志输出:

    [FJFTapGestureRecognizer touchesBegan:withEvent:]
    [FJFImageControl touchesBegan:withEvent:]
    [FJFTapGestureRecognizer touchesEnded:withEvent:]
    [FJFThreeViewController viewTap:]
    [FJFImageControl touchesCancelled:withEvent:]
    

    调用堆栈:

    image.png

    从以上信息,我们又可以得出::UIControl比其父视图上的手势识别器的优先级来的低?

    经验证系统提供的有默认action操作的UIControl,例如UIbuttonUISwitch等的单击,UIControl的响应优先级比手势识别器高,而对于自定义的UIControl,响应的优先级比手势低。

    至于为什么会这样,没找到具体原因,但测试的结果,推测系统应该是依据UITouchtouchIdentifier来进行区别处理。

    Target-Action的管理:
    UIControl通过addTarget方法和removeTarget方法来添加和删除Target-Action的操作。

    
    // 添加
    - (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents
     // 删除
    - (void)removeTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents
    
    

    如果想获取控件对象所有相关的target对象,则可以调用allTargets方法,该方法返回一个集合。集合中可能包含NSNull对象,表示至少有一个nil目标对象。

    而如果想获取某个target对象及事件相关的所有action,则可以调用actionsForTarget:forControlEvent:方法。

    不过,这些都是UIControl开放出来的接口。我们还是想要探究一下,UIControl是如何去管理Target-Action的呢?

    实际上,我们在程序某个合适的位置打个断点来观察UIControl的内部结构,可以看到这样的结果:

    image.png
    从图中我们可以看出,UIControl内部实际上是有一个可变数组(_targetActions)来保存Target-Action,数组中的每个元素是一个UIControlTargetAction对象。UIControlTargetAction类是一个私有类,内部维护
    @interface UIControlTargetAction : NSObject {
        SEL _action;
        BOOL _cancelled;
        unsigned int _eventMask;// 事件类型,比如:UIControlEventTouchUpInside
        id _target;
    }
    

    这四个变量,UIControl正是依据UIControlTargetAction来对事件进行处理。

    五.事件完整响应链

    • 系统通过 IOKit.framework来处理硬件操作,其中屏幕处理也通过IOKit完成(IOKit可能是注册监听了屏幕输出的端口)
      当用户操作屏幕,IOKit收到屏幕操作,会将这次操作封装为IOHIDEvent对象。通过mach port(IPC进程间通信)将事件转发给SpringBoard来处理。

    • SpringBoardiOS系统的桌面程序。SpringBoard收到mach port发过来的事件,唤醒main runloop来处理。

    • main runloop将事件交给source1处理,source1会调用__IOHIDEventSystemClientQueueCallback()函数。
      函数内部会判断,是否有程序在前台显示,如果有则通过mach portIOHIDEvent事件转发给这个程序。
      如果前台没有程序在显示,则表明SpringBoard的桌面程序在前台显示,也就是用户在桌面进行了操作。
      __IOHIDEventSystemClientQueueCallback()函数会将事件交给source0处理,source0会调用__UIApplicationHandleEventQueue()函数,函数内部会做具体的处理操作。

    • 例如用户点击了某个应用程序的icon,会将这个程序启动。
      应用程序接收到SpringBoard传来的消息,会唤醒main runloop并将这个消息交给source1处理,source1调用__IOHIDEventSystemClientQueueCallback()函数,在函数内部会将事件交给source0处理,并调用source0__UIApplicationHandleEventQueue()函数。
      __UIApplicationHandleEventQueue()函数中,会将传递过来的IOHIDEvent转换为UIEvent对象。

    • 在函数内部,将事件放入UIApplication的事件队列,等到处理该事件时,将该事件出队列,UIApplication将事件传递给窗口对象(UIWindow),如果存在多个窗口,则从后往前询问最上层显示的窗口

    • 窗口UIWindow通过hitTestpointInside操作,判断是否可以响应事件,如果窗口UIWindow不能响应事件,则将事件传递给其他窗口;若窗口能响应事件,则从后往前询问窗口的子视图。

    • 以此类推,如果当前视图不能响应事件,则将事件传递给同级上一个子视图;如果能响应,就从后往前遍历当前视图子视图

    • 如果当前视图子视图都不能响应事件,则当前视图就是第一响应者

    • 找到第一响应者,事件的传递的响应链也就确定的。

    • 如果第一响应者UIControl子类且响应链上也没有绑定手势识别器UIGestureRecognizer;

    • 那么由于第一响应者具有处理事件的最高优先级,因此UIApplication会先将事件传递给它供其处理。首先,UIApplication将事件通过 sendEvent: 传递给事件所属的windowwindow同样通过 sendEvent: 再将事件传递给hit-tested view,即第一响应者,第一响应者具有对事件的完全处理权,默认对事件不进行处理,传递给下一个响应者(nextResponder);如果响应链上的对象一直没有处理该事件,则最后会交给UIApplication,如果UIApplication实现代理,会交给UIApplicationDelegate,如果UIApplicationDelegate没处理,则该事件会被丢弃。

    • 如果第一响应者UIControl子类但响应链上也绑定了手势识别器UIGestureRecognizer;

    • UIWindow会将事件先发送给响应链上绑定的手势识别器UIGestureRecognizer,再发送给第一响应者,如果手势识别器能成功识别事件,UIApplication默认会向第一响应者发送cancel响应事件的命令;如果手势识别器未能识别手势,而此时触摸未结束,则停止向手势识别器发送事件,仅向第一响应者发送事件。如果手势识别器未能识别手势,且此时触摸已经结束,则向第一响应者发送end状态的touch事件,以停止对事件的响应。

    • 如果第一响应者是自定义的UIControl的子类同时响应链上也绑定了手势识别器UIGestureRecognizer;这种情况跟第一响应者UIControl子类但响应链上也绑定了手势识别器UIGestureRecognizer`处理逻辑一样;

    • 如果第一响应者UIControl的子类且是系统类(UIButton、UISwitch)同时响应链上也绑定了手势识别器UIGestureRecognizer;

    • UIWindow会将事件先发送给响应链上绑定的手势识别器UIGestureRecognizer,再发送给第一响应者,如果第一响应者能响应事件,UIControl调用调用sendAction:to:forEvent:target、action以及event对象发送给UIApplicationUIApplication对象再通过 sendAction:to:from:forEvent:target发送action

    六. 延伸阅读

    iOS事件处理,看我就够了~
    iOS触摸事件全家桶

    相关文章

      网友评论

        本文标题:iOS 事件(UITouch、UIControl、UIGestu

        本文链接:https://www.haomeiwen.com/subject/tfireqtx.html