Hit Test和响应者链条

iOS应用定义了多种事件,简单的分类有三种:

  • MultiTouch Events 多点触摸事件
  • Motion Events 设备加速器、陀螺仪检测到的运动事件
  • Remote Control Events 比如蓝牙耳机等对设备的控制

这篇文章主要基于触摸事件来说明Hit Test和响应者链条。iOS在处理用户触摸事件的时候是通过响应者链条来向上回溯找到一个UIResponder及其子类的对象,将事件交由它来处理的。

那这样一个过程实际上是两个步骤,首先用户在点击屏幕区域的时候,如何来判断这个事件应该首先由谁来处理呢,也就是事件的first responder是谁呢?

如果这个first responder不能够处理这个事件,那么事件将交给first respondernext responder来处理,那又该遵循什么样的规则来部署这个next responder,继而获得由first responder至最后一个响应者的链条(responder chain)?

如何获取first responder

获取相应一个触摸事件的first responder就是对应用的view hierachyhit 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

其中原理图如下:

Hit Test原理图

从图中可以看出,在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 respondernext responder直至App Delegate(如果AppDelegate为UIResponder的子类的话)串联起来的链条,可以是不一样的。只有当first responder确定之后,那么整个链条就能被确定下来。

上面我们讲到了first responder的确定,那么整个链条是如何被确定的呢?

规则有下面几条:

  • 如果一个responder的对象的next responder方法被重载了,那么该响应者的下一个响应者就是该被重载的方法返回的对象。
  • 如果该responderUIView类及子类的对象的话。在该view不是一个view controllerroot view的情况下,next responder对象为该view的superView;反之,为持有该view的view controller对象(也就是该controllerself.view为该view)。
  • 如果该responderUIViewController类及子类的对象的话。在该Controller不是某个window的根控制器的情况下,那么next responder是它的父控制器;反之,为该window对象。
  • 如果该responderUIWindow对象,那么其next responder是application对象。
  • 如果该responderUIApplication对象,那么其next responder是其代理对象(如果该代理对象是UIResponder的子类的话)。

这套逻辑就是用来确定一个responder对象的next responder从而确定整个响应者链条。下图是对该逻辑的展现。

Responder Chain示例

事件处理流程

在确定了一个触摸事件的first responder之后,事件将尝试交由该响应者处理。如何响应者处理不了,事件就会从该响应者开始reponder chain中的下一个响应者传递。如果到了最后一个响应者都不能够处理该事件,那么该事件将被应用丢弃掉。

参考资料