iOS事件传递和响应机制-原理篇

前言:

首先,在我们iOS里,事件的整个周期包括事件的产生、事件的传递以及事件的响应。产生一般是来源于用户的交互,比如点击。事件的传递是正向的,从父控件一直循环往下传递给子控件直到寻找到最合适的view。事件的响应是反向的,顺着响应者链条向上传递,由最上一级控件来处理事件。

在这一整个过程中,比较难理解的点是:

1、如何找到最适合的view 

2、寻找最合适的view的底层实现(hitTest:withEvent:底层实现)

一、iOS中的事件

iOS中的事件可以分为3大类型:

  • 触摸事件
  • 加速计事件
  • 远程控制事件

        这里我们只讨论iOS中的触摸事件

1.1、响应者对象

在我们iOS里面,不是任何对象都能处理事件,只有继承了UIResponder的对象才能接受并处理事件,我们称之为“响应者对象”。像UIApplication、UIViewController、UIView都是继承自UIResponder的,所以都能接收并处理事件。

而响应者对象之所以能处理触摸事件是因为它内部提供了4个对象方法来处理事件:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

相应的加速计时间和远程控制事件的处理对象,也是在内部提供了响应的对象方法:

加速计事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;
远程控制事件
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;


二、事件的处理

下面以UIView为例来说明触摸事件的处理。

// UIView是UIResponder的子类,可以覆盖下列4个方法处理不同的触摸事件
// 一根或者多根手指开始触摸view,系统会自动调用view的下面方法
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
// 一根或者多根手指在view上移动,系统会自动调用view的下面方法(随着手指的移动,会持续调用该方法)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
// 一根或者多根手指离开view,系统会自动调用view的下面方法
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
// 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用view的下面方法
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
// 提示:touches中存放的都是UITouch对象

需要注意的是:以上四个方法是由系统自动调用的,所以可以通过重写该方法来处理一些事件。

如果两根手指同时触摸一个view,那么view只会调用一次touchesBegan:withEvent:方法,touches参数中装着2个UITouch对象

如果这两根手指一前一后分开触摸同一个view,那么view会分别调用2次touchesBegan:withEvent:方法,并且每次调用时的touches参数中只包含一个UITouch对象

重写以上四个方法,如果是处理UIView的触摸事件。必须要自定义UIView子类继承自UIView。因为苹果不开源,没有把UIView的.m文件提 供给我们。我们只能通过子类继承父类,重写子类方法的方式处理UIView的触摸事件(注意:我说的是UIView触摸事件而不是说的 UIViewController的触摸事件)。

如果是处理UIViewController的触摸事件,那么在控制器的.m文件中直接重写那四个方法即可!

有些同学会说,我要是处理控制器的自带的view的事件就不需要自定义UIView子类继承于UIView,因为可以在viewController.m 文件中直接重写touchBegan:withEvent:方法。但是,你如果是在viewController.m文件中重写touchBegan:withEvent:方法,相当于处理的是viewController的触摸事件,而不是我们这里讨论的处理view的事件,因为viewController也是继承自UIResponder,所以会给人一种错觉。
所以说想处理UIView的触摸事件,必须自定义一个继承自UIView的子类。

2.1 UIView的拖拽

怎么实现UIView的拖拽呢,这就需要我们重写- touchsMoved:withEvent:方法。
此时需要用到参数touches,下面是UITouch的属性和方法:

2.1.1.UITouch对象

  • 当用户用一根手指触摸屏幕时,会创建一个与手指相关的UITouch对象
  • 一根手指对应一个UITouch对象
  • 如果两根手指同时触摸一个view,那么view只会调用一次touchesBegan:withEvent:方法,touches参数中装着2个UITouch对象
  • 如果这两根手指一前一后分开触摸同一个view,那么view会分别调用2次touchesBegan:withEvent:方法,并且每次调用时的touches参数中只包含一个UITouch对象

2.1.2 UITouch的作用

  • 保存着跟手指相关的信息,比如触摸的位置、时间、阶段
  • 当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指在的触摸位置
  • 当手指离开屏幕时,系统会销毁相应的UITouch对象
     

2.1.3 UITouch的属性

触摸产生时所处的窗口
@property(nonatomic,readonly,retain) UIWindow *window;

触摸产生时所处的视图
@property(nonatomic,readonly,retain) UIView *view
;

短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或更多的点击
@property(nonatomic,readonly) NSUInteger tapCount;

记录了触摸事件产生或变化时的时间,单位是秒
@property(nonatomic,readonly) NSTimeInterval timestamp;

当前触摸事件所处的状态
@property(nonatomic,readonly) UITouchPhase phase;

2.1.4 UITouch的方法

(CGPoint)locationInView:(UIView *)view;
// 返回值表示触摸在view上的位置
// 这里返回的位置是针对view的坐标系的(以view的左上角为原点(0, 0))
// 调用时传入的view参数为nil的话,返回的是触摸点在UIWindow的位置

(CGPoint)previousLocationInView:(UIView *)view;
// 该方法记录了前一个触摸点的位置

代码实现:

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{ 
    // 想让控件随着手指移动而移动,监听手指移动 
    // 获取UITouch对象 
    UITouch *touch = [touches anyObject]; 
    // 获取当前点的位置 
    CGPoint curP = [touch locationInView:self]; 
    // 获取上一个点的位置 
    CGPoint preP = [touch previousLocationInView:self]; 
    // 获取它们x轴的偏移量,每次都是相对上一次 
    CGFloat offsetX = curP.x - preP.x; 
    // 获取y轴的偏移量 
    CGFloat offsetY = curP.y - preP.y; 
    // 修改控件的形变或者frame,center,就可以控制控件的位置 
    // 形变也是相对上一次形变(平移) 
    // CGAffineTransformMakeTranslation:会把之前形变给清空,重新开始设置形变参数 
    // make:相对于最原始的位置形变 
    // CGAffineTransform t:相对这个t的形变的基础上再去形变 
    // 如果相对哪个形变再次形变,就传入它的形变 
    self.transform = CGAffineTransformTranslate(self.transform, offsetX, offsetY);}

三、事件的产生和传递

3.1 事件的产生

  • 当我们触摸屏幕时,系统会添加一个触摸事件到由UIApplication管理的队列中
  • UIApplication会从事件队列中取出最前面的事件,并将事件分发下去处理。通常会先发送给主窗口keyWindow
  • keyWindow会在视图层次结构中找到一个最合适的视图来处理事件
  • 找到合适的视图后,就会调用视图的touches方法来处理具体的事件

3.2 事件的传递

  • 触摸事件的传递是从父控件-->子控件传递的
  • 也就是UIApplication-->Window-->最合适的view

3.2.1 如何寻找最适合的控件来处理事件

  • 首先判断主窗口是否能接受触摸事件
  • 判断触摸点是否在自己身上
  • 然后再遍历自己的子控件,判断子控件是否满足前面的两个步骤(注意这里是从最后一个元素开始遍历的,因为如果两个子控件重叠了,后添加的在前面优先响应,这样能减少循环次数)
  • 假设我们找到符合条件的控件叫做someView,那么事件又会传递给someView,someView又会用相同的方式遍历它自己的子控件,直到没有更合适的view
  • 如果someView没有符合条件的子控件,那么就会认定someView就是那个最合适的view

这里我们需要注意,UIView在下面三种情形下是不能接受触摸事件的

  • 不允许交互:userInteractionEnabled = NO
  • 被隐藏:如果控件hidden=YES,不能接受事件
  • 透明度:如果一个控件的透明度<0.01,也是不能接受触摸事件的

3.2.2 寻找最合适的view底层剖析

这里主要有2个方法:

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

3.2.2.1​​​​​​​ hitTest: withEvent:

只要事件一传递给一个控件,控件就会调用自己的hitTest方法,寻找并返回最合适的view。​​​​​​​

所以事件的传递顺序是这样的:

产生触摸事件->UIApplication事件队列->[UIWindow hitTest:withEvent:]->返回更合适的view->[子控件 hitTest:withEvent:]->返回最合适的view

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

​​​​​​​​​​​​​​注意:

  • 不管控件是否可以处理事件、不管触摸点是否在控件上,只要事件传递给它,就会立即调用hitTest方法。
  • 如果hitTest:withEvent:方法中返回nil,那么调用该方法的控件本身和其子控件都不是最合适的view,也就是在自己身上没有找到更合适的view。那么最合适的view就是该控件的父控件

拦截事件:

  • hitTest: withEvent: 方法是返回最适合的view的方法,那么我们就可以通过重写这个方法来拦截事件指定最合适的view。
  • 想让谁成为最合适的view就重写谁自己的父控件的hitTest:withEvent:方法返回指定的子控件,或者重写自己的hitTest:withEvent:方法 return self。但是,建议在父控件的hitTest:withEvent:中返回子控件作为最合适的view!原因在于在自己的hitTest:withEvent:方法中返回自己有时候会出现问题。因为会存在这么一种情况:当遍历子控件时,如果触摸点不在子控件A自己身上而是在子控件B身上,还要要求返回子控件A作为最合适的view,采用返回自己的方法可能会导致还没有来得及遍历A自己,就有可能已经遍历了点真正所在的view,也就是B。这就导致了返回的不是自己而是触摸点真正所在的view。所以还是建议在父控件的hitTest:withEvent:中返回子控件作为最合适的view!

底层实现:

hitTest: withEvent:方法底层是如何实现的呢

#import "CustomWindow.h"
@implementation CustomWindow.h
// 什么时候调用:只要事件一传递给一个控件,那么这个控件就会调用自己的这个方法
// 作用:寻找并返回最合适的view
// UIApplication -> [UIWindow hitTest:withEvent:]寻找最合适的view告诉系统
// point:当前手指触摸的点
// 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] == NO) return nil; 
    // 3.从后往前遍历子控件数组 
    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) {
        // 如果能找到最合适的view 
            return fitView; 
        }
    } 
    // 4.没有找到更合适的view,也就是没有比自己更合适的view 
    return self;
}
// 作用:判断下传入过来的点在不在方法调用者的坐标系上
// point:是方法调用者坐标系上的点
//- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
//{
    // return NO;
//}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ 
    NSLog(@"%s",__func__);
}
@end

3.2.2.2 pointInside: withEvent:

pointInside:withEvent:方法判断点在不在当前view上(方法调用者的坐标系上)如果返回YES,代表点在方法调用者的坐标系上;返回NO代表点不在方法调用者的坐标系上,那么方法调用者也就不能处理事件。

练习:

屏幕上现在有一个viewA,viewA有一个subView叫做viewB,要求触摸viewB时,viewB会响应事件,而触摸viewA本身,不会响应该事件。如何实现?

重写viewA的hitTest: withEvent方法。先拿到正常逻辑下返回的最合适的view。然后判断这个view是不是viewA,如果是则return nil,此时就会拦截掉viewA的响应。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    UIView *view = [super hitTest:point withEvent:event];
    if (view == self) {
        return nil;
    }
    return view;
}

四、事件的响应

4.1.触摸事件处理的整体过程

  1. 用户点击屏幕后产生的一个触摸事件,经过一系列的传递过程后,会找到最合适的视图控件来处理这个事件
  2. 找到最合适的视图控件后,就会调用控件的touches方法来作具体的事件处理touchesBegan…、touchesMoved…、touchedEnded…
  3. 这些touches方法的默认做法是将事件顺着响应者链条向上传递(也就是touch方法默认不处理事件,只传递事件),将事件交给上一个响应者进行处理

4.2.响应者链条示意图

响应者链条:在iOS程序中无论是最后面的UIWindow还是最前面的某个按钮,它们的摆放是有前后关系的,一个控件可以放到另一个控件上面或下面,那么用户点击某个控件时是触发上面的控件还是下面的控件呢,这种先后关系构成一个链条就叫“响应者链”。也可以说,响应者链是由多个响应者对象连接起来的链条。在iOS中响应者链的关系可以用下图表示:

响应者链的事件传递过程:

  1. 如果当前view是控制器的view,那么控制器就是上一个响应者,事件就传递给控制器;如果当前view不是控制器的view,那么父视图就是当前view的上一个响应者,事件就传递给它的父视图
  2. 在视图层次结构的最顶级视图,如果也不能处理收到的事件或消息,则其将事件或消息传递给window对象进行处理
  3. 如果window对象也不处理,则其将事件或消息传递给UIApplication对象
  4. 如果UIApplication也不能处理该事件或消息,则将其丢弃

touches方法:

如果一个控件实现了touches方法,那么这个事件将由这个控件来接受,如果touches内部重写调用了[super touches];就会将事件顺着响应者链条向上传递。注意是[super touches];而不是[superView touches];

五、总结

当一个触摸动作发生时,会产生一个触摸事件。整个事件的处理过程我们可以将他理解为2个步骤,传递+响应。

1、传递:

即前面讲的事件会从父控件传给子控件,由UIApplication -> UIWindow -> UIView -> initial view,这就是事件的传递,也就是寻找最合适的view的过程。

2、响应:

找到initial view后看initialView能否处理事件,即看他有没有实现touches方法,如果没有就会传递给上级视图。就是事件顺着initial view->superView ->controller ->UIWindow ->UIApplication这样往上找。如果找到了UIApplication也不能处理,则事件会被丢弃

3、区别:

事件的传递是从上到下(父控件到子控件)

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值