本文系转载,原文地址为iOS触摸事件全家桶
APP接收到触摸事件后,会被放入当前应用的一个事件队列中,然后寻找
一个具备最高优先级响应权的响应对象(the most appropriate responder object),这个过程叫做Hit-Testing,那个命中的最佳响应者称为hit-tested view,找到这个view来响应它,然后释放
本节要探讨的问题是:
- 应用接收到事件后,如何寻找最佳响应者?底层如何实现?
- 寻找最佳响应者过程中事件的拦截。
事件自下而上的传递
- 应用接收到事件后先将其置入事件队列中以等待处理。
- 出队后,application首先将事件传递给当前应用最后显示的窗口(UIWindow)询问其能否响应事件。
若窗口能响应事件,则传递给子视图询问是否能响应,子视图若能响应则继续询问子视图。
子视图询问的顺序是优先询问后添加的子视图,即子视图数组中靠后的视图。事件传递顺序如下:
UIApplication ——> UIWindow ——> 子视图 ——> ... ——> 子视图
- 事实上把UIWindow也看成是视图即可,这样整个传递过程就是一个递归询问子视图能否响应事件过程,且后添加的子视图优先级高(对于window而言就是后显示的window优先级高)。
具体流程如下:
(1)UIApplication首先将事件传递给窗口对象(UIWindow),若存在多个窗口,则优先询问后显示的窗口。
(2) 若窗口不能响应事件,则将事件传递其他窗口;若窗口能响应事件,则从后往前询问窗口的子视图。
(3) 重复步骤2。即视图若不能响应,则将事件传递给上一个同级子视图;若能响应,则从后往前询问当前视图的子视图。
(4) 视图若没有能响应的子视图了,则自身就是最合适的响应者。
示例:
视图层级如下(同一层级的视图越在下面,表示越后添加):
A
├── B
│ └── D
└── C
├── E
└── F
现在假设在E视图所处的屏幕位置触发一个触摸,应用接收到这个触摸事件事件后,先将事件传递给UIWindow,然后自下而上开始在子视图中寻找最佳响应者。事件传递的顺序如下所示:
(0) UIWindow将事件传递给其子视图A
(1) A判断自身能响应该事件,继续将事件传递给C(因为视图C比视图B后添加,因此优先传给C)。
(2) C判断自身能响应事件,继续将事件传递给F(同理F比E后添加)。
(3) F判断自身不能响应事件,C又将事件传递给E。
(4) E判断自身能响应事件,同时E已经没有子视图,因此最终E就是最佳响应者。
Hit-Testing的本质
视图如何判断能否响应事件?以及视图如何将事件传递给子视图?
首先要知道的是,以下几种状态的视图无法响应事件:
- 不允许交互:userInteractionEnabled = NO
- 隐藏:hidden = YES 如果父视图隐藏,那么子视图也会隐藏,隐藏的视图无法接收事件
- 透明度:alpha < 0.01 如果设置一个视图的透明度<0.01,会直接影响子视图的透明度。alpha:0.0~0.01为透明。
每个UIView对象都有一个 hitTest:withEvent:
方法,这个方法是Hit-Testing过程中最核心的存在,其作用是询问事件在当前视图中的响应者
,同时又是作为事件传递的桥梁
。
hitTest:withEvent:
方法返回一个UIView对象,作为当前视图层次中的响应者。默认实现是:
- 若当前视图无法响应事件,则返回nil
- 若当前视图可以响应事件,但无子视图可以响应事件,则返回自身作为当前视图层次中的事件响应者
- 若当前视图可以响应事件,同时有子视图可以响应,则返回子视图层次中的事件响应者
- 一开始
UIApplication
将事件通过调用UIWindow
对象的hitTest:withEvent:
传递给UIWindow
对象 -
UIWindow
的hitTest:withEvent:
在执行时若判断本身能
响应事件,则调用子视图的hitTest:withEvent:
将事件传递给子视图,并询问子视图上的最佳响应者。最终UIWindow返回一个视图层次中的响应者视图给UIApplication,这个视图就是hit-testing
的最佳响应者。
系统对于视图能否响应事件的判断逻辑除了之前提到的3种限制状态(交互,隐藏,透明度
),默认能响应的条件就是触摸点在当前视图的坐标系范围内
(即方法pointInside:withEvent:
)。因此,hitTest:withEvent: 的默认实现就可以推测了,大致如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
//3种状态无法响应事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
//触摸点若不在当前视图上则无法响应事件
if ([self pointInside:point withEvent:event] == NO) return nil;
//从后往前遍历子视图数组
int count = (int)self.subviews.count;
for (int i = count - 1; i >= 0; i--)
{
// 获取子视图
UIView *childView = self.subviews[i];
// 坐标系的转换,把触摸点在当前视图上坐标转换为在子视图上的坐标
CGPoint childP = [self convertPoint:point toView:childView];
//询问子视图层级中的最佳响应视图
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView)
{
//如果子视图中有更合适的就返回
return fitView;
}
}
//没有在子视图中找到更合适的响应视图,那么自身就是最合适的
return self;
}
值得注意的是pointInside:withEvent:
这个方法,用于判断触摸点是否在自身坐标范围内。默认实现是若在坐标范围内则返回YES,否则返回NO。
现在我们在上述示例的视图层次中的每个视图类中添加下面3个方法来验证一下之前的分析(注意 hitTest:withEvent:
和 pointInside:withEvent:
方法都要调用父类的实现,否则不会按照默认的逻辑来执行Hit-Testing
):
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
return [super hitTest:point withEvent:event];
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
return [super pointInside:point withEvent:event];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"%s",__func__);
}
单点触摸视图E,相关日志打印如下:
-[AView hitTest:withEvent:]
-[AView pointInside:withEvent:]
-[CView hitTest:withEvent:]
-[CView pointInside:withEvent:]
-[FView hitTest:withEvent:]
-[FView pointInside:withEvent:]
-[EView hitTest:withEvent:]
-[EView pointInside:withEvent:]
-[EView touchesBegan:withEvent:]
可以看到最终是视图E先对事件进行了响应,同时事件传递过程也和之前的分析一致。
事实上单击后从 [AView hitTest:withEvent:]
到[EView pointInside:withEvent:]
的过程会执行两遍,两次传的是同一个touch,区别在于touch的状态不同,第一次是begin阶段,第二次是end阶段。也就是说,应用对于事件的传递起源于触摸状态的变化。
Hit-Testing过程中的事件拦截(自定义事件流向)
实际开发中可能会遇到一些特殊的交互需求,需要定制视图对于事件的响应。例如下面Tabbar的这种情况,中间的原型按钮是底部Tabbar上的控件,而Tabbar是添加在控制器根视图中的。默认情况下我们点击图中红色方框中按钮的区域,会发现按钮并不会得到响应。
分析一下原因其实很容易就能明白问题所在。忽略不相关的控件,视图层次如下:
RootView
└── TableView
└── TabBar
└── CircleButton
点击红色方框区域后,生成的触摸事件首先传到UIWindow,然后传到控制器的根视图即RootView
。RootView
经判断可以响应触摸事件,而后将事件传给了子控件TabBar
。
问题就出在这里,因为触摸点不在TabBar的坐标范围内,因此TabBar无法响应该触摸事件,hitTest:withEvent:
直接返回了nil
。而后RootView就会询问TableView
是否能够响应,事实上是可以的,因此事件最终被TableView消耗
。整个过程,事件根本没有传递到圆形按钮
。
有问题就会有解决策略。经过分析,发现原因是hit-Testing
的过程中,事件在传递到TabBar
的时候没能继续往CircleButton
传,因为点击区域坐标不在Tabbar
的坐标范围内,因此Tabbar
被识别成了无法响应事件。既然如此,我们可以修改事件hit-Testing
的过程,当点击红色方框区域时让事件流向原型按钮。
事件传递到TabBar
时,TabBar
的 hitTest:withEvent:
被调用,但是 pointInside:withEvent:
会返回NO
,如此一来 hitTest:withEvent:
返回了nil
。既然如此,可以重写TabBard的pointInside:withEvent:
,判断当前触摸坐标是否在子视图CircleButton
的坐标范围内,若在,则返回YES,反之返回NO。这样一来点击红色区域,事件最终会传递到CircleButton,CircleButton能够响应事件,最终事件就由CircleButton响应了。同时点击红色方框以外的非TabBar区域的情况下,因为TabBar无法响应事件,会按照预期由TableView响应。代码如下:
//TabBar
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
//将触摸点坐标转换到在CircleButton上的坐标
CGPoint pointTemp = [self convertPoint:point toView:_CircleButton];
//若触摸点在CricleButton上则返回YES
if ([_CircleButton pointInside:pointTemp withEvent:event]) {
return YES;
}
//否则返回默认的操作
return [super pointInside:point withEvent:event];
}
这样一来,点击红色方框区域的按钮就有效了。
网友评论