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

本文章将记录有关iOS事件的传递机制,如有错误欢迎指出~

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:方法,来查找最终响应的视图

再来看个示例:

Hit-testing

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

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:来搞些什么事情呢

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

Demo示意图

视图层级如下:

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: 上打个断点查看调用栈就能看清这一过程:

touchesBegan调用栈

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

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

事件沿着响应链传递

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

NS_CLASS_AVAILABLE_IOS(2_0) @interface UIResponder : NSObject 

#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 *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet *)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:方法搞点事情~

总结

触摸事件先通过自下而上(父视图-->子视图)的传递方式寻找最佳响应者,
然后以自上而下(子视图-->父视图)的方式在响应链中传递。

Github :TouchEventDemo(仅供参考)

参考资料

  • 深入浅出iOS事件机制

  • iOS触摸事件全家桶

你可能感兴趣的:(【iOS面试粮食】UI视图—iOS事件的传递机制)