上一篇文章中详细的介绍了响应链中的一些概念。这里会重点介绍响应链的流程。
一、响应链流转
1.1 事件分发
在上一篇文章中介绍了MacOS中事件分发,而且指出事件分发的方向是“向上”
。当我们手指触碰到屏幕时,最开始获取到这个事件的并不是APP中最上层的视图,而是系统的I/O Kit
。我们将这个过程分为两个过程:
event进入APP之前
- 首先经过
I/O Kit
,将触摸屏上的物理触摸事件产生的电子信号传递到下一层; -
Core Services
对信号进行处理再转发到Window Services
上; - 在
Window Services
层,将这个触摸事件转成一个event对象; -
Window Services
再将event对象通过Mach Port
将event分发到当前活动的application中,具体位置是main run loop
中的event queue
中。
到这里,event已经生成并分发到了指定的application中。从计算机软件设计框架来看,上面的过程属于底层框架
,完成了数据从底层到上层的传递,仍然满足向上
的分发方向。
event进入APP之后
随着响应链构造
的过程,event会被分发到最上层的view,一般被当做第一响应者。当开始处理事件相应的时候,会按照响应链的方向逐个去询问是否能处理,直到事件被处理,或一直没处理并被响应链最后一个响应者对象捕获,在iOS中为ApplicationDelegate对象,会丢弃事件。
1.2 事件响应
- 如果当前响应者处理了event,则流程结束;
- 如果当前响应者没有处理,则将事件传递给
next responders
; - 直到事件传递到响应链最底端对象
ApplicationDelegate
对象,并被丢弃;
二、响应链构造
在一个应用创建之后,系统帮我们完成了响应链最底端的链路构造:
Window -> WindowDelegate -> Application -> ApplicationDelegate
与cocoa中的一样,响应链上的对象可以通过重写nextResponder
属性,来改变响应链。而我们在开发过程中,可以通过以下几种方式来改变响应链:
-
UIView
- 如果这个view是viewController的root view,则nextResponder是这个viewController
- 其他情况,nextResponder都是它的superView
-
UIViewController
- 如果viewController的view是window的root view,则nextResponder是这个window
- 如果viewControllerA是被另一个viewControllerB present出来的,则nextResponder是presenting view controller
-
UIWindow
- nextResponder是UIApplication对象
-
UIApplication
- nextResponder是app delegate对象
- 这个app delegate继承UIResponder,但不能是view/viewController/app对象
三、hitTest:withEvent: 与 pointInside:withEvent:
前面两章都是从理论上来讲解响应链的流程,现在就来看看这个过程在代码中是怎么表现的。
3.1 hitTest:withEvent: 方法
hitTest:withEvent:
Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.
返回包含指定点的视图层次结构中接收器的最远的后代(包括它自己)。
-
最远的后代
,从视图层级来说,指的是最上层视图; -
指定点
,必须是包含这个点的,文档中也指出是根据pointInside:withEvent:
方法来判断,当前视图是否包含该点。
当我们重写这个方法,从一个view的视角来读这个方法:
- 返回一个能处理该事件的视图;
- 这个视图不是view本身,就是view的子视图;
- 如果都不能处理该事件,view就将事件抛给next responders;
上面这个流程是严格按照响应链的方向来执行的,如果我们不按照响应链的顺序来读这个方法:
- 找到一个合适的处理该事件的视图;
- 业务告诉我,viewX是最合适的,所以我每次都返回viewX。只要event能走到这个界面,我每次都将event交给viewX来处理。
这个过程跟消息转发流程:forwardingTargetForSelector
是非常类似的,给消息找到一个合适的响应者,所以可以通过hitTest:withEvent:
方法将event转发给指定的view来处理。
3.2 pointInside:withEvent:
pointInside:withEvent:
Returns a Boolean value indicating whether the receiver contains the specified point.
判断receiver是否包含指定的点。这个方法比较简单,只做了这一件事,判断点击位置是否落在receiver中。这个receiver指的是view本身。所以这个方法的目的:判断自己是否包含指定的点
// recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
在 hitTest:withEvent:
方法的注释中表明会递归调用-pointInside:withEvent:
方法来判断点是否落在receiver中。
3.3 仿源码实现
应用场景
- 在一个方形按钮中点击中间的圆形区域有效,而点击四角无效
- 核心思想是在
pointInside: withEvent:
方法中修改对应的区域
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// 如果控件不允许与用用户交互,那么返回nil
if (!self.userInteractionEnabled || [self isHidden] || self.alpha <= 0.01) {
return nil;
}
//判断当前视图是否在点击范围内
if ([self pointInside:point withEvent:event]) {
//遍历当前对象的子视图(倒序)
__block UIView *hit = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//坐标转换,把当前坐标系上的点转换成子控件坐标系上的点
CGPoint convertPoint = [self convertPoint:point toView:obj];
//调用子视图的hitTest方法,判断自己的子控件是不是最适合的View
hit = [obj hitTest:convertPoint withEvent:event];
//如果找到了就停止遍历
if (hit) *stop = YES;
}];
//返回当前的视图对象
return hit?hit:self;
}else {
return nil;
}
}
// 该方法判断触摸点是否在控件身上,是则返回YES,否则返回NO,point参数必须是方法调用者的坐标系
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGFloat x1 = point.x;
CGFloat y1 = point.y;
CGFloat x2 = self.frame.size.width / 2;
CGFloat y2 = self.frame.size.height / 2;
//判断是否在圆形区域内
double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
if (dis <= self.frame.size.width / 2) {
return YES;
}
else{
return NO;
}
}
上面的实现是非常高还原度的实现逻辑,考虑了非常多的细节。
-
响应条件:
- userInteractionEnabled=YES
- hidden=YES
- alpha>0.01
点位条件:
[self pointInside:point withEvent:event]
,如果点击范围超过了自己的bundle,则所有子视图将不会有机会成为响应者;倒叙遍历:
NSEnumerationReverse
,后添加的响应者永远在响应链上端,所以代码实现中用的是倒叙遍历;遍历子视图:
[obj hitTest:convertPoint withEvent:event]
;如果hit成功,则返回子视图,如果hit失败,则继续遍历,若子视图都没有响应,则返回
self
;
四、响应链探索
4.1 应用场景
视图层级
视图层级树
CoreAnimation
最终会将视图层级转成以下一个树结构。最上层的视图是112
。最后被addSubview
到视图中的在最右侧。
hitTest:withEvent: 与 pointInside:withEvent:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *aView = [super hitTest:point withEvent:event];
if (aView) {
NSLog(@"hitTest from : %ld --> %ld", aView.tag, self.tag);
} else {
NSLog(@"hitTest from : nil --> %ld", self.tag);
}
return aView;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
BOOL ret = [super pointInside:point withEvent:event];
NSLog(@"pointInside : %ld (%d)", (long)self.tag, ret);
return ret;
}
4.2 响应顺序探索
下面将rootView
上的所有点击组合的调用做了测试,并输出测试结果。
- 点空白处
倒叙遍历rootView的子视图,
-
pointInside:withEvent:
都返回NO -
hitTest:withEvent:
都返回nil
pointInside : 10086 (0)
hitTest from : nil --> 10086
pointInside : 110 (0)
hitTest from : nil --> 110
pointInside : 10010 (0)
hitTest from : nil --> 10010
- 点10010
倒叙遍历rootView的子视图,
- 视图10010:
pointInside:withEvent:
都返回YES - 视图10010:
hitTest:withEvent:
都返回视图10010
pointInside : 10086 (0)
hitTest from : nil --> 10086
pointInside : 110 (0)
hitTest from : nil --> 110
pointInside : 10010 (1)
hitTest from : 10010 --> 10010
- 点110
倒叙遍历rootView的子视图,
- 110已经响应了事件,则停止遍历
倒叙遍历110的子视图,
-
pointInside:withEvent:
都返回NO -
hitTest:withEvent:
都返回nil
pointInside : 10086 (0)
hitTest from : nil --> 10086
pointInside : 110 (1)
pointInside : 112 (0)
hitTest from : nil --> 112
pointInside : 111 (0)
hitTest from : nil --> 111
hitTest from : 110 --> 110
- 点10086
倒叙遍历rootView的子视图,
- 10086已经响应了事件,则停止遍历
pointInside : 10086 (1)
hitTest from : 10086 --> 10086
后面的过程仍然遵循上述规律,就不再一一细数结果了。
- 点111(superview范围内)
pointInside : 10086 (0)
hitTest from : nil --> 10086
pointInside : 110 (1)
pointInside : 112 (0)
hitTest from : nil --> 112
pointInside : 111 (1)
hitTest from : 111 --> 111
hitTest from : 111 --> 110
- 点112
pointInside : 10086 (0)
hitTest from : nil --> 10086
pointInside : 110 (1)
pointInside : 112 (1)
hitTest from : 112 --> 112
hitTest from : 112 --> 110
- 点111(superview范围外)
pointInside : 10086 (0)
itTest from : nil --> 10086
ointInside : 110 (0)
itTest from : nil --> 110
ointInside : 10010 (0)
itTest from : nil --> 10010
- 点112,111混合处
pointInside : 10086 (0)
hitTest from : nil --> 10086
pointInside : 110 (1)
pointInside : 112 (1)
hitTest from : 112 --> 112
hitTest from : 112 --> 110
4.3 响应链分析
- 发生在
rootView
上的所有点击事件,每次都会先询问10086
是否能处理。在视图层级树种,10086
是第二层最右边的节点; - 如果点没有落在
110
视图中,它的所有子视图都没有机会去响应事件; - 发生在
110
上的所有点击事件,每次都会先询问112
是否能处理。在视图层级树种,112
是第3层的最右边的节点;
事件响应的过程可以理解为N叉数的后续遍历
,不一样的是当找到响应者之后便终止遍历。
4.4 响应链函数分析
以上面分析过程中的6. 点111(superview范围内)
为例。
- 入栈:进入
110视图
的hitTest
方法,开始递归子视图; - 调用:判断点是否落在
110视图
中,输出pointInside : 110 (1)
; - 入栈:进入
112视图
的hitTest
方法,开始递归子视图; - 调用:判断点是否落在
112视图
中,输出pointInside : 112(0)
; - 出栈:退出
112视图
的hitTest
方法,返回nil; - 入栈:进入
111视图
的hitTest
方法,开始递归子视图; - 调用:判断点是否落在
111视图
中,输出pointInside : 111(1)
; - 出栈:退出
111视图
的hitTest
方法,返回111视图
; - 出栈:退出
110视图
的hitTest
方法,返回111视图
;
pointInside : 110 (1)
pointInside : 112 (0)
hitTest from : nil --> 112
pointInside : 111 (1)
hitTest from : 111 --> 111
hitTest from : 111 --> 110
4.5 响应链函数应用
- 扩大点击范围,重写
pointInside
,指定点位的新范围; - 透传点击事件,重写
pointInside
,返回NO; - 透传点击事件,重写
hitTest
,返回nil; - 拦截点击事件,重写
hitTest
,根据条件转发给指定视图;
下面这两个实现都是非常规的调用方式,若非万不得已,尽量不要去修改。他们都依赖响应链的递归顺序,而且在中途修改的递归顺序,会让问题难以排查。
- 显式转发:重写的方法中,显示的告诉调用者将事件转给了哪个视图;
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *aView = [super hitTest:point withEvent:event];
if (aView) {
NSLog(@"hitTest from : %ld --> %ld", aView.tag, self.tag);
} else {
NSLog(@"hitTest from : nil --> %ld", self.tag);
}
// 显式转发
aView = [self viewWithTag:10010];
return aView;
}
- 隐式转发:根据响应链的遍历过程,中途拦截某个过程。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
BOOL ret = [super pointInside:point withEvent:event];
NSLog(@"pointInside : %ld (%d)", (long)self.tag, ret);
// 隐式转发(拦截)
if (self.tag == 10086) {
return YES;
}
return ret;
}
hitTest被调用两次的issue
对于一次tap,hitTest会被调用两次。这个问题在Apple Mailing List Re: -hitTest:withEvent: called twice?里面有描述:
Yes, it’s normal. The system may tweak the point being hit tested between the calls. Since hitTest should be a pure function with no side-effects, this should be fine.
苹果告诉我们-hitTest:withEvent:
是一个纯函数,没有副作用。
怎么理解这段话呢?
- 这个函数功能单一,并没有调用其他逻辑的函数,不会对视图造成影响;
- 这个函数两次调用完成之后才会进入:
touchesBegan
和touchesEnded
,所以不会影响到我们的业务; - 在上一章的最后也有指出不要在这个方法中处理业务逻辑,正好与苹果解释的
pure funciton
相对应;
参考资料:
Cocoa Event Handling Guide
Using Responders and the Responder Chain to Handle Events
hitTest:withEvent:
pointInside:withEvent:
iOS中事件的响应链和传递链