iOS之hitTest

前言

我负责努力,其余交给运气。

写这篇文章,是因为之前写了一篇如何解决button点击范围过小的文章,然后评论区小伙伴说hitTest也可以,然后我就查了一下hitTest,发现真的有其牛逼之处,所以整理一下。

一、什么是hitTest

官方文档中介绍(若理解翻译的不对还请指正):- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;

  • Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.
    自我理解:返回所能包含point的view和view.subviews中最后的一个view。

  • point:A point specified in the receiver’s local coordinate system (bounds).
    自我理解:在接收器的局部坐标系(界)中指定的点。

  • event:The event that warranted a call to this method. If you are calling this method from outside your event-handling code, you may specify nil.
    自我理解:此方法可以正常响应的事件。如果从触发事件之外调用此方法,则可以指定为nil。

  • Return Value:The view object that is the farthest descendent of the current view and contains point. Returns nil if the point lies completely outside the receiver’s view hierarchy.
    自我理解:所能包含point的view和view.subviews中最后的一个view。如果point完全位于视图层次结构之外,则返回nil

总的来说就是:该方法会被系统调用(可重写),在视图的层次结构中寻找到一个最适合的 view (理解为最上层view)来响应触摸事件,如果返回为nil,即事件有可能被丢弃。

二、hitTest的调用顺序

触摸事件寻找最佳响应者,即hitTest 的调用顺序大致如下:

touch(UIEvent)->UIApplication->UIWindow->window.subviews->...->view
  1. 当App接收触摸事件时,主线程的runloop被唤醒,触发source1回调。source1回调又触发了一个source0回调,将接收到的触摸事件(IOHIDEvent对象)封装成UIEvent对象,此时APP将正式开始对于触摸事件的响应。source0回调将触摸事件添加到UIApplication的事件队列中。
  2. UIApplication会从事件队列中取出最早的事件进行分发处理,首先将事件传递给窗口对象(UIWindow),如果有多个UIWindow对象,则先选择最后加上的UIWindow对象。
  3. UIWindow会调用其hitTest:withEvent:方法在视图(UIView)层次结构中找到一个最合适的UIView来处理触摸事件。
三、触摸事件的传递顺序

通过hitTest我们已经找到了最佳响应者,下面要做的事就是让这个最佳响应者响应触摸事件。这个最佳响应者对于触摸事件拥有决定权,它可以决定是自己独自响应这个事件,也可以自己响应之后还把它传递给其他响应者。

事件传递顺序大致为:

view -> superView ...- > UIViewController.view -> UIViewController -> UIWindow -> UIApplication -> 事件丢弃

文字说明:

  • 1、 首先由 view 来尝试处理事件,如果他处理不了,事件将被传递到他的父视图superview
  • 2、superview 也尝试来处理事件,如果他处理不了,继续传递他的父视图
    UIViewcontroller.view
  • 3、UIViewController.view尝试来处理该事件,如果处理不了,将把该事件传递给UIViewController
  • 4、UIViewController尝试处理该事件,如果处理不了,将把该事件传递给主窗口Window
  • 5、主窗口Window尝试来处理该事件,如果处理不了,将传递给应用单例Application
  • 6、如果Application也处理不了,则该事件将会被丢弃。

注:
响应者对于事件的响应和传递都是在touchesBegan:withEvent:这个方法中完成的。该方法默认的实现是将该方法沿着响应链往下传递。
响应者对于接收到的事件有三种操作:

  • 1.默认的操作。不拦截,事件会沿着默认的响应链自动往下传递。
  • 2.拦截,不再往下分发事件,重写touchesBegan:withEvent:方法,不调用父类的touchesBegan:withEvent:方法。
  • 3.不拦截,继续往下分发事件,重新touchesBegan:withEvent:方法,并调用父类的touchesBegan:withEvent:方法。
四、hitTest的实现思路

首先看一下系统的hitTest是怎么调用的,看代码:

#import "ViewController.h"
#import "AView.h"
#import "BView.h"
#import "CView.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    AView* aView = [[AView alloc] init];
    aView.frame = CGRectMake(100, 100, 100, 100);
    aView.backgroundColor = [UIColor orangeColor];
    
    BView* bView = [[BView alloc] init];
    bView.frame = CGRectMake(100, 100, 80, 80);
    bView.backgroundColor = [UIColor blueColor];
    
    CView* cView = [[CView alloc] init];
    cView.frame = CGRectMake(100, 100, 60, 60);
    cView.backgroundColor = [UIColor redColor];
    
    [self.view addSubview:aView];
    [self.view addSubview:bView];
    [self.view addSubview:cView];
    
    for (UIView* hitView in self.view.subviews) {
        NSLog(@"for %@",[hitView class]);
    }
}

@end
#import "AView.h"

@implementation AView

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    NSLog(@"-----hitTest star AView-----");
    UIView* view = [super hitTest:point withEvent:event];
    NSLog(@"-----hitTest end AView-----");
    return view;
}

@end

AView、BView、CView都是继承UIView,都重写了它们的hitTest。在A、B、C之外点击,运行结果如下:

运行结果

我们可以看到:for in循环的打印结果,因为subviews是一个数组,所以有序,顺序为addSubview决定;而hitTest打印结果很明显,官方文档说是寻找最远View,看打印结果我理解是就是从subviews最后一个开始找,也就是最上层的view(虽然subviews可以说是同一层级,因为都在view上,但是后添加的确实会覆盖先添加的view,所以个人认为哪怕subviews都属于view层级,但是他们之间依然是后添加的相对来说在最上层。)

且常见的视图不响应事件不外乎如下几种情况:

1、view.userInteractionEnabled = NO;
2、view.hidden = YES;
3、view.alpha < 0.05;
4、view 超出 superview 的 bounds;

那么hitTest 就可根据上面 结果 大概模拟下 hitTest 方法的大概实现:

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // 如果交互未打开,或者透明度小于0.05 或者 视图被隐藏
    if (self.userInteractionEnabled == NO || self.alpha < 0.05 || self.hidden == YES)
    {
        return nil;
    }
    // 如果 touch 的point 在 self 的bounds 内
    if ([self pointInside:point withEvent:event])
    {
        NSInteger count = self.subviews.count;
        for ( int i = 0; i < count; I++)
        {
            UIView* subView = self.subviews[count - 1 - I];
            //进行坐标转化
            CGPoint coverPoint = [subView convertPoint:point fromView:self];
            // 调用子视图的 hitTest 重复上面的步骤。找到了,返回hitTest view ,没找到返回有自身处理
            UIView *hitTestView = [subView hitTest:coverPoint withEvent:event];
            if (hitTestView)
            {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

很多文章,直接用for in 遍历 subviews,应该是不对的。步骤文字说明:

  • 1、首先在当前视图的hitTest 方法中调用pointInside 方法判断触摸点是否在当前视图内
  • 2、若pointInside 方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest 返回nil ,该视图不处理该事件
  • 3、若pointInside 方法返回YES,说明触摸点在当前视图内,则从最上层的子视图开始(即从subviews 数组的末尾向前遍历),遍历当前视图的所有子视图,调用子视图的hitTest 方法重复步骤1-3
  • 4、直到有子视图的hitTest 方法返回非空对象或者全部子视图遍历完毕
  • 5、若第一次有子视图的hitTest 方法返回非空对象,则当前视图的hitTest 方法就返回此对象,处理结束
  • 6、若所有子视图的hitTest 方法都返回nil,则当前视图的hitTest 方法返回当前视图本身,最终由该对象处理触摸事件

代码文字描述的可能比较复杂,下面图文再描述一遍,如图:

视图

A、B橘黄色View在白色View上,C、D在A上,E、F在B上:
层次结构图

addSubview顺序为A、B、C、D、E、F;

结果:
点击白色view区域:B->A
点击F:B->F
点击E:B->F->E
点击C:B->A->D->C
点击D:B->A->D

结论与之前相同,hitTest 一直是在找包含触点的最上层View(subviews最后一个:最后add的View)

五、hitTest的运用场景
1、事件穿透

我们可以让上层响应事件的同时,下层view同时响应。好像不太能碰得到,暂不举例说明(感觉情况有点多...不同层次结果可能解决方法是不一样的,有需要的可以留言...)。

2、子视图超出父视图 范围
我是盗图小能手

类似与上图:发布按钮已然已经超出tabbar的范围,那么该按钮是如何响应点击事件的?
解决办法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {


     //将当前tabbar的触摸点转换坐标系,转换到中间按钮的身上,生成一个新的点
     CGPoint newP = [self convertPoint:point toView:self.centerBtn];

      //判断如果这个新的点是在中间按钮身上,那么处理点击事件最合适的view就是中间按钮
      if ( [self.centerBtn pointInside:newP withEvent:event]) 
      {
            return self.centerBtn;
       }


    return [super hitTest:point withEvent:event];

}//重写hitTest方法,去监听中间按钮的点击,目的是为了让凸出的部分点击也有反应
总结:

hitTest其实最牛逼的地方在于,我们可以更好的了解一个事件触发后从App一直到响应的过程;我们也可以针对触点重写hitTest,让其在一定范围内响应某些事件;最后就是解决明明在触点下但是超出父视图事件不响应的问题。(有的时候发现很多东西,都是知其然不知其所以然,希望自己和大家,慢慢的探索,做到知其然知其所以然... 所以若有问题,欢迎并感谢大家指正)

提问:

最上面的输出结果大家也看到了,hitTest实际上走了两遍,不知道为什么,大家有知道的请留言哈...

参考:

参考文章1
参考文章2
参考文章3 iOS中触摸事件传递和响应原理
官方文档

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

推荐阅读更多精彩内容