Hulk' Den

in-depth thinking and keep moving.

iOS触控响应中那些没有细想过的问题

写在前面:这篇文章的重点在于讨论我想到的一些关于iOS触控响应这块容易忽略的问题,限于篇幅就不对基本的iOS触控响应知识做说明了。

一、iOS给我们提供的响应触控的方式

1、The touch-delivery methods

定义在UIResponder中的以下方法,通过重写可以实现自定义的触控响应逻辑。

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

2、UIControl

Target-Action模式,具体支持的事件在UIControlEvents枚举中有定义。

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)cancelTrackingWithEvent:(nullable UIEvent *)event;

- (void)sendActionsForControlEvents:(UIControlEvents)controlEvents;

可以通过重写这四个tracking方法结合sendActionsForControlEvents:方法实现事件的自定义逻辑。

Note: 或许有人会有疑问说这四个tracking方法跟UIResponder中的四个touch-delivery methods方法很像,有什么区别呢?下文中的结论三会讲到。

3、UIGestureRecognizer

  • UILongPressGestureRecognizer 长按
  • UIPanGestureRecognizer 拖动
  • UIPinchGestureRecognizer 缩放
  • UIRotationGestureRecognizer 旋转
  • UISwipeGestureRecognizer 轻扫
  • UITapGestureRecognizer 点击

我们也可以继承UIGestureRecognizer,通过重写touch-delivery methods来自定义一个新手势。

二、为什么是多种方式

那么让我好奇的是为什么要提供三种方式呢?比如最常见的点击操作,通过上述三种方式都可以响应,那到底用哪种呢?为何不统一成一种方式呢?

我的理解,UIControl的出现是为了更好的封装性,把具有一些特定行为的UIView封装成控件,方便复用。而且通过demo实验可知,UIControl中对于UIControlEvents中定义的事件的支持,其实就是通过重写UIResponder中的touch-delivery methods实现的,不过加了一些特殊的逻辑(下文会提到)。

而UIGestureRecognizer是NS_CLASS_AVAILABLE_IOS(3_2)加入的机制,我认为这是Apple对UIView的触控响应部分的解耦。同时,还有很重要的一点是UIGestureRecognizer的优先级是最高的,这个在网上很多关于触控响应的资料都有说明,下文中有我对于这块代码的实现猜想。

三、多种方式带来的混乱

为了便于对下文场景的描述我们约定一下简称:
UIView统一用view
UIScrollView统一用scrollView
UIButton统一用button
UITextField统一用textField
UIGestureRecognizer统一用gesture

声明:下文中描述的存在上下层级关系的view,都存在包含关系(即上层view都是addSubView到下层view上的)。显然,如果不存在包含关系,就不在一个响应链上,也就不需要讨论了。

  1. scrollViewA 上方有一个viewA,滑动viewA,scrollViewA会不会被滑动呢?它们的touch-delivery methods会触发吗?
  2. scrollViewA上方有一个scrollViewB,滑动scrollViewB,scrollViewA会不会被滑动呢?它们的touch-delivery methods会触发吗?
  3. buttonA上方有一个viewA,点击viewA,buttonA会响应吗?它们的touch-delivery methods会触发吗?
  4. buttonA上方有一个buttonB,点击buttonB,buttonA会响应吗?它们的touch-delivery methods会触发吗?
  5. viewA viewB viewC依次从下到上, viewA和viewB都addGestureRecognizer:tapGesture,那么点击viewC,viewA和viewB上的手势(都)会被触发吗?它们的touch-delivery methods会触发吗?
  6. 还是(5)这种场景,如果viewC是一个button,会怎样呢?
  7. buttonA上addGestureRecognizer:tapGesture,同时还addTarget:action:forControlEvents:UIControlEventTouchDown,在用户点击的时候它们都会被识别并触发吗?如果是UIControlEventTouchUpInside事件又会这样呢?

相信这些情况在一些复杂的业务场景中是可能碰到的,也会带来困惑。从下文中就可以找到答案了,我是通过最初的猜想,到自己写demo实验,得出的以下结论。

四、从猜想到实验得出的结论

结论一:UIResponder实现了UITouch的传递

@implementation UIResponder
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.nextResponder touchesBegan:touches withEvent:event];
}
...
etc.
@end

也就是我们重写touch-delivery methods时,如果不调用父类的实现,就会阻断UITouch的传递。

结论二:UIControl阻断了UITouch的传递

@implementation UIControl
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    //没有调用下面这句
    //[super touchesBegan:touches withEvent:event];
}
...
etc.
@end

基于结论一,再加以实验发现,UIControl在实现touch-delivery methods时就没有调用父类的实现,因此UIControl以及它的子类都会阻断UITouch的传递。

结论三:UIControlEvents只会在此UIControl是用户触摸的view时才会被触发

@implementation UIControl
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    if ([touch view] == self) {
        //TODO: UIControlEvents中定义的事件的识别逻辑
        [self beginTrackingWithTouch:touch withEvent:event];
    }
}
//其它几个方法逻辑类似
...
etc.
@end

UIControl只有在hitTest:withEvent:方法最终返回的view是自己的时候,即用户真正触摸的view是自己的时候,才会触发响应。这样描述是为了说明,虽然UIControl也是继承了UIResponder并重写touch-delivery methods来实现触控响应逻辑,但是如果它不是在响应链的最外端,那么UIControlEvents事件并不会被触发。

Note: 可能有人会有疑问,代码中的//TODO: UIControlEvents中定义的事件的识别逻辑为什么是在touchesBegan:withEvent:方法中实现?而不是封装到beginTrackingWithTouch:withEvent:中去?我也是经过实验得出的这个结论,我的理解是如果按后者来,那么我们在重写beginTrackingWithTouch:withEvent:方法时如果没有调用父类的实现,就使它失去了在父类里已经明确声明的对于UIControlEvents的支持,是不合理的,Apple如此设计做到了功能的一致性。(其实本该如此,只是我在猜想代码实现的的小想法而已)

结论四:UIButton会阻断superViews上的手势识别

我们知道手势响应的优先级是最高的,默认情况下,在UITouch的目标view及其superView上的gestureRecognizers被识别时,会调用目标view及其superView的touchesCancelled:withEvent:方法来取消响应链对于此UITouch的响应。除非把UIGestureRecognizer的cancelsTouchesInView属性设为NO。

但是有没有想过同样依赖touch-delivery methods来实现响应的UIButton跟比如UITapGestureRecognizer同时存在时,是怎样的响应逻辑呢?

直接上我通过实验后的猜想代码:

@implementation UIWindow

- (void)sendEvent:(UIEvent *)event {
    UIView *resultView = nil;
    NSSet <UITouch *> *allTouches = [event allTouches];
    UITouch *touch = [allTouches anyObject];
    for (UIView *view in self.subviews) {
        CGPoint point = [touch locationInView:view];
        resultView = [view hitTest:point withEvent:event];
        if (resultView) {
            //收集目标view及其superView的所有手势
            NSArray *gestureRecognizers = [self collectGestureRecognizersWithLeafView:resultView];
            //触发响应
            switch (touch.phase) {
                case UITouchPhaseBegan:
                    for (UIGestureRecognizer *gestureReconizer in gestureRecognizers) {
                        [gestureReconizer touchesBegan:allTouches withEvent:event];
                    }
                    [resultView touchesBegan:allTouches withEvent:event];
                    break;
                case UITouchPhaseMoved:
                    for (UIGestureRecognizer *gestureReconizer in gestureRecognizers) {
                        [gestureReconizer touchesMoved:allTouches withEvent:event];
                    }
                    [resultView touchesMoved:allTouches withEvent:event];
                    break;
                case UITouchPhaseCancelled:
                    for (UIGestureRecognizer *gestureReconizer in gestureRecognizers) {
                        [gestureReconizer touchesCancelled:allTouches withEvent:event];
                    }
                    [resultView touchesCancelled:allTouches withEvent:event];
                    break;
                case UITouchPhaseEnded:
                    for (UIGestureRecognizer *gestureReconizer in gestureRecognizers) {
                        [gestureReconizer touchesEnded:allTouches withEvent:event];
                    }
                    [resultView touchesEnded:allTouches withEvent:event];
                    break;
                default:
                    break;
            }
            
            break;
        }
    }
}

- (NSArray<UIGestureRecognizer*>*)collectGestureRecognizersWithLeafView:(UIView*)leafView {
    NSMutableArray *gestureRecognizers = [NSMutableArray array];
    if ([leafView isKindOfClass:[UIButton class]]) {
        [gestureRecognizers addObjectsFromArray:leafView.gestureRecognizers];
    }
    else {
        UIView *superView = leafView;
        while (superView) {
            [gestureRecognizers addObjectsFromArray:superView.gestureRecognizers];
            superView = superView.superview;
        }
    }
    return [gestureRecognizers copy];
}

@end

也就是如果UITouch的目标view是UIButton(也就是UIButton处在响应链的最外端),那么只有添加到这个UIButton上的手势会被识别,其余superViews上的手势都不会被识别。

结论五:默认情况下,比如UILongPressGestureRecognizer这种不能在UITouchPhaseBegan阶段就识别的手势,并不会影响UITouch的目标view以及其superView的touchesBegan:withEvent:方法的调用。除非把UIGestureRecognizer的delaysTouchesBegan属性设为YES。

我们在结论四里面对于UIWindow部分实现代码的猜想其实也能说明这一点,欠缺的是没有涉及对delaysTouchesBegan逻辑的猜想。

结论六:这一点算是基础知识,关于响应链上有多个手势存在的场景,其实就是对于UIGestureRecognizerDelegate的应用。

UIGestureRecognizerDelegate里有详细的注释说明,熟练掌握就可以灵活应对响应链上有多种手势存在的场景。

结论七:这一点也不是本文关注的重点,是我先前猜想的UIView的hitTest:withEvent:方法的实现逻辑,顺带贴出来便于理解这部分。

- (UIView*)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event {
    //1、
    if (self.userInteractionEnabled == NO
        || self.alpha == 0.0
        || self.hidden == YES) {
        return nil;
    }


    //2、
    if ([self pointInside:point withEvent:event] == NO) {
        return nil;
    }

    //3、
    UIView *result = nil;
    for (UIView *view in self.subviews) {
        result = [view hitTest:point withEvent:event];
    }

    //4、
    return result ? result : self;
}

最后

写到这里必须声明,以上我说的都是错的。贴出来是希望跟大家交流学习,共同思考进步,阅读愉快!

参考How iOS handles touches. Responder chain, touch event handling, gesture recognizers, scrollviews – Xida Zheng

最近的文章

尊重自己

从一个兴趣说起我为什么喜欢踢球?因为我喜欢踢球过程中的操控、组织和超越的感觉,让我兴奋。相比而言,赢球都不能带来这种兴奋。所以我宁愿踢中场不赢球,也不愿意踢后卫赢球(当然只是打个比方,我踢中场反而更容易赢球)。所以就得出了不让我踢中场,我宁愿不去踢球的原因。也得出了为什么我喜欢小罗齐祖,而不喜欢大罗C罗梅西。再进一步分析,是因为操控满足我的控制欲,并让我获得安全感,组织是我对美好事物的追求(有条不紊的进攻和防守),超越让我获得成就感。再进一步分析,不论是控制欲、安全感、成就感还是对美好事物...…

感悟继续阅读
更早的文章

对各模块间的互斥关系管理的小思考

背景最近在项目中遇到这样一个场景,在直播间的主播端有个功能区,里面是一些插件,这些插件之间在业务上存在互斥关系,也就是A处于开启状态时,B、C、D、E甚至是甲乙丙丁都不能打开。随着这块业务的增多,由于没有及时重构,导致互斥逻辑写的很是让人抓狂。假设有A、B、C、D四个互斥模块,当A要启动时,需要if (B is running) { Log("B is running"); return;}if (C is running) { Log("C is running"); ...…

重构 | iOS继续阅读