分析实现-谈谈响应链

当用户的手指在屏幕上的某一点按下时,屏幕接收到点击信号将点击位置转换成具体坐标,然后本次点击被包装成一个点击事件UIEvent。最终会存在某个视图响应本次事件进行处理,而为UIEvent查找响应视图的过程被称为响应链查找,在整个过程中有两个至关重要的类:UIResponderUIView

响应者

响应者是可以处理事件的具体对象,一个响应者应当是UIResponder或其子类的实例对象。从设计上来看,UIResponder主要提供了三类接口:

  • 向上查询响应者的接口,体现在nextResponder这个唯一的接口
  • 用户操作的处理接口,包括touchpressremote三类事件的处理
  • 是否具备处理action的能力,以及为其找到target的能力

总体来说UIResponder为整个事件查找过程提供了处理能力

视图

视图是展示在界面上的可视元素,包括不限于文本按钮图片等可见样式。虽然UIResponder提供了让对象响应事件的处理能力,但拥有处理事件能力的responder是无法被用户观察到的,换句话说,用户也无法点击这些有处理能力的对象,因此UIView提供了一个可视化的载体,从接口上来看UIView提供了三方面的能力:

  • 视图树结构。虽然responder也存在相同的树状结构,但其必须依托于可视载体进行表达
  • 可视化内容。通过frame等属性决定视图在屏幕上的可视范围,提供了点击坐标和响应可视对象的关联能力
  • 内容布局重绘。视图渲染到屏幕上虽然很复杂,但按照不同的layout方式提供了不同阶段的重绘调起接口,使得子类具有很强的定制性

视图树的结构如下,由于UIViewUIResponder的子类,可以通过nextResponder访问到父级视图,但由于responder并不全是具备可视载体的对象,通过nextResponder向上查找的方式可能会导致无法通过位置计算的方式查找响应者

查找响应者

讲了这么多,也该聊聊查找响应者的过程了。前面说了,responder决定了对象具有响应处理的能力,而UIView才是提供了可视载体和点击坐标关联的能力。换句话说,查找响应者实际上是查找点击坐标落点位置在其可视范围内且其具备处理事件能力的对象,按照官方话来说就是既要responde又要view的对象。因为需要先找到响应者,才能有进一步的处理,所以直接从后者的接口找起,两个api

/// 检测坐标点是否落在当前视图范围内
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
/// 查找响应处理事件的最终视图
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;

通过exchange掉第一个方法的实现,很轻易的就能得出响应者查找的顺序:

- (BOOL)sl_pointInside: (CGPoint)point withEvent: (UIEvent *)event {
    BOOL res = [self sl_pointInside: point withEvent: event];
    if (res) {
        NSLog(@"[%@ can answer]", self.class);
    } else {
        NSLog(@"non answer in %@", self.class);
    }
    return res;
}

如图创建相同的布局结构,然后点击BView,得到的日志:

[UIStatusBarWindow can answer]
non answer in UIStatusBar
non answer in UIStatusBarForegroundView
non answer in UIStatusBarServiceItemView
non answer in UIStatusBarDataNetworkItemView
non answer in UIStatusBarBatteryItemView
non answer in UIStatusBarTimeItemView
[UIWindow can answer]
[UIView can answer]
non answer in CView
[AView can answer]
[BView can answer]

通过日志输出可以看出查找顺序优先级有两条:

  1. 优先级更高的window优先匹配
  2. 同一个window中从父视图向子视图寻找

通过pointInside:确定了点击坐标落在哪些视图的范围后,会继续调用另一个方法来找寻真正的响应者。同样hook掉这个方法,从日志来看父级视图会调用子视图,最终以递归的格式输出:

- (UIView *)sl_hitTest: (CGPoint)point withEvent: (UIEvent *)event {
    UIView *res = [self sl_hitTest: point withEvent: event];
    NSLog(@"hit view is: %@ and self is: %@", res.class, self.class);
    return res;
}

/// 输出日志
hit view is: (null) and self is: CView
hit view is: BView and self is: BView
hit view is: BView and self is: AView
hit view is: BView and self is: UIView
hit view is: BView and self is: UIWindow

当确定了是CView是最后一个落点视图时,会以CView为起始点,向上寻找事件的响应者,这个查找过程由一个个responder链接起来,这也是响应链名字的来由。另外,基于树结构的视图层级,只需要我们持有根节点,就能遍历整棵树,这也是为什么搜索过程是从window开始的

响应者处理

经过pointInside:hitTest:两个方法会确定点击位置最上方的可视响应者,但并不代表了这个responder会处理本次事件。基于上面的界面demo,在AView中实现touches方法:

@implementation AView

- (void)touchesBegan: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event {
    NSLog(@"A began");
}

- (void)touchesCancelled: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event {
    NSLog(@"A canceled");
}

- (void)touchesEnded: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event {
    NSLog(@"A ended");
}

@end

前面说过UIResponder提供了用户操作的处理接口,但很明显touches系列的接口默认是未实现的,因此BView即便成为了响应链上的最上层节点,依旧无法处理点击事件,而是沿着响应链查找响应者:

void handleTouch(UIResponder *responder, NSSet<UITouch *> *touches UIEvent *event) {
    if (!responder) {
        return;
    }
    if ([responder respondsToSelector: @selector(touchesBegan:withEvent:)]) {
        [responder touchesBegan: touches withEvent: event];
    } else {
        handleTouch(responder.nextResponder, touches, event);
    }
}

另外一个有趣的地方是手势拦截,除了实现touches系列方法让view提供响应能力之外,我们还可以主动的在视图上添加手势进行回调:

- (void)viewDidLoad {
    [super viewDidLoad];
    [_a addGestureRecognizer: [[UITapGestureRecognizer alloc] initWithTarget: self action: @selector(clickedA:)]];
}

- (void)clickedA: (UITapGestureRecognizer *)tap {
    NSLog(@"gesture clicked A");
}

/// 日志输出
A began
gesture clicked A
A canceled

从日志可以看到手势的处理是在touchesBegan之后,并且执行之后会中断原有的touches调用链,因此可以确定即便是手势动作,最终依旧由UIResponder进行事件分发处理

小结

触屏事件的处理被分成两个阶段:查找响应者响应者处理,分别由UIViewUIResponder提供了功能上的支持。另外由于pointInside:hitTest:这两个关键接口是对外暴露的,因此通过hook或者inherit的方式来修改这两个方法,可以使得视图的可响应范围大于显示范围

应用

最近有个需求需要在tabbar的位置上方弹出气泡,且允许用户点击气泡发生交互事件。从视图层来分析,tabbar被嵌套在多层尺寸等同于菜单栏的view当中:

如果要实现从item弹起气泡并且可交互,有两个可行的方案:

  1. 将气泡添加到ViewController的视图上
  2. 修改tabbar的响应链查找接口,实现显示范围外的可点击处理

考虑到如果项目中存在相同的弹出需求可能会导致写了一堆重复代码,所以将弹出动作触屏判断给封装起来,通过hook的方式来实现弹出气泡自动化触屏检测功能

接口设计

依照最少接口原则,只暴露两个弹出接口:

/*!
 *  @enum   SLViewDirection
 *  弹窗视图所在的方向(dismiss和pop应当保持一致)
 */
typedef NS_ENUM(NSInteger, SLViewDirection)
{
    SLViewDirectionCenter,
    SLViewDirectionTop,
    SLViewDirectionLeft,
    SLViewDirectionBottom,
    SLViewDirectionRight
};

/*!
 *  @category   UIView+SLFreedomPop
 *  自由弹窗扩展
 */
@interface UIView (SLFreedomPop)

/*!
 *  @method sl_popView:
 *  居中弹出view
 *  @param  view    要弹出的view
 */
- (void)sl_popView: (UIView *)view;

/*!
 *  @method sl_popView:WithDirection:
 *  控制弹窗方向
 *  @param  view        要弹出的view
 *  @param  direction   弹出方向
 */
- (void)sl_popView: (UIView *)view withDirection: (SLViewDirection)direction;

@end

落点检测

考虑到两个问题:

  1. 视图可能存在多个超出显示范围的子视图
  2. 视图存在超出父级视图显示范围的子视图

针对这两个问题,解决方案分别是:

  1. 用视图作key,存储一个extraRect的列表
  2. 当弹出视图超出自身范围时,向父级视图调用,确保父级视图能处理响应

最终检测代码如下:

#define SLRectOverflow(subrect, rect) \
    subrect.origin.x < 0 || \
    subrect.origin.y < 0 || \
    CGRectGetMaxX(subrect) > CGRectGetWidth(rect) ||   \
    CGRectGetMaxY(subrect) > CGRectGetHeight(rect)

#pragma mark - Private
- (BOOL)_sl_pointInsideExtraRects: (CGPoint)point {
    NSArray *extraRects = [self extraHitRects].allValues;
    if (extraRects.count == 0) {
        return NO;
    }
    
    for (NSSet *rects in extraRects) {
        for (NSString *rectStr in rects) {
            if (CGRectContainsPoint(CGRectFromString(rectStr), point)) {
                return YES;
            }
        }
    }
    return NO;
}

#pragma mark - Rects
- (void)_sl_addExtraRect: (CGRect)extraRect inSubview: (UIView *)subview {
    CGRect curRect = [subview convertRect: extraRect toView: self];
    if (SLRectOverflow(curRect, self.frame)) {
        [self _sl_expandExtraRects: curRect forKey: [NSValue valueWithBytes: &subview objCType: @encode(typeof(subview))]];
        [self.superview _sl_addExtraRect: curRect inSubview: self];
    }
}

#pragma mark - Hook
- (BOOL)sl_pointInside: (CGPoint)point withEvent: (UIEvent *)event {
    BOOL res = [self sl_pointInside: point withEvent: event];
    if (!res) {
        return [self _sl_pointInsideExtraRects: point];
    }
    return res;
}

- (UIView *)sl_hitTest: (CGPoint)point withEvent: (UIEvent *)event {
    UIView *res = [self sl_hitTest: point withEvent: event];
    if (!res) {
        if ([self _sl_pointInsideExtraRects: point]) {
            return self;
        }
    }
    return res;
}

运行效果

红色视图弹出绿色视图,超出自身和父视图的显示范围,点击有效:

定制改进

由于核心功能在于event handle,目前代码只提供了简单的弹出接口,需要进一步扩充弹出接口能力的可以考虑两点改进:

  • 增加configuration来定制弹窗样式
  • 提供animation来完成自定义的动画

文章的源码地址

关注我的公众号获取更新信息

PREVIOUS开发日记-警惕swizzling
NEXT多线程-线程安全(二)