美文网首页
iOS响应链

iOS响应链

作者: anny_4243 | 来源:发表于2022-02-23 18:05 被阅读0次

摘自《iOS开发快速进阶与实战》

自从iPhone问世以来,iPhone手机采用CocoaTouch框架,使其更注重于图形化和触摸操作,其中,基于Foundation的UIKit是实现Cocoa Touch最主要的框架,UIKit提供了iOS设备上图形化事件驱动程序的基本工具。

当一个应用程序展示在iOS设备上时,我们会考虑两个问题:界面如何展示?界面是如何交互的?本节将详细回答第二个问题。

当用户的手真正触摸到屏幕时,程序内部是如何响应的?实际上,当触摸到屏幕时会生成一个TouchEvent(触摸事件),添加到UIApplication管理的事件队列中,UIApplication会从事件队列中依次取出事件来分发到应响应的视图去处理(关于这个触摸事件的队列,我们在iOS开发中也有遇到过,例如在我们启动程序后的某一时间正执行到断点处,而我们并没有注意到此时处于断点,就在屏幕上进行各种操作,但此时是没有反应的,因为UIApplication虽然收到了我们的Touch Event,但由于系统处于暂停状态,无法继续处理交互事务,因此事件队列一直处于积累交互事件的过程中,当我们跳过断点,程序继续执行后,会发现程序会依次响应之前积累的触摸事件)。当触摸事件被UIApplication发出后,会从程序的keyWindow开始,然后依次向上传递,包括各种视图控制器以及视图,最后找到合适的处理该事件的视图来响应,这整个过程就称为事件传递。

如下图所示,展示了几个view的层级示意图,其层级关系如下。

响应链视图示例

A为B、C的父视图;C为D、E的父视图。

当触摸视图B时,事件传递的顺序为:UIApplication→A→B。

当触摸视图D时,事件传递的顺序为:UIApplication→A→C→D。

当触摸视图E时,事件传递的顺序为:UIApplication→A→C→E。

那么系统是根据什么来判定事件的传递顺序的呢?难道仅仅是根据子视图吗?事实上,这里涉及两个非常重要的方法:

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

这两个方法是事件传递机制的关键所在。这两个方法是UIView提供的,但并非表明只有UIView才能响应事件传递,因为除了UIView,UIViewController也是可以响应事件传递的,所以它们拥有事件传递的能力取决于它们共同的父类UIResponder(所以Window继承自UIView,也可以响应)。

当UIApplication发送事件到keyWindow时,keyWindow会调用-hitTest:withEvent:方法来寻找最适合处理事件的视图。假设事件已经传递到了某视图view,选择出能响应视图的逻辑如下。

(1)首先会判断该视图自身能否处理该触摸事件,如果不能响应,则不通过pointInside方法,则hitTest方法直接返回nil;

(2)如果该View可以响应,则调用-pointInside:withEvent:判断是否在显示区域上,如果不在其区域中,则返回NO,同时-hitTest:withEvent:也返回nil;

(3)如果步骤2中返回YES,表示在当前View的范围中,接着先倒序遍历该视图的子视图数组,按照当前的顺序,直到某一子视图可以响应,并且-hitTest:withEvent:返回该子视图;

(4)如果步骤3中没有子视图,或者没有任何一个子视图能够响应该触摸事件,则返回该视图自身,表示只有自身可以处理该事件。

以上步骤用代码来表示的话,或者说-hitTest:withEvent:方法的原理如下。

//point是该视图的坐标系上的点
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    //1.判断自己能否接收触摸事件
    if(self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01)
    return nil;
    //2.判断触摸点在不在自己范围内
    if(![self pointInside:point withEvent:event]) return nil;
    //3.从后往前遍历自己的子控件,看是否有子控件更适合响应此事件
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
        UIView *childView = self.subview[i];
        CGPoint childPoint = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childPoint withEvent:event];
        if (fitView) {
            return fitView;
        }
    }
    //4.没有找到比自己更合适的view
    return self;
}

视图如果满足以下三个条件其一,则不能接收触摸事件。
(1)userInteractionEnabled=NO;
(2)hidden=YES;
(3)alpha<0.01。

注:在实际代码中,-hitTest:withEvent:和-pointInside:withEvent:两个方法会分别调用两次,或者可能会有更多次,这可能是由于iOS响应链机制的原因,当然也可能是iOS触摸事件的判断逻辑,对此官方并没有给出详细的解释。所以读者在此需要注意一下,在以下的例子中只给出一次打印来表示其逻辑。

以下通过一个实际的案例来深入了解一下响应链机制。

首先构建一个demo,如下图所示。

图中字母A~E分别表示不同的视图View,数字1~6表示之后要单击的位置。并且A为B、C、D的父视图,D为E的父视图。接下来,分成几组操作并通过控制台的打印来分析响应链的传递过程。

同时,也先介绍一下代码逻辑。在工程中,首先创建一个BaseView,这是父类,A~E都继承BaseView。在BaseView中,重写三个系统方法,并做出响应的打印。

#import "BaseView.h"

@implementation BaseView

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

-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    UIView *v = [super hitTest:point withEvent:event];
    NSLog(@"hitTest ---- %@ return: %@",[self class], v.class);
    return v;
}

-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
    BOOL b = [super pointInside:point withEvent:event];
    NSLog(@"pointInside ---- %@ return: %@",[self class],b?@"YES":@"NO");
    return b;
}

@end

我们重写了touchesBegan、hitTest、pointInside三个方法,在父类中对其重写显得更为方便一些。同时基于BaseView创建AView、BView、CView、DView、EView并加到控制器的view上。

#import "ViewController.h"
#import "AView.h"
#import "BView.h"
#import "CView.h"
#import "DView.h"
#import "EView.h"

@interface ViewController ()

@property (nonatomic, strong) AView *a;
@property (nonatomic, strong) BView *b;
@property (nonatomic, strong) CView *c;
@property (nonatomic, strong) DView *d;
@property (nonatomic, strong) EView *e;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    self.a = [[AView alloc]initWithFrame:[UIScreen mainScreen].bounds];
    [self.view addSubview:self.a];
    
    self.b = [[BView alloc]initWithFrame:CGRectMake(70, 70, 260, 260)];
    [self.a addSubview:self.b];
    
    self.c = [[CView alloc]initWithFrame:CGRectMake(100, 100, 200, 200)];
    [self.a addSubview:self.c];
    
    self.d = [[DView alloc]initWithFrame:CGRectMake(130, 130, 140, 140)];
    [self.a addSubview:self.d];
    
    self.e = [[EView alloc]initWithFrame:CGRectMake(-30, 30, 200, 80)];
    self.e.backgroundColor = [UIColor colorWithWhite:0 alpha:0.3];
    [self.d addSubview:self.e];
    
    [self setViewBorder:self.a];
    [self setViewBorder:self.b];
    [self setViewBorder:self.c];
    [self setViewBorder:self.d];
    [self setViewBorder:self.e];
}

- (void)setViewBorder:(UIView *)v{
    v.layer.borderWidth = 1;
    v.layer.borderColor = [UIColor blackColor].CGColor;
}

如之前所说的那样,A为B、C、D的superView,D为E的superView。为了方便区分每个View的显示,特地添加setViewBorder方法为相应的View的layer添加边宽。并且,我们没有为任何View设置不可交互,所以每个view都是可以响应触摸事件的。至此,一切准备就绪了,接下来开始实验。

单击位置1,打印结果如下。

可以看到,首先调用了AView的pointInside,因为单击的是AView的区域,所以返回的是YES,下一步是遍历AView的subViews数组,注意遍历是倒序的,在AView中的subViews数组顺序分别是:B、C、D,先判断DView的pointInside方法,然而由于不在D的区域中,返回了NO,所以D的hitTest方法直接放回nil,DView的判断结束,转到AView的subViews数组中D的上一个子视图C(因为是倒序的)。由于单击位置也不在CView的区域中,CView的pointInside也返回NO,同时CView的hitTest返回nil,即C也不能响应,同理BView也不能,至此AView的subViews倒序遍历结束,回到AView的本身,此时1位置是在A的区域中,即AView的pointInside方法返回YES,并且hitTest也返回了AView本身,表示AView可以自己处理该触摸事件。注意,最后还有一个打印touchBegan---AView,这个后面再做分析。

单击位置2,打印结果如下。

位置2在BView上,属于AView的子View。首先AView先接收到触摸事件,通过pointInside方法返回YES,表示在AView的区域中,接着判断AView的子View,从DView到CView和BView,然而位置2属于BView所在位置,不在CView和DView上,因此先调用了DView的pointInside返回NO,然后DView的hitTest也返回nil,同理CView也是。一直到BView,pointInside返回YES,并且BView没有subViews,因此返回了自身,即hitTest返回了可以响应的BView。至此AView的subViews遍历结束,到AView本身,即调用AView的hitTest方法,也返回了BView。同时touchBegan方法打印了BView和AView。

单击位置3,打印结果如下。

位置3属于AView的CView上,所以AView接收到触摸事件后,由于是在其响应区域,所以AView的pointInside返回YES,然后遍历AView的subViews,DView不在区域内不能响应,到CView,在其区域,可以响应,hitTest返回CView自身,同时到AView的hitTest方法,也返回了CView,所以CView为最终响应的View。touchBegan打印了CView和AView。读者看到这里可能就会产生疑问,B同样是A的子View,为什么B不会调用pointInside和hitTest打印的方法呢?原因如下。

前面说过,对于subView的响应顺序是倒序的,也就是先从③开始,所以关于D,执行了pointInside和hitTest方法。到了②,已经找到了响应当前单击事件的视图CView,并返回,至此响应链结束,也就没有BView什么事了,因此B上没有执行pointInside和hitTest方法,也就是没有打印输出。

单击位置4,打印如下。

位置4在AView的DView上,但不在DView的EView上。同样先是AView区域内,倒序遍历subViews,在DView的区域中,所以DView的pointInside返回YES,然后倒序遍历DView的subViews,首先判断EView的pointInside,返回NO,表示不在EView的区域内,EView的hitTest也直接返回nil,所以只有DView本身去响应该触摸事件,即DView和AView的hitTest方法都返回了DView。touchBegan打印了DView和AView。

单击位置5,打印如下。

与之前的都一样,在AView的区域中,AView的pointInside返回YES,倒序遍历AView的subViews,首先DView的pointInside也返回YES,表示在DView的区域内,再遍历DView的subViews,EView的pointInside也返回了YES,表示在EView的区域内,EView没有subViews,所以调用EView的hitTest方法,返回了EView,同理DView和AView也返回了EView,表示EView响应该次触摸事件。touchBegan返回EView、DView、AView。

单击位置6,打印如下。

位置6属于AView的DView的EView超出DView的区域上,与位置5相比,虽然都属于EView,但打印结果不同,也可以看出逻辑是不同的。同样,AView的pointInside返回YES,表示在AView上,然后判断DView的hitTest,返回NO,表示不在DView的区域中,接着判断DView同级的CView,CView的pointInside返回YES,即在其区域中,CView的hitTest返回了CView自身,AView也返回了CView,表示由CView来响应该次触摸事件。touchBegan打印了CView和AView。

至此,图示的6种打印都已结束。接下来解释一下,什么是响应者链。通过以上的单击打印测试,响应者链可以简单理解为pointInside返回YES,并且hitTest方法返回非空的View都属于响应者链的一部分,这与上面所提到的touchBegan是相对应的。这只是我们通过实验得到的结果。那什么是响应者呢?

响应者:继承自UIResponder的类都称为响应者。

问一个问题,你知道UIView和UIViewController的父类是谁吗?没错,实际开发中经常打交道的这两个类都是继承自UIResponder类,这个类对于开发者来说似乎很少见到和用到,但UIResponder类提供了一些常用的方法,例如becomeFirstResponder、touchesBegan、motionBegan等一系列方法。

如下图所示,在Apple的官方文档中提供了iOS中响应链层级。

响应链层级

可以看到,当寻找出一个响应者接收响应事件的时候,也确定了该次响应的响应链,包括view、控制器的view、控制器、Window、Application。这里结合图3-4提及一个场景,就是实际开发中经常采用UITabBarViewController结构来搭建APP。我们知道,在UITabBarView-Controller中,每个item都包含另外一个控制器,甚至可能是导航栏控制器。举个简单例子,例如在UITabBarViewController结构中,包含若干个item,这里先只看第一个,第一个item是一个导航栏控制器,导航栏控制器有一个根控制器,假设称之为FirstViewController,在FirstViewController的view上有一个button,当我们单击button的时候,形成的响应链是如何的呢?结合图3-4可以分析出,响应链为:button→FirstViewController.view→FirstViewController→FirstViewController.navigationCtroller.view→FirstViewController.navigationCtroller→UITabBarViewController.view→UITabBarController→Window→Application。

本节小结

当我们单击屏幕时,系统会记录该次的触摸事件,添加到Application的事件队列中,然后从keyWindow开始依次向上寻找,结合响应者的pointInside方法和hitTest方法找出处理该触摸事件的View,从而也形成一条事件响应链。

结合本节响应链的知识点,在实际开发中有很多使用的场景,例如处理多个UIScrollView的手势冲突,扩大UIButton的响应范围,这些都是通过重写pointInside方法或者hitTest方法来实现的。但与此同时,开发者应当注意,使用前应当思考充分,避免屏蔽了其他单击事件的响应链。

相关文章

  • iOS 响应链

    iOS开发 - 事件传递响应链iOS 响应者链,事件的传递事件传递之响应链Cocoa Touch事件处理流程--响...

  • iOS响应者链

    iOS响应者链

  • 二、事件传递链和响应者链

    iOS触摸事件详解iOS开发-事件传递响应链 响应者链 UIResponser包括了各种Touch message...

  • 响应链

    iOS事件响应链中Hit-Test View的应用从iOS的事件响应链看TableView为什么不响应touche...

  • tableView 与collectionView嵌套 coll

    这里就要说到 iOS 的响应链iOS 的所有点击方法 都是用响应链 传递到最底层的 所以可以截取响应链 让coll...

  • iOS中对于响应链的理解

    对于响应链的理解: 在IOS中,有响应者链对事件进行响应,所有的响应类都是UIResponder的子类,响应者链是...

  • 深入浅出iOS事件机制

    深入浅出iOS事件机制事件传递:响应链事件传递响应链

  • iOS响应者链

    参考好文 iOS开发-事件传递响应链,用运行时分析 iOS事件传递:响应者链[译] http://www.jian...

  • UIView 和 CALayer

    从iOS的响应链开始说起 最近在看iOS 的响应链 看到了这样的关系 因为UIView 继承自 UIRespond...

  • ios响应者链

    iOS 响应者链 字数418 阅读41 评论0 喜欢3 响应者链 响应者链是一个响应者的连接序列,事件或者动作消息...

网友评论

      本文标题:iOS响应链

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