UI 事件传递 & 响应

UIView 包含 CALayer 类型的 layer属性; backgroundColor 属性,实际上是对 CALayer的同名属性方法(backgroundColor)的包装;实际上的显示部分是由 CALayer 对应的 contents 来决定的,对应一个 backing store, 实际是就是一个 bitmap 类型的位图;最终显示到屏幕上对应的 UI 控件可以理解为位图

UIView 和 CALayer 区别

  • UIView 为其提供内容,以及负责处理触摸事件,参与响应链
  • CALayer 负责显示内容 contents

为什么这么设计,单一职责原则

事件传递与视图响应链

事件传递

-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent*)event;//返回视图
-(BOOL)pointInside(CGPoint)point withEvent:(UIEvent*)event;//判断点击位置是否在

hitTest:withEvent 系统实现

  1. 优先判断视图的 hidden 属性,是否可交互(userInterfactionEnable) 属性,以及它的 alpha(>0.01)值:必须同时满足不是隐藏,可交互,并且透明度大于0.01三个条件,否则返回 nil
  2. 调用[v pointInside:point withEvent:event]方法,判断点击的点是否在当前视图内:不在当前视图返回 nil
  3. 以倒序方式调用当前视图的子视图,每个子视图调用 hitTest:withEvent 方法,加入某一个视图返回就是最终响应视图,返回 nil 则继续遍历下一个子视图;如果都返回 nil 则把当前视图作为最终响应视图返回给调用方

Responder Object

响应者对象是能够响应并处理事件的对象,是构成Responder Chain(响应链)和事件传递链的节点

在iOS中不是任何对象都能处理事件,只有继承了UIResponder的对象才能接受并处理事件,称之为"响应者对象",UIApplication/UIView/UIViewControl都是UIResponder的子类,UIResponder声明了用于处理事件的接口,并定义了默认行为.

注:CALayer不是UIResponder的子类,这说明CALayer无法响应事件,这也是UIView与CALayer的重要区别之一

UIResponder内部提供了以下方法来处理事件触摸事件
// 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对象

这四个方法分别处处触摸开始,触摸移动,触摸终止,以及触摸跟踪取消事件,再看UIResponder的继承链:
关于这些方法的一些说明

  1. 如果两根手指同时触摸一个view,那么view只会调用一次touchesBegan:withEvent:方法,touches参数中装着2个UITouch对象
  2. 如果这两根手指一前一后分开触摸同一个view,那么view会分别调用2次touchesBegan:withEvent:方法,并且每次调用时的touches参数中只包含一个UITouch对象
  3. 重写以上四个方法,如果是处理UIView的触摸事件。必须要自定义UIView子类继承自UIView。因为苹果不开源,没有把UIView的.m文件提 供给我们。我们只能通过子类继承父类,重写子类方法的方式处理UIView的触摸事件(注意:我说的是UIView触摸事件而不是说的 UIViewController的触摸事件)。

注:iOS中事件传递首先从App(UIApplication)开始,接着传递到Window(UIWindow),在接着往下传递到View之前,Window会将事件交给GestureRecognizer,如果在此期间,GestureRecognizer识别了传递过来的事件,则该事件将不会继续传递到View去,而是像我们之前说的那样交给Target(ViewController)进行处理。

First Responder

第一响应者用于第一个接受事件,通常情况下,第一响应者是一个视图对象.一个对象可以通过以下操作成为第一响应者

  1. 重写canBecomeFirstResponder方法,并返回YES
  2. 接受becomeFirstResponder消息,如果必要,一个对象可以给自己发这个消息

Responder Chain

当一个事件发生时,如果First Responder 不进行处理,事件就会继续往下传递,被下个Responder接收,如果下个Responder仍不处理,又会被下下个Responder接收...直到一个Responder处理了事件或者没有Responder了.这些Responder按照传递次序链接起来的链条就构成了Responder Chain(响应者链),下图直观反映了事件传递的流程

从图中可以看到,响应者链有以下特点

  1. 响应者链通常是由 initial view 开始
  2. UIView 的 nextResponder 是它的 Super View;如果 UIView 已经是其所在的 UIViewController 的 Top View,那么 UIView 的 nextResponder 就是 UIViewController
  3. UIViewController 如果有 Super ViewController,那么它的 nextResponder 为其 Super ViewController 最表层的 View;如果没有,那么它的 nextResponder 就是 UIWindow
  4. UIWindow 的 contentView 指向 UIApplication,将其作为 nextResponder
  5. UIApplication 是一个响应者链的终点,它的 nextResponder 指向 AppDelegate(文档中说是 nil,如果有同学有明确的答案请告知),整个 Responder Chain 结束

注:如果当前的Responder不处理事件,并希望将其传递给nextResponder时,需要手动编写代码,才会继续往下传递,否则事件会被废弃:

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{  
    // 将事件传递给 nextResponder
    id theNextResponder = [self nextResponder];
    [theNextResponder touchesBegan:touches withEvent:event];
}

UIResponder本身是不会去存储或者设置nextResponder的,所谓的nextResponder都是子类去实现的.

Hit-Test View & Hit-Testing

Hit-Test View:当用户与触摸屏产生交互时,硬件就会探测到物理接触并且通知操作系统.操作系统就会创建相应的事件,并将其传递给当前正在运行的应用程序的事件队列.然后这个事件会被事件循环传递给优先响应对象,即 Hit-Test View;简单来说就是触发事件所在的那个View

UIApplication管理事件队列;UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常先发送事件给应用程序的主窗口;主窗口会在视图的层次结构中找到一个最合适的视图来处理触摸事件.

Hit-Testing: Hit-Test View 就是事件被触发时和用户交互的对象,寻找Hit-Test View的过程就叫做Hit-Testing

在UIVIew中定义了如下两个函数

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   // default returns YES if point is in bounds

系统先调用 pointInSide: WithEvent: 判断当前视图以及这些视图的子视图是否能接收这次点击事件,然后在调用 hitTest: withEvent: 依次获取处理这个事件的所有视图对象,在获取所有的可处理事件对象后,开始调用这些对象的 touches 回调方法。

UIWindow 有一个 MianView,MainView 里面有三个 Sub View:View A、View B、View C,他们各自有两个 Sub View,他们层级关系是:View A 在最下面,View B 中间,View C 最上(按照 addSubview 的顺序),其中 View A 和 View B 有一部分重叠。如果手指在 View B.1 和 View A.2 重叠的上面点击,按照上面说的递归方式,顺序如下图所示:

递归是向界面的根节点 UIWindow 发送 hitTest:withEvent: 消息开始的,从这个消息返回的是一个 UIView,也就是手指当前位置最前面的那个 Hittest View。当向 UIWindow 发送 hitTest:withEvent: 消息时,hitTest:withEvent: 里面所做的事,就是判断当前的点击位置是否在 Window 里面,如果在则遍历 Window 的 Subview 然后依次对 Subview 发送 hitTest:withEvent: 消息(注意这里给 Subview 发送消息是根据当前 Subview 的 index 顺序,index 越大就越先被访问)。如果当前的 point 没有在 View 上面,那么这个 View 的 Subview 也就不会被遍历了。当事件遍历到了 View B.1,发现 point 在 View B.1 里面,并且 View B.1 没有 Subview,那么他就是我们要找的 Hittest View 了,找到之后就会一路返回直到根节点,而 View B 之后的 View A 也不会被遍历了。

注意hitTest里面是有判断当前的view是否支持点击事件,比如userInteractionEnabled、hidden、alpha等属性,都会影响一个view是否可以相应事件,如果不响应则直接返回nil。 我们留意到还有一个pointInside:withEvent:方法,这个方法跟hittest:withEvent:一样都是UIView的一个方法,通过他开判断point是否在view的frame范围内。如果这些条件都满足了,那么遍历就可以继续往下走了

关于事件的链有两条:事件的响应链;Hit-Testing时事件的传递链.

  • 事件响应链:由离用户最近的 View 向系统传递:
    initial view –> Super View –> …… –> View Controller –> Window –> Application –> AppDelegate

  • 事件传递链:由系统向离用户最近的 View 传递:
    UIKit –> active app’s event queue –> Window –> Root View –> …… –> Lowest View

那么在上面的查找响应者流程完成之后,系统会将本次事件中的点击转换成UITouch对象,然后将这些对象和UIEvent类型的事件对象传递给touchesBegan方法,不仅如此,从上面输出的nextResponder来看,所有的响应者都是在查找中返回可响应点击的视图。因此,我们可以推测出UIApplication对象维护着自己的一个响应者栈,当pointInSide: withEvent:返回yes的时候,响应者入栈。

响应者栈: 栈顶的响应者作为最优先处理事件的对象,假设AView不处理事件,那么出栈,移交给UIView,以此下去,直到事件得到了处理或者到达AppDelegate后依旧未响应,事件被摒弃为止。通过这个机制我们也可以看到controller是响应者栈中的例外,即便没有pointInSide: withEvent:的方法返回可响应,controller依旧能够入栈成为UIView的下一个响应者。

如何寻找最合适的View

事件传递,UIApplication -> UIWindow
UIWindow去寻找最合适的view? [UIWindow hitTest:withEvent:]里面做了什么事情?
1> 判断窗口能不能处理事件? 如果不能,意味着窗口不是最合适的view,而且也不会去寻找比自己更合适的view,直接返回nil,通知UIApplication,没有最合适的view。
2> 判断点在不在窗口
3> 遍历自己的子控件,寻找有没有比自己更合适的view
4> 如果子控件不接收事件,意味着子控件没有找到最合适的view,然后返回nil,告诉窗口没有找到更合适的view,窗口就知道没有比自己更合适的view,就自己处理事件。

HitTest: WithEvent:底层实现

相当于调用[super hitTest:point withEvent:event]


// 找最合适的view
// point是self的坐标系上的点
- (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.去找有没有比自己更合适的view
     UIView *fitView = nil;
    // 方法一
    /*
    
    // 从后往前遍历自己的子控件
    int count = (int)self.subviews.count;
    
    for (int i = count - 1; i >= 0; i--) {
        // 获取子控件
        UIView *childView = self.subviews[i];
        
        // 转换坐标系
        
        // 把自己坐标系上的点转换成子控件做坐标系上的点
        CGPoint childPoint = [self convertPoint:point toView:childView];
        
        //递归找
        fitView = [childView hitTest:childPoint withEvent:event];
        
        // 找到最合适的view
        if (fitView) {
            break;
        }
    }
    */
    
    //方法二,注意倒序
    
    [self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        //坐标转换
        CGPoint vonvertPoint = [self convertPoint:point toView:obj];
        //调用子视图的 hittest 方法
        fitView = [obj hitTest:vonvertPoint withEvent:event];
        //如果找到了接受事件的对象,则停止遍历
        if(fitView) {
            *stop = YES;
        }
    }];
    
    if(fitView) return fitView;
    
    // 没有找到比自己更合适的view
    return self;
}

实践

不规则圆形点击

既然已经知道了系统是怎么获取响应视图的流程了,那么我们可以通过重写查找事件处理者的方法来实现不规则形状点击。最常见的不规则视图就是圆形视图,在demo中我设置view的宽高为200,那么重写方法事件如下:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    const CGFloat halfWidth = 100;
    CGFloat xOffset = point.x - 100;
    CGFloat yOffset = point.y - 100;
    CGFloat radius = sqrt(xOffset * xOffset + yOffset * yOffset);
    return radius <= halfWidth;
}

一个事件多个处理对象
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ 
// 1.自己先处理事件...
NSLog(@"do somthing...");
// 2.再调用系统的默认做法,再把事件交给上一个响应者处理
[super touchesBegan:touches withEvent:event]; 
}

事件穿透


当点击重叠区上,让下方的视图响应事件
重写绿色控件的hitTest 方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    CGPoint redBtnPoint = [self convertPoint:point toView:_redButton];
    if ([_redButton pointInside:redBtnPoint withEvent:event]) {
        return _redButton;
    }
    //如果希望严谨一点,可以将上面if语句及里面代码替换成如下代码
    //UIView *view = [_redButton hitTest: redBtnPoint withEvent: event];
    //if (view) return view;
    return [super hitTest:point withEvent:event];

让超出父视图的子视图接受事件
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *view = [super hitTest:point withEvent:event];
    if (view == nil) {
        for (UIView *subView in self.subviews) {
            CGPoint p = [subView convertPoint:point fromView:self];
            if (CGRectContainsPoint(subView.bounds, p)) {
                view = subView;
            }
        }
    }
    return view;
}

转载于:https://my.oschina.net/sshsxl/blog/1931747

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值