话说事件响应链回顾

问题:给一个UIbutton 添加手势 和添加touchUpInside 和 重写touch:begin方法,谁会响应?

1.UITouch(源起触摸)

一个手指一次触摸屏幕,就对应生成一个UITouch对象。多个手指同时触摸,生成多个UITouch对象。
多个手指先后触摸,系统会根据触摸的位置判断是否更新同一个UITouch对象。若两个手指一前一后触摸同一个位置(即双击),那么第一次触摸时生成一个UITouch对象,第二次触摸更新这个UITouch对象(UITouch对象的 tap count 属性值从1变成2);若两个手指一前一后触摸的位置不同,将会生成两个UITouch对象,两者之间没有联系。
每个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)
};

1.1 UIEvent(事件的真身)

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

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

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

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

事件自下而上的传递

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

UIApplication ——> UIWindow ——> 子视图 ——> ... ——> 子视图
事实上把UIWindow也看成是视图即可,这样整个传递过程就是一个递归询问子视图能否响应事件过程,且后添加的子视图优先级高(对于window而言就是后显示的window优先级高)。

2. iOS中事件的产生和传递

1.发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的队列事件中

2.UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常会先发送事件给应用程序的主窗口(keyWindow)

3.主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件

事件的具体传递过程,如图:

image

一般事件的传递是从父控件传递到子控件的

例如:

1.点击了绿色的View,传递过程如下:UIApplication->Window->白色View->绿色View
2.点击蓝色的View,传递过程如下:UIApplication->Window->白色View->橙色View->蓝色View

如果父控件接受不到触摸事件,那么子控件就不可能接收到触摸事件

( 重难点)如何寻找最合适的view

应用如何找到最合适的控件来处理事件?有以下准则
1.首先判断主窗口(keyWindow)自己是否能接受触摸事件
2.触摸点是否在自己身上
3.从后往前遍历子控件,重复前面的两个步骤(首先查找数组中最后一个元素)
4.如果没有符合条件的子控件,那么就认为自己最合适处理

详述:
1.主窗口接收到应用程序传递过来的事件后,首先判断自己能否接手触摸事件。如果能,那么在判断触摸点在不在窗口自己身上
2.如果触摸点也在窗口身上,那么窗口会从后往前遍历自己的子控件(遍历自己的子控件只是为了寻找出来最合适的view)
3.遍历到每一个子控件后,又会重复上面的两个步骤(传递事件给子控件,1.判断子控件能否接受事件,2.点在不在子控件上)
4.如此循环遍历子控件,直到找到最合适的view,如果没有更合适的子控件,那么自己就成为最合适的view。

底层具体实现如下 :

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

注意:之所以会采取从后往前遍历子控件的方式寻找最合适的view只是为了做一些循环优化。因为相比较之下,后添加的view在上面,降低循环次数

UIView不能接收触摸事件的三种情况:

1.不接受用户交互:userInteractionEnabled = NO;
2.隐藏:hidden = YES;
3.透明:alpha = 0.0~0.01

image

说明一下控件的添加顺序:白1->绿2->橙2->蓝3->红3->黄4

这里点击了橙色的那块区域,事件传递判断过程如下:

1.UIApplication从事件队列中取出事件分发给UIWindow
2.UIWindow判断自己是否能接受触摸事件,可以
3.UIWindow判断触摸点是否在自己身上,是的。
4.UIWindow从后往前便利自己的子控件,取出白1
5.白1都满足最上面两个条件,遍历子控件橙2
6.橙2都满足最上面两个条件,遍历子控件,先取出红3
7.红3不满足条件2,取出蓝3
8.蓝3也不满足条件2,最后最合适的控件是橙2

寻找合适的View用到两个重要方法:

hitTest:withEvent:

pointInside
hitTest:withEvent:方法

什么时候调用?

只要事件一传递给一个控件,这个控件就会调用他自己的hitTest:withEvent:方法寻找合适的View

作用
寻找并返回最合适的view(能够响应事件的那个最合适的view)
注 意:不管这个控件能不能处理事件,也不管触摸点在不在这个控件上,
事件都会先传递给这个控件,随后再调用hitTest:withEvent:方法

hitTest:withEvent:底层调用流程:


image

事件传递给窗口或控件的后,就调用hitTest:withEvent:方法寻找更合适的view。所以是,先传递事件,再根据事件在自己身上找更合适的view。不管子控件是不是最合适的view,系统默认都要先把事件传递给子控件,经过子控件调用自己的hitTest:withEvent:方法验证后才知道有没有更合适的view。即便父控件是最合适的view了,子控件的hitTest:withEvent:方法还是会调用,不然怎么知道有没有更合适的!即,如果确定最终父控件是最合适的view,那么该父控件的子控件的hitTest:withEvent:方法也是会被调用的。

3.事件响应

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

响应者对于事件的操作方式:
响应者对于事件的拦截以及传递都是通过 touchesBegan:withEvent: 方法控制的,该方法的默认实现是将事件沿着默认的响应链往下传递。
响应者对于接收到的事件有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。

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

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

上文介绍了事件的传递过程,找到合适的View之后就会调用该view的touches方法要进行响应处理具体的事件,找不到最合适的view,就不会调用touches方法进行事件处理。

这里先介绍一下响应者链条:响应者链条其实就是很多响应者对象(继承自UIResponder的对象)一起组合起来的链条称之为响应者链条

一般默认做法是控件将事件顺着响应者链条向上传递,将事件交给上一个响应者进行处理 (即调用super的touches方法)。

那么如何判断当前响应者的上一个响应者是谁呢?有以下两个规则:

1.判断当前是否是控制器的View,如果是控制器的View,上一个响应者就是控制器

2.如果不是控制器的View,上一个响应者就是父控件


image
  • 找到最合适的view会调用touches方法处理事件
  • touches默认做法是把事件顺着响应者链条向上抛

//只要点击控件,就会调用touchBegin,如果没有重写这个方法,自己处理不了触摸事件
// 上一个响应者可能是父控件

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ 
// 默认会把事件传递给上一个响应者,上一个响应者是父控件,交给父控件处理
[super touchesBegan:touches withEvent:event]; 
// 注意不是调用父控件的touches方法,而是调用父类的touches方法
// super是父类 superview是父控件 

如果控制器也不响应响应touches方法,就交给UIWindow。如果UIWindow也不响应,交给UIApplication,如果都不响应事件就作废了。

用具体的例子来看下响应过程,


image

控件的添加顺序:红1->蓝2->绿2->黄3

image

绿色View实现了touch方法,如下

image

黄色view没有实现touch方法,如下


image

当点击黄色区域时,由于黄色view没有实现touch方法,就顺着响应链找到其父view(绿色View),绿色view实现了touch方法,便打印了-- touchGreen

最后总结来说一次完整的触摸事件的传递响应过程为:

UIApplication-->UIWindow-->递归找到最合适处理的控件-->控件调用touches方法-->判断是否实现touches方法-->没有实现默认会将事件传递给上一个响应者-->找到上一个响应者-->找不到方法作废

一句话总结整个过程是:触摸或者点击一个控件,然后这个事件会从上向下(从父->子)找最合适的view处理,找到这个view之后看他能不能处理,能就处理,不能就按照事件响应链向上(从子->父)传递给父控件

事件的传递和响应的区别:
事件的传递是从上到下(父控件到子控件),事件的响应是从下到上(顺着响应者链条向上传递:子控件到父控件。

4 实际项目中的应用

  • 情景1: 点击子控件,让父控件响应事件;(点击绿色View,红色View响应)
    分析:可通过两种方式实现

(1)因为hitTest:withEvent:方法的作用就是控件接收到事件后,判断自己是否能处理事件,判断点在不在自己的坐标系上,然后返回最合适的view。所以,我们可以在hitTest:withEvent:方法里面强制返回父控件为最合适的view.

#import "GreenView2.h"
 
@implementation GreenView2
 
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    return [self superview]; // return nil; 
    // 此处返回nil也可以。返回nil就相当于当前的view不是最合适的view
}
 
@end

让谁响应,就直接重写谁的touchesBegan: withEvent:方法

 #import "RedView1.h"
 
  @implementation RedView1
 
  -(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
  {
 
    NSLog(@"-- touchRed touchesBegan");
  }
 
  @end

情景2:点击子控件,父控件和子控件都响应事件


#import "GreenView2.h"
 
@implementation GreenView2
 
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"-- touchGreen");
[super touchesBegan:touches withEvent:event];
}

扩大UIButton的响应热区
相信大家都遇到小图button点击热区太小问题,之前我是用UIButton的setImage方法来设置图片解决,但是调起坐标就坑了,得各种计算不说,写出的代码还很难看不便于维护,如果我们用用hit-test view的知识你就能轻松地解决这个问题。
重载UIButton的-(BOOL)pointInside: withEvent:方法,让Point即使落在Button的Frame外围也返回YES。

- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event {
    return CGRectContainsPoint(HitTestingBounds(self.bounds, self.minimumHitTestWidth, self.minimumHitTestHeight), point);
}

CGRect HitTestingBounds(CGRect bounds, CGFloat minimumHitTestWidth, CGFloat minimumHitTestHeight) {
    CGRect hitTestingBounds = bounds;
    if (minimumHitTestWidth > bounds.size.width) {
        hitTestingBounds.size.width = minimumHitTestWidth;
        hitTestingBounds.origin.x -= (hitTestingBounds.size.width - bounds.size.width)/2;
    }
    if (minimumHitTestHeight > bounds.size.height) {
        hitTestingBounds.size.height = minimumHitTestHeight;
        hitTestingBounds.origin.y -= (hitTestingBounds.size.height - bounds.size.height)/2;
    }
    return hitTestingBounds;
}

2、子view超出了父view的bounds响应事件

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    /**
     *  此注释掉的方法用来判断点击是否在父View Bounds内,
     *  如果不在父view内,就会直接不会去其子View中寻找HitTestView,return 返回
     */
//    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
//    }
    return nil;
}
  • UIResponder、UIGestureRecognizer、UIControl区别

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

此处要探讨的是:手势识别器与UIResponder的联系

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

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

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

屏幕快照 2019-04-04 上午9.42.40.png

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

// LXFViewController
- (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, delaysTouchesEnded properties.

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

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

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

  • 然而看日志,却是YellowView的 touchesBegan:withEvent: 先调用了,既然手势识别器先响应,不应该上面的action先执行吗?

  • 手势识别器的action的调用时机(即此处的 actionTap)并不是手势识别器接收到事件的时机,而是手势识别器成功识别事件后的时机,即手势识别器的状态变为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> ,因为相关方法声明在该头文件中。
// LXFTapGestureRecognizer (继承自UITapGestureRecognizer)
- (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];
}
现在,再次点击YellowView,日志如下:
-[LXFTapGestureRecognizer touchesBegan:withEvent:]
-[YellowView touchesBegan:withEvent:]
-[LXFTapGestureRecognizer touchesEnded:withEvent:]
View Taped
-[YellowView touchesCancelled:withEvent:]

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

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

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

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上执行一次滑动:

屏幕快照 2019-04-04 上午9.42.30.png

日志打印如下:

-[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都会传递事件寻求响应。

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

手势识别器的3个属性

@property(nonatomic) BOOL cancelsTouchesInView;
@property(nonatomic) BOOL delaysTouchesBegan;
@property(nonatomic) BOOL delaysTouchesEnded;
  1. 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。

  1. 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调用。

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

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

  • UIControl

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

关于UIControl,此处介绍两点:

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: 添加事件处理的target和action,当事件发生时,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: 自定义事件执行的target及action。

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

触摸事件优先级

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高,但前提是相比于父视图上的手势识别器。

  • UIControl的响应优先级比手势识别器高的说法不准确,准确地说只适用于系统提供的有默认action操作的UIControl,例如UIbutton、UISwitch等的单击,而对于自定义的UIControl,经验证,响应优先级比手势识别器低

屏幕快照 2019-04-04 上午10.14.03.png

[图片上传失败...(image-90bba7-1554346016683)]

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

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

操作方式:单击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得不到执行。

其他:经测试,若示例一中的手势识别器设置 cancelsTouchesInView 为NO,手势识别器和button都能响应事件。也就是说这种情况下,button不会阻止父视图中手势识别器的识别

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

手势销毁? 滑动手势??

屏幕快照 2019-04-04 上午10.46.23.png
@interface UIScrollViewDelayedTouchesBeganGestureRecognizer : UIGestureRecognizer {
    UIView<UIScrollViewDelayedTouchesBeganGestureRecognizerClient> * _client;
    struct CGPoint { 
        float x; 
        float y; 
    }  _startSceneReferenceLocation;
    UIDelayedAction * _touchDelay;
}
- (void).cxx_destruct;
- (id)_clientView;
- (void)_resetGestureRecognizer;
- (void)clearTimer;
- (void)dealloc;
- (void)sendDelayedTouches;
- (void)sendTouchesShouldBeginForDelayedTouches:(id)arg1;
- (void)sendTouchesShouldBeginForTouches:(id)arg1 withEvent:(id)arg2;
- (void)touchesBegan:(id)arg1 withEvent:(id)arg2;
- (void)touchesCancelled:(id)arg1 withEvent:(id)arg2;
- (void)touchesEnded:(id)arg1 withEvent:(id)arg2;
- (void)touchesMoved:(id)arg1 withEvent:(id)arg2;
@end
\\

//TouchEventHook.m
+ (void)load{
    Class aClass = objc_getClass("UIScrollViewDelayedTouchesBeganGestureRecognizer");
    SEL sel = @selector(hook_sendTouchesShouldBeginForDelayedTouches:);
    Method method = class_getClassMethod([self class], sel);
    class_addMethod(aClass, sel, class_getMethodImplementation([self class], sel), method_getTypeEncoding(method));
    exchangeMethod(aClass, @selector(sendTouchesShouldBeginForDelayedTouches:), sel);
}

- (void)hook_sendTouchesShouldBeginForDelayedTouches:(id)arg1{
    [self hook_sendTouchesShouldBeginForDelayedTouches:arg1];
}

void exchangeMethod(Class aClass, SEL oldSEL, SEL newSEL) {
    Method oldMethod = class_getInstanceMethod(aClass, oldSEL);
    Method newMethod = class_getInstanceMethod(aClass, newSEL);
    method_exchangeImplementations(oldMethod, newMethod);
}

现象一由于点击后,UIScrollViewDelayedTouchesBeganGestureRecognizer 拦截了事件并延迟了0.15s发送。又因为点击时间比0.15s短,在发送事件前触摸就结束了,因此事件没有传递到hit-tested view,导致TableView的 touchBegin 没有调用。而现象二,由于短按的时间超过了0.15s,手势识别器拦截了事件并经过0.15s后,触摸还未结束,于是将事件传递给了hit-tested view,使得TableView接收到了事件。因此现象二的日志虽然和离散型手势Demo中的日志一致,但实际上前者的hit-tested view是在触摸后延迟了约0.15s左右才接收到触摸事件的。

屏幕快照 2019-04-04 上午10.52.35.png

]

答案:
现象一 快速点击cell

backview taped???


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


现象二 短按cell

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

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


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

现象三 长按cell

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

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



现象四 点击button

-[GLButton touchesBegan:withEvent:]
-[GLButton touchesEnded:withEvent:]
button clicked!
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,980评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,422评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,130评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,553评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,408评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,326评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,720评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,373评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,678评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,722评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,486评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,335评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,738评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,283评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,692评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,893评论 2 335

推荐阅读更多精彩内容