iOS UI

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

2017-11-23  本文已影响0人  人话博客

当发生触摸事件后,系统会将该事件加入到一个由 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地址

上一篇下一篇

猜你喜欢

热点阅读