美文网首页
【iOS面试粮食】UI视图—iOS事件的传递机制

【iOS面试粮食】UI视图—iOS事件的传递机制

作者: iOS鑫 | 来源:发表于2021-03-16 14:17 被阅读0次

    iOS的事件分为3大类型

    • Touch Events(触摸事件)
    • Motion Events(运动事件,比如重力感应和摇一摇等)
    • Remote Events(远程事件,比如用耳机上得按键来控制手机)

    在开发中,最常用到的就是Touch Events(触摸事件),基本贯穿于每个App中,也是本文的猪脚~ 因此文中所说事件均特指触摸事件。

    接下来,记录、涉及的问题大致包括:

    • 事件是怎么找它的妈妈的?(寻找事件的最佳响应者)
    • 事件又是如何去到妈妈的身边的?妈妈又将如何对待它?(事件的响应及在响应链中的传递)

    寻找事件的最佳响应者(Hit-Testing)

    当我们触摸屏幕的某个可响应的功能点后,最终都会由UIView或者继承UIView的控件来响应

    那我们先来看下UIView的两个方法:

     // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
    //返回寻找到的最终响应这个事件的视图
    - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;  
    
    // default returns YES if point is in bounds
    //判断某一个点击的位置是否在视图范围内
    - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   
    

    每个UIView对象都有一个 hitTest: withEvent: 方法,这个方法是Hit-Testing过程中最核心的存在,其作用是询问事件在当前视图中的响应者,同时又是作为事件传递的桥梁。

    看看它是什么时候被调用的

    • 当手指接触屏幕,UIApplication接收到手指的触摸事件之后,就会去调用UIWindowhitTest: withEvent:方法
    • hitTest: withEvent:方法中会调用pointInside: withEvent:去判断当前点击的point是否属于UIWindow范围内,如果是,就会以倒序的方式遍历它的子视图,即越后添加的视图,越先遍历
    • 子视图也调用自身的hitTest: withEvent:方法,来查找最终响应的视图

    再来看个示例:

    视图层级如下(同一层级的视图越在下面,表示越后添加):

    A
    ├── B
    │   └── D
    └── C
        ├── E
        └── F
    

    现在假设在E视图所处的屏幕位置触发一个触摸,App接收到这个触摸事件事件后,先将事件传递给UIWindow,然后自下而上开始在子视图中寻找最佳响应者。事件传递的顺序如下所示:

    • UIWindow将事件传递给其子视图A
    • A判断自身能响应该事件,继续将事件传递给C(因为视图C比视图B后添加,因此优先传给C)。
    • C判断自身能响应事件,继续将事件传递给F(同理F比E后添加)。
    • F判断自身不能响应事件,C又将事件传递给E。
    • E判断自身能响应事件,同时E已经没有子视图,因此最终E就是最佳响应者。

    以上,就是寻找最佳响应者的整个过程。

    接下来,来看下hitTest: withEvent:方法里,都做些了什么?

    我们已经知道事件在响应者之间的传递,是视图通过判断自身能否响应事件来决定是否继续向子视图传递,那么判断响应的条件是什么呢?

    视图响应事件的条件:

    • 允许交互: userInteractionEnabled = YES
    • 禁止隐藏:hidden = NO
    • 透明度:alpha > 0.01
    • 触摸点的位置:通过 pointInside: withEvent:方法判断触摸点是否在视图的坐标范围内

    代码的表现大概如下:

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        //3种状态无法响应事件
        if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
            return nil;
        }
    
         //触摸点若不在当前视图上则无法响应事件
        if ([self pointInside:point withEvent:event]) {
             //从后往前遍历子视图数组
            for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
                // 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
                CGPoint convertedPoint = [subView convertPoint:point fromView:self];
                 //询问子视图层级中的最佳响应视图
                UIView *hitTestView = [subView hitTest:convertedPoint withEvent:event];
                if (hitTestView) {
                     //如果子视图中有更合适的就返回
                    return hitTestView;
                }
            }
             //没有在子视图中找到更合适的响应视图,那么自身就是最合适的
            return self;
        }
    
        return nil;
    }
    

    说了这么多,那我们可以运用hitTest: withEvent:来搞些什么事情呢

    使超出父视图坐标范围的子视图也能响应事件

    视图层级如下:

    A
    ├── B
    

    如上图所示,视图B有一部分是不在父视图A的坐标范围内的,当我们触摸视图B的上半部分,是不会响应事件的。当然,我们可以通过重写视图AhitTest: withEvent:方法来解决这个需求。

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        UIView *view = [super hitTest:point withEvent:event];
        //如果找不到合适的响应者
        if (view == nil) {
            //视图B坐标系的转换
            CGPoint newPoint = [self.deleteButton convertPoint:point fromView:self];
            if (CGRectContainsPoint(self.deleteButton.bounds, newPoint)) {
                // 满足条件,返回视图B
                view = self.deleteButton;
            }
        }
    
        return view;
    }
    

    视图AhitTest: withEvent:方法中判断触摸点,是否位于视图B的视图范围内,如果属于,则返回视图B。这样一来,当我们点击视图B的任何位置都可以响应事件了。

    注:文章底部有简单的Demo(仅供参考)

    事件的响应及在响应链中的传递

    经历Hit-Testing后,UIApplication已经知道事件的最佳响应者是谁了,接下来要做的事情就是:

    • 将事件传递给最佳响应者响应
    • 事件沿着响应链传递

    事件传递给最佳响应者

    最佳响应者具有最高的事件响应优先级,因此UIApplication会先将事件传递给它供其响应。

    UIApplication中有个sendEvent:的方法,在UIWindow中同样也可以发现一个同样的方法。UIApplication是通过这个方法把事件发送给UIWindow,然后UIWindow通过同样的接口,把事件发送给最佳响应者。

    寻找事件的最佳响应者一节中点击视图E为例,在EViewtouchesBegan:withEvent: 上打个断点查看调用栈就能看清这一过程:

    当事件传递给最佳响应者后,响应者响应这个事件,则这个事件到此就结束了,它会被释放。假设响应者没有响应这个事件,那么它将何去何从?事件将会沿着响应链自上而下传递。

    注意: 寻找最佳响应者一节中也说到了事件的传递,与此处所说的事件的传递有本质区别。上面所说的事件传递的目的是为了寻找事件的最佳响应者,是自下而上(父视图到子视图)的传递;而这里的事件传递目的是响应者做出对事件的响应,这个过程是自上而下(子视图到父视图)的。前者为“寻找”,后者为“响应”。

    事件沿着响应链传递

    在UIKit中有一个类:UIResponder,它是所有可以响应事件的类的基类。来看下它的头文件的几个属性和方法

    NS_CLASS_AVAILABLE_IOS(2_0) @interface UIResponder : NSObject <UIResponderStandardEditActions>
    
    #if UIKIT_DEFINE_AS_PROPERTIES
    @property(nonatomic, readonly, nullable) UIResponder *nextResponder;
    #else
    - (nullable UIResponder*)nextResponder;
    #endif
    
    --------------省略部分代码------------
    
      // Generally, all responders which do custom touch handling should override all four of these methods.
    // Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
    // touch it is handling (those touches it received in touchesBegan:withEvent:).
    // *** You must handle cancelled touches to ensure correct behavior in your application.  Failure to
    // do so is very likely to lead to incorrect behavior or crashes.
    - (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);
    

    UIApplication,UIViewController和UIView都是继承自它,都有一个 nextResponder 方法,用于获取响应链中当前对象的下一个响应者,也通过nextResponder来串成响应链

    在App中,所有的视图都是根据树状层次结构组织起来的,因此,每个View都有自己的SuperView。当一个ViewaddSuperView上的时候,它的nextResponder属性就会被指向它的SuperView,各个不同响应者的指向如下:

    • UIView 若视图是控制器的根视图,则其nextResponder为控制器对象;否则,其nextResponder为父视图。
    • UIViewController 若控制器的视图是window的根视图,则其nextResponder为窗口对象;若控制器是从别的控制器present出来的,则其nextResponder为presenting view controller。
    • UIWindow nextResponder为UIApplication对象。
    • UIApplication 若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewController或app本身,则UIApplication的nextResponder为app delegate。

    这样,整个App就通过nextResponder串成了一条链,也就是我们所说的响应链,子视图指向父视图构成的响应链。

    看一下官网对于响应链的示例展示

    若触摸发生在UITextField上,则事件的传递顺序是:

    • UITextField ——> UIView ——> UIView ——> UIViewController ——> UIWindow ——> UIApplication ——> UIApplicationDelegte

    图中虚线箭头是指若该UIView是作为UIViewController根视图存在的,则其nextResponderUIViewController对象;若是直接addUIWindow上的,则其nextResponderUIWindow对象。

    响应者对于事件的拦截以及传递都是通过 touchesBegan:withEvent: 方法控制的,该方法的默认实现是将事件沿着默认的响应链往下传递。

    响应者对于接收到的事件有3种操作:

    • 不拦截,默认操作 事件会自动沿着默认的响应链往下传递
    • 拦截,不再往下分发事件 重写 touchesBegan:withEvent: 进行事件处理,不调用父类的 touchesBegan:withEvent:
    • 拦截,继续往下分发事件 重写 touchesBegan:withEvent: 进行事件处理,同时调用父类的 touchesBegan:withEvent: 将事件往下传递

    因此,你也可以通过 touchesBegan:withEvent:方法搞点事情~

    相关文章

      网友评论

          本文标题:【iOS面试粮食】UI视图—iOS事件的传递机制

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