欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

响应者链 完全解析

程序员文章站 2022-06-08 18:35:03
...

本文将会解析从触摸到屏幕开始,发生了什么;
响应者怎么获得事件,响应者是什么,事件是什么,让谁响应,怎么响应?

交互方式

目前有(未来可能有其他方式):

  • Touch 触摸
  • Press 按压,物理按钮
  • Motion 运动,摇一摇
  • Remote-Control 远程控制,AirPods

以上交互,都会产生用户事件。本文仅以第一种作例子,触类旁通。

触摸屏幕

当前 App 的所有用户事件,都放入该 App 的事件队列,由 UIApplication 的实例维护。触摸屏幕后,当前 UIApplication 单例从队列中取出了这个事件,初步封装为 UIEvent。


UIEvent: 用来描述一个事件。交互类型,产生时间等。
UITouch: 用来描述一次触摸。除了物理上的位置、移动、力度等,还有所在 UIView、UIWindow 等。
初步封装? 触摸产生的 UIEvent 对象正常情况下应该包含所有 UITouch(一个手指一个)的详细数据。然而此时的 UIEvent,仅含有操作系统传来的原始物理数据,或者说,真正的 UITouch 还没有生成。此时打印:<UITouchesEvent: 0x610000104a40> timestamp: 15125.6 touches: {()}
UIResponder: 表示可以响应、处理事件的抽象类,响应者。先了解 UIApplication 继承自 UIResponder,UIWindow 继承自 UIView,UIView 继承自 UIResponder。


摸到谁了

UIApplication 对象发现初步封装的 UIEvent 对象的交互类型是触摸,需要知道摸到的是哪个响应者,然后让该响应者去响应。于是发消息给 UIWindow(继承自 UIView)对象,hitTest:withEvent:返回值 UIView 就是被摸到的响应者(因为是触摸,所以响应者肯定是屏幕上显示的,所以该 UIResponder 肯定是 UIView 实例)。hitTest:withEvent:代码如***释的地方后面有解释。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1
    // 2
    if (![self pointInside:point withEvent:event])
        return 0;
    
    for (UIView * child in [self subviews]) {
        // 3
        if (![child isUserInteractionEnabled] || [child isHidden] || [child alpha] < 0.01)
            continue;
        // 4
        CGPoint converted = [child convertPoint:point fromView:self];
        UIView* found = [child hitTest:converted withEvent:event];
        if (found)
            return found;
    }
    // 5
    return self;
}

这段代码是我打了 20 个断点、运行了好多次推断出来的,比别的博客里的伪码准确的多。

再回顾一下函数目的:根据触摸的 CGPoint(UIWindow 坐标系下),得到视图树上尽可能远的响应者。利用递归实现。

代码注释

  1. 此时的 event 仍然没有生成 UITouch。
  2. 子视图超出父视图范围的部分不响应事件。
  3. 遍历所有子视图,需要符合一定条件。
  4. 在子视图中找,需要先转换坐标系。如果没找到,就搜下一个子视图,如果找到了就直接返回。
  5. 所有子视图都没有,只能是当前视图了。

最终返回触摸的最远层级的 UIView(UIResponder)。

构造完整的 UIEvent

现在已经知道触摸的是哪个 UIView(UIResponder),还没有构造 UITouch。
具体构造 UIEvent 的细节和响应者链无关,以后再写别的文章,这里稍微提一下:UIWindow 对象调用了 convertPoint:toWindow:convertPoint:fromWindow:各5次,后面又完完整整的走了一遍上面的 hitTest:withEvent:最后终于完成构造 UIEvent(也就是将来 touchesBegan 的参数之一)。

告诉响应者

UIApplication 终于包装好了此次事件,也知道了最应该通知谁去处理。
调用自己的 sendEvent: 函数,函数内调用 UIWindow 对象的 sendEvent 函数(注意不要混了),UIWindow 的 sendEvent 函数内把 UIEvent、所有 UITouch 发送给之前找到的 UIView(UIResponder),怎么发的?就是最熟悉的 touchesBegan 系列函数,下面都称处理函数。

响应者链

如果最远层响应者没有实现处理函数,UIResponder 默认会调用 nextResponder 的处理函数(如果不存在 nextResponder 则 sendEvent 函数返回)。


nextResponder 是 UIResponder 的一个属性,规则:一个 UIResponder 如果有容器或控制器(如 UIView 与 UIViewController),则返回该容器,容器的 nextResponder 为该 UIResponder 的父视图;没有容器则返回该 UIResponder 的父视图。(没有父视图的返回 nil)。最终一定会归结到 UIWindow 上(UIWindow 属于 UIView,最终的父视图一定是 UIWindow)。UIWindow 的 nextResponder 为 UIApplication,UIApplication 的 nextResponder 为 AppDelegate(也继承自 UIResponder),AppDelegate 的 nextResponder 为 nil,sendEvent 函数返回。


如果实现了处理函数,即响应者处理了事件。至于要不要再转发给 super,取决于业务。

响应者与 InputView 还有 UIGestureRecognizer

下次补充。

附:子视图超出父视图范围 无法响应点击事件解决办法

网上的博客都是抄,就硬抄,如果看了本文再去解决这个问题,高下立判。
my solution:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {

   for (UIView * child in [self subviews]) {

       if (![child isUserInteractionEnabled] || [child isHidden] || [child alpha] < 0.01)
           continue;

       CGPoint converted = [child convertPoint:point fromView:self];
       UIView* found = [child hitTest:converted withEvent:event];
       if (found)
           return found;
   }

   if ([self pointInside:point withEvent:event])
       return self;

   return 0;
}