响应链流程
基本流程
大家都知道 iOS 的响应链是 UIApplication 收到用户触摸屏幕的事件以后通过逐层寻找最后得到用户触摸的 View 也就是第一响应者,然后调用 View 的 touchesBegan:withEvent:
方法处理事件任务的流程.大概流程是这样的:
图片很清晰的说明了查找流程 AppDelegate 收到事件逐层查找.最终找到 UIButton 这个响应者 然后调用 UIButton 的touchesBegan:withEvent:
方法处理事件.
如何查找第一响应者
查找第一响应者主要涉及以下两个方法
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
pointInside:
通过 point 参数确定触碰点是否在当前 View 的响应范围内 是则返回YES 否则返回 NO 实现方法大概是这个样子的
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
return CGRectContainsPoint(self.bounds, point);
}
hitTest方法:
- 它首先会通过调用自身的 pointInside 方法判断用户触摸的点是否在当前对象的响应范围内,如果 pointInside 方法返回 NO hitTest方法直接返回 nil
- 如果 pointInside 方法返回 YES hitTest方法接着会判断自身是否有子视图.如果有则调用顶层子视图的 hitTest 方法 直到有子视图返回 View
- 如果所有子视图都返回 nil hitTest 方法返回自身.
hitTest方法的内部实现伪代码
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
// 判断触摸位置是否在当前视图内
if ([self pointInside:point withEvent:event]) {
NSArray<UIView *> * superViews = self.subviews;
// 倒序 从最上面的一个视图开始查找
for (NSUInteger i = superViews.count; i > 0; i--) {
UIView * subview = superViews[i - 1];
// 转换坐标系 使坐标基于子视图
CGPoint newPoint = [self convertPoint:point toView:subview];
// 得到子视图 hitTest 方法返回的值
UIView * view = [subview hitTest:newPoint withEvent:event];
// 如果子视图返回一个view 就直接返回 不在继续遍历
if (view) {
return view;
}
}
// 所有子视图都没有返回 则返回自身
return self;
}
return nil;
}
事件传递
找到第一响应者 application 便会根据 event 调用第一响应者响应的
touch 方法:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);
第一响应者在这几个方法中处理响应的事件,处理完成后根据需要调用 nextResponder 的 touch 方法,通常 nextResponder 就是第一响应者的 superView 文章的第一张图倒着看就是nextResponder 的顺序
事件拦截
通常第一响应者都是响应链中最末端的响应者,事件拦截就是在响应链中截获事件,停止下发.将事件交由中间的某个响应者执行.比如这样:
通常点击红色 view 事件将交由 红色 view 处理.如果想让粉色 View 或者绿色 view 处理事件应该怎么办?
有两种办法
- 在红色 view 的的 touch 方法中调用父类或者 nextResponder 的
touch 方法 - 在需要拦截的 view 中重写 hitTest 方法改变第一响应者
首先来看第一种
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
// 将事件传递给下一响应者
[self.nextResponder touchesBegan:touches withEvent:event];
// 调用父类的touch方法 和上面的方法效果一样 这两句只需要其中一句
[super touchesBegan:touches withEvent:event];
}
这种方法有两个问题,你需要重写所有的 touch 方法并且还要重写要拦截事件的 view 与顶级 view 之间的所有 view 的 touch 方法
第二种方法
重写拦截事件的 view 的 hitTest 方法 比如要让绿色的 view 处理事件 就重写绿色 view 的 hitTest 方法
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
// 如果在当前 view 中 直接返回 self 这样自身就成为了第一响应者 subViews 不再能够接受到响应事件
if ([self pointInside:point withEvent:event]) {
return self;
}
return nil;
}
这种方法比较简单粗暴.实现后 所有 subview 将不再能够接受任何事件 具体使用那种方式看需求.当然还可以通过 event 或者 point 有针对性的拦截
事件转发
有时候还需要将事件转发出去.让本来不能响应事件的 view 响应事件,最常用的场景就是让子视图超出父视图的部分也能响应事件,比如要实现这样的 tabbar
橙色按钮有两个区域 a 区超出父视图 b 区没有超出父视图,如果不作处理,那么点击 a 区是无法响应事件的,因为 a 区域的坐标不在父视图的范围内,当执行到父视图的 pointInside 的时候就会返回 NO
想要让 a 区响应事件 就需要重写父视图的 pointInside 或 hitTest 方法让 pointInside 返回 YES 或 让hitTest 直接返回橙色视图
重写hitTest
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
// 触摸点在视图范围内 则交由父类处理
if ([self pointInside:point withEvent:event]) {
return [super hitTest:point withEvent:event];
}
// 如果触摸点不在范围内 而在子视图范围内依旧返回子视图
NSArray<UIView *> * superViews = self.subviews;
// 倒序 从最上面的一个视图开始查找
for (NSUInteger i = superViews.count; i > 0; i--) {
UIView * subview = superViews[i - 1];
// 转换坐标系 使坐标基于子视图
CGPoint newPoint = [self convertPoint:point toView:subview];
// 得到子视图 hitTest 方法返回的值
UIView * view = [subview hitTest:newPoint withEvent:event];
// 如果子视图返回一个view 就直接返回 不在继续遍历
if (view) {
return view;
}
}
return nil;
}
重写 pointInside 方法原理相同 重点注意转换坐标系 就算他们不是一条响应链上 也可以通过重写 hitTest 方法转发事件.原理相同的东西就不再写了
扩展
关于手势的处理逻辑和这个相同.但是手势的优先级更高.如果父视图有手势.默认优先处理手势事件 可以修改手势的属性cancelsTouchesInView
为 NO 来同时处理手势和普通触摸事件