iOS触摸事件的分发和响应链(一)

前言

iOS中的事件在官网可以看到分为三类 : events<em>to</em>app

但是在iOS9之后又新增了3D Touch事件, 在UIEvent.h中可以看到事件定义的枚举:

```objc typedef NSENUM(NSInteger, UIEventType) { UIEventTypeTouches, UIEventTypeMotion, UIEventTypeRemoteControl, UIEventTypePresses NSENUMAVAILABLEIOS(9_0), };

```

所以, iOS中的事件可以分为下面四类:

  • 触摸事件 (Multitouch events)
    • e.g. 触摸,捏合缩放等
  • 加速计事件 (Accelerometer events)
    • e.g. 晃动设备等
  • 远程控制事件 (Remote control events)
    • 主要是外部辅助设备或者耳机的远程命令,eg. 控制音乐声音的大小
  • 3D 按压事件 (Presses events)
    • e.g. 3D Touch

这次主要学习 触摸事件 (Multitouch events)。

触摸事件的产生

  1. 当我们的手指在iPhone等屏幕上操作的时候, 手机中屏幕在手指按压/触摸的地方会产生电信号的变化,该变化会由操作系统内核的驱动程序捕捉到该Event, 暂且叫做eventOrigin
  2. UIKit会创建一个包含处理eventOrigin 时所需要的信息的UIEvent对象eventA, 然后将eventA放置到当前活动app的(active app's)事件队列(Event Queue)中。
  3. 当前活动的app判断到有需要执行的事件之后,UIApplication的单例实例就会从事件队列中去取顶部的第一个事件对象来分发并且处理。通常,UIApplication的单例实例会将事件送到app的key window object

触摸事件的传递

触摸事件,它是包含着一组触摸信息(touches)的UIEvent的对象。 通常,UIApplication的单例实例获取到Event Queue中的第一个事件之后会将改事件送到app的key window object ---> keyWindow,keyWindow会根据事件的类型找到初始的对象,并将事件传给该对象去处理

对于触摸事件,窗口对象首先尝试将事件传递到有接收到触摸的视图。 该视图称为命中测试(hit-test)视图。 找到命中测试(hit-test)视图的过程称为命中测试(hit-testing)。这些事件路径的最终目标是找到一个可以处理和响应事件的对象。 因此,UIKit首先将事件发送到最适合处理事件的对象。 对于触摸事件,该对象是命中测试视图,对于其他事件,该对象是第一个响应者

图2. Hit-testing returns the subview that was touched

Hit-Testing返回触摸发生的视图

hitTest:withEvent:方法首先检查视图是否允许接收触摸事件。视图允许接收触摸事件的条件是:

  • 视图不是隐藏的: self.hidden NO
  • 视图是允许交互的: self.userInteractionEnabled YES
  • 视图透明度大于0.01: self.alpha > 0.01
  • 视图包含这个点: pointInside:withEvent: == YES

备注:UIImageView的userInteractionEnabled属性默认值是NO!

iOS使用hit-testing来查找被触摸的视图。hit-testing需要检查触摸是否在所有相关视图对象的边界内。如果是,它会递归检查视图的所有子视图。视图层级中包含触摸点的最低的视图成为hit-test view(命中测试视图)。iOS确定hit-test view后,它会将触摸事件传递到该视图进行处理

如上图2所示,假设用户触摸图中的View E。iOS通过按照如下顺序检查子视图来查找hit-test view:

  • 触摸在View A的边界内,因此它检查子视图View B和View C.
  • 触摸不在View B的界限内,但它在View C的界限内,因此它检查子视图View D和View E.
  • 触摸不在View D的界限内,但它在View E的界限内。

最终,View E是视图层级中包含触摸的最低的视图,因此它成为hit-test view.

hitTest:withEvent:方法为给定的CGPoint和UIEvent返回特定的点击测试视图(hit test view)。hitTest:withEvent:方法的调用是通过调用自身的pointInside:withEvent:方法开始的。 如果传递到hitTest:withEvent:方法中的点是在视图的边界内,则pointInside:withEvent:返回YES。然后,在每个返回YES的子视图上递归调用hitTest:withEvent:方法 。

如果传递到hitTest:withEvent:方法中的点不在视图的边界内,首次调用pointInside:withEvent:方法返回NO,同时该点被忽略,并且hitTest:withEvent:返回nil。如果一个子视图返回NO,则该子视图层级结构的整个分支将被忽略,因为如果触摸没有发生在该子视图中,则它也不会出现在该子视图中的任何子视图中。这意味着在子视图内而在父视图之外的任何点都不能接受触摸事件,因为触摸点必须在父视图和子视图边界内。e.g.如果该子视图(相对它的子试图而言,它就是父试图)的clipsToBounds属性设置为NO,则可能出现此问题。

>注意:一个触摸对象的生命期与其命中测试视图(hit-test view)相关联,即使触摸稍后移动到视图外。

这篇文章中有更加详细的解释,我先引用这篇文章中的两张图: 图3. 图4.

hitTest的粗略应用

一. 放大点击区域

// 自定义view
// 重写下面的两个方法
// 注:在controller中,蓝色view是self,灰色view是蓝色view下面的shapeLayer

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    CGRect touchRect = CGRectInset(self.bounds, -20, -20);
    return CGRectContainsPoint(touchRect, point);
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.userInteractionEnabled) {
        return nil;
    }

    if (self.hidden) {
        return nil;
    }

    if (self.alpha <= 0.01) {
        return nil;
    }

    CGRect touchRect = CGRectInset(self.bounds, -20, -20);
    if (CGRectContainsPoint(touchRect, point)) {
        if (CGRectContainsPoint(self.bounds, point)) {
            NSLog(@"点击了蓝色区域");
        } else {
            NSLog(@"点击了灰色区域");
        }
        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;
}

这里有demo

二. 把事件传递给它下面的view

有的时候对于一个视图忽略触摸事件并传递给下面的视图是很重要的。例如,假设一个透明的视图覆盖在应用内所有视图的最上面。覆盖层View上如果有子视图,那么这些子视图比如按钮则响应自己对应的触摸事件。但是触摸覆盖层的其他区域应该传递给覆盖层下面的视图。为了完成这个行为,覆盖层需要覆盖hitTest:withEvent:方法来返回包含触摸点的子视图中的一个,然后其他情况返回nil,包括覆盖层包含触摸点的情况:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView == self) {
        hitTestView = nil;
    }
    return hitTestView;
    // 这里直接返回nil的话会把所有的子试图的触摸事件全部忽略了
}

三. 传递触摸事件给子视图

e.g. 有时候在scrollView中做翻页浏览的时候,比如一般app首页的轮播图,为了做出一些动画效果就像滑动的时候不同的子试图会有放大缩小的效果,这时候scrollView的width小于屏幕的宽度,所以在触摸两端距离屏幕的空白区域的时候,scrollView不会响应滑动触摸,会影响体验,这时候hitTest:withEvent:派上用场:

// 在自定义的轮播图view中重写hitTest:withEvent:
// 注: scrollView是作为subView存在的

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView) {
        hitTestView = self.scrollView;
    }
    return hitTestView;
}

参考链接

作者介绍

  • 张亚斌 iOS 高级开发工程师

微鲤技术团队

微鲤技术团队承担了中华万年历、Maybe、蘑菇语音、微鲤游戏高达3亿用户的产品研发工作,并构建了完备的大数据平台、基础研发框架、基础运维设施。践行数据驱动理念,相信技术改变世界。