关于 hitTest:withEvent 的一点个人理解

当发生触摸事件后,系统会将该事件加入到一个由 UIApplication 管理的事件队列中。
UIApplication 会从事件队列的最前面取出事件,并将此事件分发出去。
通常,事件会最先传递到 UIWindow (keyWindow),主窗口会在其视图层次结构中,找到一个最合适的视图来处理这次触摸事件,这个寻找的过程就叫做事件传递。

事件传递

传递过程实例

image.png

前提,任何一个在屏幕上触摸事件,都会首先被 UIApplication 接管,并存储在事件队列中。
然后 UIApplication 从事件队列中,取出最前面的那个事件,开始从 keyWindow 往下分发。

如果点击了 1
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1

如果点击了2
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 2

如果点击了3
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 3

如果点击了4
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 4

如果点击了5上半部分
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 5

如果点击了5下半部分
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 5
但是5的点击的坐标点,并不在它的父视图范围,于是此次触摸就无效。无法触发

一张事件分发消息队列调用堆栈

image.png

可以证明,事件确实是从 UIApplication -> window -> 目标视图 这条路径的。

手势的判断路径

前提:基本上99%的页面布局,从我们可以知道的事件传递开始。都是从一个 ViewController 的 rootView 开始的。
那么我们就把这个 rootView 最为最底层的 View 也未尝不可。

  1. 当用户在某个 App 的页面点击了屏幕,会产生一个事件。
  2. 当前 App 会通过用户的这个物理点击,把这个事件放到 UIApplicationEventQueue 里面。
  3. 从当前事件队列中,拿出一个事件开始往上传递。
  4. 这里的传递从当前控制器的 rootView 开始。
  5. 首先判断,在此 rootView 上是否有子 View。
  6. 如果没有,那么就是当前的 rootView 执行这个事件 - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event 也就是我们常写的这个方法。
  7. 如果 self.view.subviews.count>0,那么就开始倒序的遍历一级父视图。
  8. 并在父视图里面,递归调用 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event 。 来找到最合适的视图来处理此次的触摸事件。

有几个问题需要解释一下:

  • 为什么要倒序

个人猜测:因为后加的 view 按照层级来算的话,绝大部分情况下都是会处于当前视图层次结构的最顶层。它们才是用户最能够直接点击的 view。所以,倒序在这里是很符合真实情况的。

  • 为什么子视图关闭了交互,父亲视图仍然可以处理事件?

因为事件的传递是从下到上了,直接父视图的 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event 不返回 nil 了,子视图才有机会调用自己的 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event 。也就是说,不管子视图是否能处理这次事件,事件都是从父视图传递过来的。所以,即使子视图无法处理了,事件流仍然会在父视图上执行。

- (UIView )hitTest:(CGPoint)point withEvent:(UIEvent )event基本逻辑如下。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1. 判断当前视图是否可以交互。
    // 如果当前视图无法交互,事件传递信号在这里就被终止了。
    // 从而影响此视图内的所有子视图,都将变成无法交互的状态。
    if (self.userInteractionEnabled == NO || self.alpha < 0.01 || self.hidden == YES) return nil;
    
    // 2. 判断当前的触摸点是否在当前视图的 bounds 里。
    if (![self pointInside:point withEvent:event]) return nil;
    
    // 3. 达到这一步,就说明,当前视图开启了交互,且触摸点在这个视图里。
    for (UIView *view in self.subviews) {
        // 4. 转换点坐标,把父亲视图里的 point 转换为相对于当前 subView 视图的坐标系的点
        CGPoint p2 = [self convertPoint:point toView:view];
        // 开始循环递归,找到当前(self)最合适处理这个事件的子视图。
        UIView *fitView = [super hitTest:p2 withEvent:event];
        if (fitView) {
            return fitView;
        }
    }

    // 否则返回自己,来处理这个事件。
    /**
     情况有2种
     1. 当前 self 中,不包含任何子视图
     2. 当前 self 中,子视图都不能被交互 (userInteractionEnabled == NO || Aplha < 0.01 || hidden == YES)
     */
    return self;
}


那么知道了,事件传递的基本逻辑,我们可以尝试一下,解决一些 App 开发过程中常见的由于可以使用事件传递机制来解决的问题。

一、 子视图超出了父视图的范围导致无法点击。

image.png

按照正常情况下,当我们点击红色区域的时候,黄色的 view 是无法响应用户触摸事件的。
原因是:

  1. 当我们点击了红色的区域,随即产生了一个触摸事件 event。
  2. 当前 event 被添加到 UIApplicationEventQueue 中。
  3. 从当前队列中,拿出这个事件,并找到 rootView.subViews.
  4. 然后开始倒序的对这个 view 开始执行 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event` 。
  5. 由于,除了 rootView 外,只有一个底层视图,也就是 grayView。
  6. 于是,hitTest 就开始测试 grayView 了。
  7. 由于,点击的区域是红色的部分。
  8. 在 grayView 的 hitTest 函数中,虽然第一行,是否可以交互判断通过了,但是第二行的 pointInside 却返回 nil 了。
  9. 返回 nil 的话,后续的对子视图的递归 hitTest 就不会执行。

所以,这里点击红色区域的地方,导致黄色视图无法触发事件的原因是,当前点击的点,不在黄色视图的父视图 grayView 里面。

解决办法:
既然,点不在父视图的范围里面。那么父视图 hitTest 肯定就返回 nil 了。
重写父视图的 hitTest,当返回 nil 的时候,让他也去遍历他的子视图控件。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *view = [super hitTest:point withEvent:event];
     // 返回 nil 了,无法就是点不在你的范围,但并不代表点不在你的子视图范围内。
    if (!view) {
        for (UIView *subView in self.subviews) {
            CGPoint p = [self convertPoint:point toView:subView];
            if (CGRectContainsPoint(subView.bounds, p)) {
                return subView;
            }
        }
    }
    
    return view;
}

效果截图:

15115151008943.gif

二、扩大子视图的可点击范围。
场景:有时候,某些可点击的范围太小了,用户不是很好点击。


15115156254124.jpg

造成不好点击的原因

  1. 首先时间肯定是由后面那个灰色的 view 传递过来的。
  2. 由于绿色的按钮太小了,虽然它的 hitTest 进去了,但是 pointInside 老是判断不在当前绿色 view 的 bounds,返回 nil。从而无法触发用户事件。

解决思路
重写绿色 view 的 pointInSide 方法,扩大可点击的范围。

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    /*
     上下左右个大20个点。
     */
    // 横向距离扩大20个 pt
    CGFloat minX = -20;
    CGFloat maxX = self.bounds.size.width + 20;
    // 纵向距离扩大20个 pt
    CGFloat minY = -20;
    CGFloat maxY = self.bounds.size.height + 20;
    
    // 只要点击的点在这么个范围,就算是点到了这个按钮
    if ((point.x > minX && point.x < maxX) &&
        (point.y > minY && point.y < maxY)
        ) {
        return YES;
    }
    
    return NO;
}

运行效果:

15115158165395.gif

三、穿透。(有这种需求吗?我不知道。。反正可以先搞个这么个 demo,理一下思路,防止后期可能用到)

其实穿透这种做法,很常见。
我们在一个 view 上,套一个 UIImageView,然后点击 UIImageView 的时候,让 view 来执行此次事件。
这很简单,原理是 UIImageView 默认是无法交互的。hitTest 从 view 到 UIImageView。imageView 返回 nil 了。
自然而然的 view 就执行这个触摸事件了。

当然,如果 view 上套的子视图也开启了交互了?
解决办法就两种:

  1. 把子视图的 .userInteractionEnabled = NO;
  2. 第二种,重写子视图的 hitTest or pointInside 前者返回 nil,或者返回 NO 也可以。
//// 解决方法一
//- (void)awakeFromNib {
//    [super awakeFromNib];
//    self.userInteractionEnabled = NO;
//}
// 解决方法二:可以重写此 view 的 hitTest 直接返回 nil,让递归进行到后面的GreenView 上去
//- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//    return nil;
//}

// 解决办法三、重写 pointInside 返回 NO
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    return NO;
}

推荐第一个种写法,本质上也是在 hitTest 方法里,第一行就返回 nil 了。

运行结果:

15115167079379.gif

DEMO github地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,463评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,868评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,213评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,666评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,759评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,725评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,716评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,484评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,928评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,233评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,393评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,073评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,718评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,308评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,538评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,338评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,260评论 2 352