iOS 点击事件传递及响应

2018 - 05 - 02

Posted by QunarFlight团队博客

本文由我们团队刘明志 童鞋组内分享后总结。
前言:苹果的官方文档《Understanding Event Handling, Responders, and the Responder Chain》对事件处理做了非常详尽清晰的解释,建议大家仔细研读

事件的传递

  1. 发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的队列事件中
  2. UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常会先发送事件给应用程序的主窗口(keyWindow)
  3. 主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件

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

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

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

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

  • 不接受用户交互:userInteractionEnabled = NO
  • 隐藏:hidden = YES
  • 透明:alpha = 0.0~0.01

寻找最合适的view过程,如图:
寻找最合适的view过程图

说明一下控件的添加顺序:白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时,底层到底干了哪些事?

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

  • hitTest:withEvent:
  • pointInside
1. hitTest:withEvent:方法

什么时候调用?

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

作用

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

hitTest:withEvent:底层调用流程:
底层调用流程图

底层具体实现如下 :

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {  
    // 1.判断当前控件能否接收事件  
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha 
    // 2. 判断点在不在当前控件  
    if ([self pointInside:point withEvent:event] == NO) return nil;  
    // 3.从后往前遍历自己的子控件  
    NSInteger count = self.subviews.count;  
    for (NSInteger 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; }  // 寻找到最合适的view  
    }  

    // 循环结束,表示没有比自己更合适的view  
    return self;  
}  

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

hitTest:withEvent:方法忽略隐藏(hidden=YES)的视图,禁止用户操作(userInteractionEnabled=YES)的视图,以及alpha级别小于0.01(alpha **
该方法判断触摸点是否在控件身上, 是则返回YES, 否则返回NO

作用
可以使用以上两个方法做到:

  • 指鹿为马(明明点击的是B视图, 却由A视图来响应事件)
  • 穿透某控件点击被覆盖的下一层控件
  • 让父控件frame之外的子控件响应触摸事件

事件响应

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

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

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

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

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

响应过程如下图:
响应过程图

touch响应:

  • 找到最合适的view会调用touches方法处理事件
  • touches默认做法是把事件顺着响应者链条向上抛
// 只要点击控件,就会调用touchBegin  
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ 
// 默认会把事件传递给上一个响应者, 即交给父控件处理  
[super touchesBegan:touches withEvent:event]; 
// 注意不是调用父控件的touches方法,而是调用父类的touches方法  
// super是父类 superview是父控件 
}  

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

例子

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

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

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

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

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

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

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

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

实际项目中的应用

情景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  
    
  2. 让谁响应,就直接重写谁的touchesBegan: withEvent:方法
    #import "RedView1.h"  
    @implementation RedView1  
    -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {  
      NSLog(@"-- touchRed touchesBegan");  
    }  
    @end  
    

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

事件的响应是顺着响应者链条向上传递的,即从子控件传递给父控件,touch方法默认不处理事件,而是把事件顺着响应者链条传递给上一个响应者。这样我们就可以依托这个原理,让一个事件多个控件响应

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

参考资料

Table of Contents