Hit Test和响应者链条
Hit Test和响应者链条
iOS应用定义了多种事件,简单的分类有三种:
- MultiTouch Events 多点触摸事件
- Motion Events 设备加速器、陀螺仪检测到的运动事件
- Remote Control Events 比如蓝牙耳机等对设备的控制
这篇文章主要基于触摸事件来说明Hit Test和响应者链条。iOS在处理用户触摸事件的时候是通过响应者链条来向上回溯找到一个UIResponder
及其子类的对象,将事件交由它来处理的。
那这样一个过程实际上是两个步骤,首先用户在点击屏幕区域的时候,如何来判断这个事件应该首先由谁来处理呢,也就是事件的first responder
是谁呢?
如果这个first responder
不能够处理这个事件,那么事件将交给first responder
的next responder
来处理,那又该遵循什么样的规则来部署这个next responder
,继而获得由first responder
至最后一个响应者的链条(responder chain
)?
如何获取first responder
获取相应一个触摸事件的first responder
就是对应用的view hierachy
做hit test
的一个过程,首先从window
开始递归地逆序遍历自己的subViews
。如果被触摸的区域在某个subView
内部,再次以同样的方式进行遍历查找。
直到找到一个可能处理该事件的hit-test view
。这里的可能其实指的是该view的userInteractionEnabled
属性为YES
;其hidden
属性为NO
;其alpha
值大于0.01
。这些都可以从Apple的API说明中获得。
使用到的方法,就主要有两个:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
文章的作者对于Hit Test
的原理做了详细的解读,同时对hitTest:withEvent:
方法的可能实现也做了剖析。
除了上面提到的文章之外,在WWDC 2014的一个session中,也以官方的姿态说明了Hit Test
的原理。具体的说明,大家可以参考Advanced Scrollviews and Touch Handling Techniques。
其中原理图如下:
从图中可以看出,在window
进行Hit Test
时,会首先判断自己是不是满足基本条件:
userInteractionEnabled
hidden
alpha > 0.01
- 触摸的点是不在在自己的范围内
如果不满足任何一条,则自己不是hit-test view
。
如果满足,则逆序遍历自己的所有subViews
。(为什么要逆序:因为后添加的subView
在最上层的几率大嘛)。对每一个自己的subView
进行同样的Hit Test
,如果能够找到一个符合条件的subView
,那么它就是hit-test view
。如果没有,就返回自己。
示例代码如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
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;
}
了解Hit Test
的机制在应用实际开发过程中有几个常用的地方,在这篇文章中有详细的说明。同样在WWDC的session视频中也有提到在什么样的场景下需要去重写hitTest:withEvent:
或者是pointInside:withEvent:
方法。
总的来讲有下面几种常用场景:
- 增加一个视图的可点击范围:有的时候在UI设计师完成设计之后,某些按钮可能设计大小较小;在这种情况下,我们可以重写按钮的
pointInside:withEvent:
方法,将按钮的响应范围扩大,因而提高用户的体验。 - 将点击事件传递给下级视图,如果自己不需要相应该事件。将视图的
userInteractionEnabled
属性置为NO
,也能够达到这个需求, 但是如果点击了自己的自视图区域,自己的自视图保持原有的相应,userInteractionEnabled
属性禁用之后子视图也统统被禁用了。WWDC中示例的用途就恰恰是这个:让touch事件穿透某个视图而到达下层视图,同时保持视图的子视图可以持有原来的点击相应行为。 - 将自己接收的事件传递给某个特定的子视图。这个只需要在父视图的
hitTest
方法中返回该特定的子视图就完成了。
响应者链条
当在阅读文档时候,官方文档会告诉你响应者链条是一个dynamic
的链条,一个App不存在a single responder chain
。这其实是完全可以理解的。因为first responder
的不同,由这个first responder
出发到其next responder
以及next responder
的next responder
直至App Delegate
(如果AppDelegate为UIResponder的子类的话)串联起来的链条,可以是不一样的。只有当first responder
确定之后,那么整个链条就能被确定下来。
上面我们讲到了first responder
的确定,那么整个链条是如何被确定的呢?
规则有下面几条:
- 如果一个
responder
的对象的next responder
方法被重载了,那么该响应者的下一个响应者就是该被重载的方法返回的对象。 - 如果该
responder
是UIView
类及子类的对象的话。在该view不是一个view controller
的root view
的情况下,next responder
对象为该view的superView
;反之,为持有该view的view controller
对象(也就是该controller
的self.view
为该view)。 - 如果该
responder
是UIViewController
类及子类的对象的话。在该Controller不是某个window
的根控制器的情况下,那么next responder
是它的父控制器;反之,为该window
对象。 - 如果该
responder
是UIWindow
对象,那么其next responder
是application对象。 - 如果该
responder
是UIApplication
对象,那么其next responder
是其代理对象(如果该代理对象是UIResponder
的子类的话)。
这套逻辑就是用来确定一个responder
对象的next responder
从而确定整个响应者链条。下图是对该逻辑的展现。
事件处理流程
在确定了一个触摸事件的first responder
之后,事件将尝试交由该响应者处理。如何响应者处理不了,事件就会从该响应者开始reponder chain
中的下一个响应者传递。如果到了最后一个响应者都不能够处理该事件,那么该事件将被应用丢弃掉。