美文网首页ios底层原理
iOS 事件链&响应链 理解

iOS 事件链&响应链 理解

作者: 吊炸 | 来源:发表于2019-10-29 13:39 被阅读0次

    平时开发中经常会遇到子view超出父view时,超出的部分不会响应点击事件
    原因就在于:iOS的事件响应机制
    接下来简单了解一下事件的响应链和传递链

    响应链流程

    基本流程

    大家都知道 iOS 的响应链是 UIApplication 收到用户触摸屏幕的事件以后通过逐层寻找最后得到用户触摸的 View 也就是第一响应者,然后调用 View 的 touchesBegan:withEvent: 方法处理事件任务的流程.大概流程是这样的:

    image.png
    图片很清晰的说明了查找流程 AppDelegate 收到事件逐层查找.最终找到 UIButton 这个响应者 然后调用 UIButton 的touchesBegan:withEvent: 方法处理事件.
    可以总结为一下几点
    一个触摸事件的响应过程如下:
    • 用户触摸屏幕时,UIKit会生成UIEvent对象来描述触摸事件。对象内部包含了触摸点坐标等信息。
    • 通过Hit Test确定用户触摸的是哪一个UIView。这个步骤通过- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法来完成。
    • 找到被触摸的UIView之后,如果它能够响应用户事件,相应的响应函数就会被调用。如果不能响应,就会沿着响应链(Responder Chain)寻找能够响应的UIResponder对象(UIView是UIResponder的子类)来响应触摸事件。

    如何查找第一响应者

    查找第一响应者主要涉及以下两个方法

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

    系统会先执行- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
    如果不在响应的point内的话直接返回 false,接下来- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;会直接返回nil

    pointInside:
    通过 point 参数确定触碰点是否在当前 View 的响应范围内 是则返回YES 否则返回 NO 实现方法大概是这个样子的

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

    hitTest方法:

    1. 它首先会通过调用自身的 pointInside 方法判断用户触摸的点是否在当前对象的响应范围内,如果 pointInside 方法返回 NO hitTest方法直接返回 nil
    2. 如果 pointInside 方法返回 YES hitTest方法接着会判断自身是否有子视图.如果有则调用顶层子视图的 hitTest 方法 直到有子视图返回 View
    3. 如果所有子视图都返回 nil hitTest 方法返回自身.

    寻找 hit-TestView 的过程的总结

    通过 hit-Testing 找到触摸点所在的 View( hit-TestView )。寻找过程总结如下(默认情况下) :

    • 寻找顺序如下
    1. 从视图层级最底层的 window 开始遍历它的子 View。
    2. 默认的遍历顺序是按照 UIView 中 Subviews 的逆顺序。
    3. 找到 hit-TestView 之后,寻找过程就结束了。
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
    {
        if (self.hidden || !self.userInteractionEnabled || self.alpha < 0.01 || ![self pointInside:point withEvent:event] || ![self _isAnimatedUserInteractionEnabled]) {
            return nil;
        } else {
    // 判断触摸位置是否在当前视图内
        if ([self pointInside:point withEvent:event]) {
            NSArray<UIView *> * superViews = self.subviews;
            // 倒序 从最上面的一个视图开始查找
            for (NSUInteger i = superViews.count; i > 0; i--) {
                UIView * subview = superViews[i - 1];
                // 转换坐标系 使坐标基于子视图
                CGPoint newPoint = [self convertPoint:point toView:subview];
                // 得到子视图 hitTest 方法返回的值
                UIView * view = [subview hitTest:newPoint withEvent:event];
                // 如果子视图返回一个view 就直接返回 不在继续遍历
                if (view) {
                    return view;
                }
            }
            // 所有子视图都没有返回 则返回自身
            return self;
            //for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
    //            UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
        //        if (hitView) {
            //        return hitView;
                //}
            } else {
              return nil;
          }
        }
    }
    

    根据以上源码分析
    确定一个 View 是不是 hit-TestView 的过程如下:

    1. 如果 View 的 userInteractionEnabled = NO,enabled = NO( UIControl ),或者 alpha <= 0.01, hidden = YES 直接返回 nil(不再往下判断)。
    2. 如果触摸点不在 view 中,直接返回 nil。
    3. 如果触摸点在 view 中,逆序遍历它的子 View ,重复上面的过程,如果子View没有subView了,那子View就是hit-TestView。
    4. 如果 view 的 子view 都返回 nil(都不是 hit-TestVeiw ),那么返回自身(自身是 hit-TestView )。
    image.png
    图来自https://liangdahong.com/2018/06/08/2018/浅谈-iOS-事件的传递和响应过程/

    看过这个,我们也就懂了为什么超出屏幕不能响应点击事件-----如果超出边界,UIKit无法根据这个触摸点找到父视图,自然也就无法找到子视图。寻找过程有点像递归,可以理解理解。

    找到 hit-TestView 之后,事件就交给它来处理,hit-TestView 就是 firstResponder(第一响应者),如果它无法响应事件(不处理事件),则把事件交给它的 nextResponder(下一个响应者)

    第一响应者在这几个方法中处理响应的事件,处理完成后根据需要调用 nextResponder 的 touch 方法,通常 nextResponder 就是第一响应者的 superView 文章的第一张图倒着看就是nextResponder 的顺序

    nextResponder 过程如下:

    当一个view被add到superView上的时候,他的nextResponder属性就会被指向它的superView,当controller被初始化的时候,self.view(topmost view)的nextResponder会被指向所在的controller,而controller的nextResponder会被指向self.view的superView,这样,整个app就通过nextResponder串成了一条链,也就是我们所说的响应链,他只是一条虚拟链,所以总结各个可响应者的nextResponder如下:

    1. UIView 的 c 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果当前 View 不是 ViewController 直接管理的 View,则 nextResponder 是它的 superView( view.nextResponder = view.superView )。
    2. UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
    3. UIWindow 的 nextResponder 是 UIApplication 。
    4. UIApplication 的 nextResponder 是 AppDelegate。
      基于以上过程,那么上面寻找hit_testView例子的响应者链就是viewB.1-viewB-mainView-UIWindow

    有了响应链,并且找到了第一个响应事件的对象,接下来就是把事件发送给这个响应者了。 UIApplication中有个sendEvent:的方法,在UIWindow中同样也可以发现一个同样的方法。UIApplication是通过这个方法把事件发送给UIWindow,然后UIWindow通过同样的API,把事件发送给hit-testview。

    接下来我们看一下事件的传递(传递链)

    找到第一响应者 application 便会根据 event 调用第一响应者响应的
    touch 方法:

    - (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;
    - (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);
    

    当我们点击了Button之后,UIWindow会通过一个私有方法,在里面会去调用按钮的touchesBegan和touchesEnded方法,touchesBegan里面有设置按钮的高亮等之类的动作,这样就实现了事件的传递。而事件的响应,也就是按钮上绑定的action,是在touchEnded里面通过调用UIApplication的sendAction:to:from:forEvent:方法来实现的,至于这个方法里面是怎么去响应action,就只能猜测了(可能是通过oc底层消息机制的相关接口
    objc_msgSend
    来发送消息实现的,可以参考message.h文件)。如果第一响应者没有响应这个事件,那么就会根据响应链,把事件冒泡传递给nextResponder来响应。

    注意这里是怎么把事件传递给nextResponder的呢?拿touch事件来说,UIResponder里面touch四个阶段的方法里面,实际上是什么事都没有做的,UIView继承了它进行重写,重写的内容也是没有什么东西,就是把事件传递给nextResponder,UIView继承于 UIResponder,而UIResponder只是重写了touch 四个方法,真正的实现在UIControl里,可以看UIKit源码
    UIControl->UiView->UIResponder->NSObject这个继承关系
    UIResponder有touch的四个方法,而实现都在UIControl

    UIResponder中touch方法实现如下,默认不做任何操作,只是沿着响应链传递事件

    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
        [[self nextResponder] touchesBegan:touches withEvent:event];
    }
    

    所以当一个view或者controller里面没有重写touch事件,那么这个事件就会一直传递下去,直到UIApplication,这也就是事件往上冒泡的原理。如果view重写了touch方法,我们一般会看到的效果是,这个view响应了事件之后,事件就被截断了,它的nextResponder不会收到这个事件,即使重写了nextResponder的touch方法。这个时候如果想事件继续传递下去,可以调用[super
    touchesBegan:touches withEvent:event],不建议直接调[self.nextResponder
    touchesBegan:touches withEvent:event]。
    注意这里是怎么把事件传递给nextResponder的呢?拿touch事件来说,UIResponder里面touch四个阶段的方法里面,实际上是什么事都没有做的,UIView继承了它进行重写,重写的内容也是没有什么东西,就是把事件传递给nextResponder,UIView继承于 UIResponder,而UIResponder只是重写了touch 四个方法,真正的实现在UIControl里,可以看UIKit源码
    UIControl->UiView->UIResponder->NSObject这个继承关系
    UIResponder有touch的四个方法,而实现都在UIControl

    UIResponder中touch方法实现如下,默认不做任何操作,只是沿着响应链传递事件

    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
        [[self nextResponder] touchesBegan:touches withEvent:event];
    }
    

    刚才提到UIControl
    在.h中能看到特别像touch四个方法的四个方法Tracking系列

    - (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
    - (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
    - (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
    - (void)cancelTrackingWithEvent:(UIEvent *)event;
    
    
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
        UITouch *touch = [touches anyObject];
        _touchInside = YES;
        _tracking = [self beginTrackingWithTouch:touch withEvent:event];
    
        self.highlighted = YES;
    
        if (_tracking) {
            UIControlEvents currentEvents = UIControlEventTouchDown;
    
            if (touch.tapCount > 1) {
                currentEvents |= UIControlEventTouchDownRepeat;
            }
    
            [self _sendActionsForControlEvents:currentEvents withEvent:event];
        }
    }
    

    根据上面的源码看到 touch内部调用了Tracking一系列的 touch 方法
    如果你点击UIButton的响应事件的话,打个断点,看到方法调用


    image.png

    就直接是UIApplication 调用sendAction方法,相当于直接到链头了吧

    首先划重点:所有响应者的基类都是 UIResponder,UIApplication/UIView/UIViewController 都是 UIResponder 的子类

    寻找响应者过程如下:

    当我们知道最合适的 View 后,事件会 由上向下【子view -> 父view,控制器view -> 控制器】来找出合适响应事件的 View,来响应相关的事件。那怎么就是真正的响应者呢?

    1. 如果当前的 View 有添加手势,那么直接响应相应的事件,不会继续向下寻找了,因为手势比响应链拥有更高的优先级

    2. 如果没有手势事件,那么会看其是否实现了如下的方法:

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

    经过验证一二步是并行的,甚至touchBegin响应更早一丢丢,所以只要有以上两个之一实现,都会阻断响应链传递

    举例

    image.png

    蓝色圆加在红色方块上,重叠部分如果想响应红色块的点击事件,应该怎么办呢

    • 重写蓝色圆的hiteTest
    //self.btn为红色块
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        UIView *view = [super hitTest:point withEvent:event];
        //蓝色的点转换给红色
        CGPoint redBtnPoint = [self convertPoint:point toView:self.btn];
        if([self.btn pointInside:redBtnPoint withEvent:event]) {
            return _btn;
        }
        return view;
    }
    

    事件拦截

    通常第一响应者都是响应链中最末端的响应者,事件拦截就是在响应链中截获事件,停止下发.将事件交由中间的某个响应者执行.比如这样:


    image.png

    通常点击红色 view 事件将交由 红色 view 处理.如果想让粉色 View 或者绿色 view 处理事件应该怎么办?
    有两种办法

    1. 在红色 view 的的 touch 方法中调用父类或者 nextResponder 的
      touch 方法
    2. 在需要拦截的 view 中重写 hitTest 方法改变第一响应者

    首先来看第一种

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
        // 将事件传递给下一响应者
        [self.nextResponder touchesBegan:touches withEvent:event];
        // 调用父类的touch方法 和上面的方法效果一样 这两句只需要其中一句
        [super touchesBegan:touches withEvent:event];  
    }
    

    这种方法有两个问题,你需要重写所有的 touch 方法并且还要重写要拦截事件的 view 与顶级 view 之间的所有 view 的 touch 方法

    第二种方法
    重写拦截事件的 view 的 hitTest 方法 比如要让绿色的 view 处理事件 就重写绿色 view 的 hitTest 方法

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
        // 如果在当前 view 中 直接返回 self 这样自身就成为了第一响应者 subViews 不再能够接受到响应事件
        if ([self pointInside:point withEvent:event]) {
            return self;
        }
        return nil;
    }
    

    这种方法比较简单粗暴.实现后 所有 subview 将不再能够接受任何事件 具体使用那种方式看需求.当然还可以通过 event 或者 point 有针对性的拦截

    事件转发

    有时候还需要将事件转发出去.让本来不能响应事件的 view 响应事件,最常用的场景就是让子视图超出父视图的部分也能响应事件,比如要实现这样的 tabbar


    image.png

    橙色按钮有两个区域 a 区超出父视图 b 区没有超出父视图,如果不作处理,那么点击 a 区是无法响应事件的,因为 a 区域的坐标不在父视图的范围内,当执行到父视图的 pointInside 的时候就会返回 NO
    想要让 a 区响应事件 就需要重写父视图的 pointInside 或 hitTest 方法让 pointInside 返回 YES 或 让hitTest 直接返回橙色视图
    重写hitTest

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
        
        // 触摸点在视图范围内 则交由父类处理
        if ([self pointInside:point withEvent:event]) {
            return [super hitTest:point withEvent:event];
        }
        
        // 如果触摸点不在范围内 而在子视图范围内依旧返回子视图
        NSArray<UIView *> * superViews = self.subviews;
        // 倒序 从最上面的一个视图开始查找
        for (NSUInteger i = superViews.count; i > 0; i--) {
            UIView * subview = superViews[i - 1];
            // 转换坐标系 使坐标基于子视图
            CGPoint newPoint = [self convertPoint:point toView:subview];
            // 得到子视图 hitTest 方法返回的值
            UIView * view = [subview hitTest:newPoint withEvent:event];
            // 如果子视图返回一个view 就直接返回 不在继续遍历
            if (view) {
                return view;
            }
        }
        return nil;
    }
    

    重写 pointInside 方法原理相同 重点注意转换坐标系 就算他们不是一条响应链上 也可以通过重写 hitTest 方法转发事件.原理相同的东西就不再写了

    参考地址https://www.jianshu.com/p/db3518be5ebb
    https://blog.csdn.net/qiangshuting/article/details/90317936

    相关文章

      网友评论

        本文标题:iOS 事件链&响应链 理解

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