当发生触摸事件后,系统会将该事件加入到一个由 UIApplication 管理的事件队列中。
UIApplication 会从事件队列的最前面取出事件,并将此事件分发出去。
通常,事件会最先传递到 UIWindow (keyWindow),主窗口会在其视图层次结构中,找到一个最合适的视图来处理这次触摸事件,这个寻找的过程就叫做事件传递。
事件传递
传递过程实例
前提,任何一个在屏幕上触摸事件,都会首先被 UIApplication 接管,并存储在事件队列中。
然后 UIApplication 从事件队列中,取出最前面的那个事件,开始从 keyWindow 往下分发。
如果点击了 1
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1
如果点击了2
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 2
如果点击了3
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 3
如果点击了4
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 4
如果点击了5上半部分
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 5
如果点击了5下半部分
手指触摸 -> UIApplicationEventQueue -> keyWindow -> 1 -> 5
但是5的点击的坐标点,并不在它的父视图范围,于是此次触摸就无效。无法触发
一张事件分发消息队列调用堆栈
可以证明,事件确实是从 UIApplication -> window -> 目标视图 这条路径的。
手势的判断路径
前提:基本上99%的页面布局,从我们可以知道的事件传递开始。都是从一个 ViewController 的 rootView 开始的。
那么我们就把这个 rootView 最为最底层的 View 也未尝不可。
- 当用户在某个 App 的页面点击了屏幕,会产生一个事件。
- 当前 App 会通过用户的这个物理点击,把这个事件放到 UIApplicationEventQueue 里面。
- 从当前事件队列中,拿出一个事件开始往上传递。
- 这里的传递从当前控制器的 rootView 开始。
- 首先判断,在此 rootView 上是否有子 View。
- 如果没有,那么就是当前的 rootView 执行这个事件
- (void)touchesBegan:(NSSet
也就是我们常写的这个方法。*)touches withEvent:(UIEvent *)event - 如果
self.view.subviews.count>0
,那么就开始倒序的遍历一级父视图。 - 并在父视图里面,递归调用
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
。 来找到最合适的视图来处理此次的触摸事件。
有几个问题需要解释一下:
- 为什么要倒序?
个人猜测:因为后加的 view 按照层级来算的话,绝大部分情况下都是会处于当前视图层次结构的最顶层。它们才是用户最能够直接点击的 view。所以,倒序在这里是很符合真实情况的。
- 为什么子视图关闭了交互,父亲视图仍然可以处理事件?
因为事件的传递是从下到上了,直接父视图的 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
不返回 nil 了,子视图才有机会调用自己的 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
。也就是说,不管子视图是否能处理这次事件,事件都是从父视图传递过来的。所以,即使子视图无法处理了,事件流仍然会在父视图上执行。
- (UIView )hitTest:(CGPoint)point withEvent:(UIEvent )event基本逻辑如下。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// 1. 判断当前视图是否可以交互。
// 如果当前视图无法交互,事件传递信号在这里就被终止了。
// 从而影响此视图内的所有子视图,都将变成无法交互的状态。
if (self.userInteractionEnabled == NO || self.alpha < 0.01 || self.hidden == YES) return nil;
// 2. 判断当前的触摸点是否在当前视图的 bounds 里。
if (![self pointInside:point withEvent:event]) return nil;
// 3. 达到这一步,就说明,当前视图开启了交互,且触摸点在这个视图里。
for (UIView *view in self.subviews) {
// 4. 转换点坐标,把父亲视图里的 point 转换为相对于当前 subView 视图的坐标系的点
CGPoint p2 = [self convertPoint:point toView:view];
// 开始循环递归,找到当前(self)最合适处理这个事件的子视图。
UIView *fitView = [super hitTest:p2 withEvent:event];
if (fitView) {
return fitView;
}
}
// 否则返回自己,来处理这个事件。
/**
情况有2种
1. 当前 self 中,不包含任何子视图
2. 当前 self 中,子视图都不能被交互 (userInteractionEnabled == NO || Aplha < 0.01 || hidden == YES)
*/
return self;
}
那么知道了,事件传递的基本逻辑,我们可以尝试一下,解决一些 App 开发过程中常见的由于可以使用事件传递机制来解决的问题。
一、 子视图超出了父视图的范围导致无法点击。
按照正常情况下,当我们点击红色区域的时候,黄色的 view 是无法响应用户触摸事件的。
原因是:
- 当我们点击了红色的区域,随即产生了一个触摸事件 event。
- 当前 event 被添加到 UIApplicationEventQueue 中。
- 从当前队列中,拿出这个事件,并找到 rootView.subViews.
- 然后开始倒序的对这个 view 开始执行
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event` 。
- 由于,除了 rootView 外,只有一个底层视图,也就是 grayView。
- 于是,
hitTest
就开始测试 grayView 了。 - 由于,点击的区域是红色的部分。
- 在 grayView 的
hitTest
函数中,虽然第一行,是否可以交互判断通过了,但是第二行的pointInside
却返回 nil 了。 - 返回 nil 的话,后续的对子视图的递归
hitTest
就不会执行。
所以,这里点击红色区域的地方,导致黄色视图无法触发事件的原因是,当前点击的点,不在黄色视图的父视图 grayView 里面。
解决办法:
既然,点不在父视图的范围里面。那么父视图 hitTest
肯定就返回 nil 了。
重写父视图的 hitTest
,当返回 nil 的时候,让他也去遍历他的子视图控件。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *view = [super hitTest:point withEvent:event];
// 返回 nil 了,无法就是点不在你的范围,但并不代表点不在你的子视图范围内。
if (!view) {
for (UIView *subView in self.subviews) {
CGPoint p = [self convertPoint:point toView:subView];
if (CGRectContainsPoint(subView.bounds, p)) {
return subView;
}
}
}
return view;
}
效果截图:
二、扩大子视图的可点击范围。
场景:有时候,某些可点击的范围太小了,用户不是很好点击。
造成不好点击的原因
- 首先时间肯定是由后面那个灰色的 view 传递过来的。
- 由于绿色的按钮太小了,虽然它的 hitTest 进去了,但是 pointInside 老是判断不在当前绿色 view 的 bounds,返回 nil。从而无法触发用户事件。
解决思路
重写绿色 view 的 pointInSide
方法,扩大可点击的范围。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
/*
上下左右个大20个点。
*/
// 横向距离扩大20个 pt
CGFloat minX = -20;
CGFloat maxX = self.bounds.size.width + 20;
// 纵向距离扩大20个 pt
CGFloat minY = -20;
CGFloat maxY = self.bounds.size.height + 20;
// 只要点击的点在这么个范围,就算是点到了这个按钮
if ((point.x > minX && point.x < maxX) &&
(point.y > minY && point.y < maxY)
) {
return YES;
}
return NO;
}
运行效果:
三、穿透。(有这种需求吗?我不知道。。反正可以先搞个这么个 demo,理一下思路,防止后期可能用到)
其实穿透这种做法,很常见。
我们在一个 view 上,套一个 UIImageView,然后点击 UIImageView 的时候,让 view 来执行此次事件。
这很简单,原理是 UIImageView 默认是无法交互的。hitTest 从 view 到 UIImageView。imageView 返回 nil 了。
自然而然的 view 就执行这个触摸事件了。
当然,如果 view 上套的子视图也开启了交互了?
解决办法就两种:
- 把子视图的
.userInteractionEnabled = NO;
- 第二种,重写子视图的
hitTest
orpointInside
前者返回 nil,或者返回 NO 也可以。
//// 解决方法一
//- (void)awakeFromNib {
// [super awakeFromNib];
// self.userInteractionEnabled = NO;
//}
// 解决方法二:可以重写此 view 的 hitTest 直接返回 nil,让递归进行到后面的GreenView 上去
//- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// return nil;
//}
// 解决办法三、重写 pointInside 返回 NO
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return NO;
}
推荐第一个种写法,本质上也是在 hitTest 方法里,第一行就返回 nil 了。
运行结果:
DEMO github地址