平时开发中经常会遇到子view超出父view时,超出的部分不会响应点击事件
原因就在于:iOS的事件响应机制
接下来简单了解一下事件的响应链和传递链
响应链流程
基本流程
大家都知道 iOS 的响应链是 UIApplication 收到用户触摸屏幕的事件以后通过逐层寻找最后得到用户触摸的 View 也就是第一响应者,然后调用 View 的 touchesBegan:withEvent:
方法处理事件任务的流程.大概流程是这样的:
图片很清晰的说明了查找流程 AppDelegate 收到事件逐层查找.最终找到 UIButton 这个响应者 然后调用 UIButton 的
touchesBegan:withEvent:
方法处理事件.
可以总结为一下几点
一个触摸事件的响应过程如下:
- 用户触摸屏幕时,UIKit会生成UIEvent对象来描述触摸事件。对象内部包含了触摸点坐标等信息。
- 通过Hit Test确定用户触摸的是哪一个UIView。这个步骤通过- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法来完成。
- 找到被触摸的UIView之后,如果它能够响应用户事件,相应的响应函数就会被调用。如果不能响应,就会沿着响应链(Responder Chain)寻找能够响应的UIResponder对象(UIView是UIResponder的子类)来响应触摸事件。
如何查找第一响应者
查找第一响应者主要涉及以下两个方法
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
系统会先执行- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
如果不在响应的point内的话直接返回 false,接下来- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
会直接返回nil
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 方法返回自身.
寻找 hit-TestView 的过程的总结
通过 hit-Testing 找到触摸点所在的 View( hit-TestView )。寻找过程总结如下(默认情况下) :
- 寻找顺序如下
- 从视图层级最底层的 window 开始遍历它的子 View。
- 默认的遍历顺序是按照 UIView 中 Subviews 的逆顺序。
- 找到 hit-TestView 之后,寻找过程就结束了。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (self.hidden || !self.userInteractionEnabled || self.alpha < 0.01 || ![self pointInside:point withEvent:event] || ![self _isAnimatedUserInteractionEnabled]) {
return nil;
} else {
// 判断触摸位置是否在当前视图内
if ([self pointInside:point withEvent:event]) {
NSArray * 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;
//for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
// UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
// if (hitView) {
// return hitView;
//}
} else {
return nil;
}
}
}
根据以上源码分析
确定一个 View 是不是 hit-TestView 的过程如下:
- 如果 View 的 userInteractionEnabled = NO,enabled = NO( UIControl ),或者 alpha <= 0.01, hidden = YES 直接返回 nil(不再往下判断)。
- 如果触摸点不在 view 中,直接返回 nil。
- 如果触摸点在 view 中,逆序遍历它的子 View ,重复上面的过程,如果子View没有subView了,那子View就是hit-TestView。
- 如果 view 的 子view 都返回 nil(都不是 hit-TestVeiw ),那么返回自身(自身是 hit-TestView )。
图来自 https://liangdahong.com/2018/06/08/2018/浅谈-iOS-事件的传递和响应过程/
看过这个,我们也就懂了为什么超出屏幕不能响应点击事件-----如果超出边界,UIKit无法根据这个触摸点找到父视图,自然也就无法找到子视图。寻找过程有点像递归,可以理解理解。
找到 hit-TestView 之后,事件就交给它来处理,hit-TestView 就是 firstResponder(第一响应者),如果它无法响应事件(不处理事件),则把事件交给它的 nextResponder(下一个响应者)
第一响应者在这几个方法中处理响应的事件,处理完成后根据需要调用 nextResponder 的 touch 方法,通常 nextResponder 就是第一响应者的 superView 文章的第一张图倒着看就是nextResponder 的顺序
nextResponder 过程如下:
当一个view被add到superView上的时候,他的nextResponder属性就会被指向它的superView,当controller被初始化的时候,self.view(topmost view)的nextResponder会被指向所在的controller,而controller的nextResponder会被指向self.view的superView,这样,整个app就通过nextResponder串成了一条链,也就是我们所说的响应链,他只是一条虚拟链,所以总结各个可响应者的nextResponder如下:
- UIView 的 c 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果当前 View 不是 ViewController 直接管理的 View,则 nextResponder 是它的 superView( view.nextResponder = view.superView )。
- UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
- UIWindow 的 nextResponder 是 UIApplication 。
- UIApplication 的 nextResponder 是 AppDelegate。
基于以上过程,那么上面寻找hit_testView例子的响应者链就是viewB.1-viewB-mainView-UIWindow
有了响应链,并且找到了第一个响应事件的对象,接下来就是把事件发送给这个响应者了。 UIApplication中有个sendEvent:的方法,在UIWindow中同样也可以发现一个同样的方法。UIApplication是通过这个方法把事件发送给UIWindow,然后UIWindow通过同样的API,把事件发送给hit-testview。
接下来我们看一下事件的传递(传递链)
找到第一响应者 application 便会根据 event 调用第一响应者响应的
touch 方法:
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet *)touches NS_AVAILABLE_IOS(9_1);
当我们点击了Button之后,UIWindow会通过一个私有方法,在里面会去调用按钮的touchesBegan和touchesEnded方法,touchesBegan里面有设置按钮的高亮等之类的动作,这样就实现了事件的传递。而事件的响应,也就是按钮上绑定的action,是在touchEnded里面通过调用UIApplication的sendAction:to:from:forEvent:方法来实现的,至于这个方法里面是怎么去响应action,就只能猜测了(可能是通过oc底层消息机制的相关接口
objc_msgSend
来发送消息实现的,可以参考message.h文件)。如果第一响应者没有响应这个事件,那么就会根据响应链,把事件冒泡传递给nextResponder来响应。
注意这里是怎么把事件传递给nextResponder的呢?拿touch事件来说,UIResponder里面touch四个阶段的方法里面,实际上是什么事都没有做的,UIView继承了它进行重写,重写的内容也是没有什么东西,就是把事件传递给nextResponder,UIView继承于 UIResponder,而UIResponder只是重写了touch 四个方法,真正的实现在UIControl里,可以看UIKit源码
UIControl->UiView->UIResponder->NSObject这个继承关系
UIResponder有touch的四个方法,而实现都在UIControl
UIResponder中touch方法实现如下,默认不做任何操作,只是沿着响应链传递事件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
[[self nextResponder] touchesBegan:touches withEvent:event];
}
所以当一个view或者controller里面没有重写touch事件,那么这个事件就会一直传递下去,直到UIApplication,这也就是事件往上冒泡的原理。如果view重写了touch方法,我们一般会看到的效果是,这个view响应了事件之后,事件就被截断了,它的nextResponder不会收到这个事件,即使重写了nextResponder的touch方法。这个时候如果想事件继续传递下去,可以调用[super
touchesBegan:touches withEvent:event],不建议直接调[self.nextResponder
touchesBegan:touches withEvent:event]。
注意这里是怎么把事件传递给nextResponder的呢?拿touch事件来说,UIResponder里面touch四个阶段的方法里面,实际上是什么事都没有做的,UIView继承了它进行重写,重写的内容也是没有什么东西,就是把事件传递给nextResponder,UIView继承于 UIResponder,而UIResponder只是重写了touch 四个方法,真正的实现在UIControl里,可以看UIKit源码
UIControl->UiView->UIResponder->NSObject这个继承关系
UIResponder有touch的四个方法,而实现都在UIControl
UIResponder中touch方法实现如下,默认不做任何操作,只是沿着响应链传递事件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
[[self nextResponder] touchesBegan:touches withEvent:event];
}
刚才提到UIControl
在.h中能看到特别像touch四个方法的四个方法Tracking系列
- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
- (void)cancelTrackingWithEvent:(UIEvent *)event;
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
_touchInside = YES;
_tracking = [self beginTrackingWithTouch:touch withEvent:event];
self.highlighted = YES;
if (_tracking) {
UIControlEvents currentEvents = UIControlEventTouchDown;
if (touch.tapCount > 1) {
currentEvents |= UIControlEventTouchDownRepeat;
}
[self _sendActionsForControlEvents:currentEvents withEvent:event];
}
}
根据上面的源码看到 touch内部调用了Tracking一系列的 touch 方法
如果你点击UIButton的响应事件的话,打个断点,看到方法调用
就直接是UIApplication 调用sendAction方法,相当于直接到链头了吧
首先划重点:所有响应者的基类都是 UIResponder,UIApplication/UIView/UIViewController 都是 UIResponder 的子类
寻找响应者过程如下:
当我们知道最合适的 View 后,事件会 由上向下【子view -> 父view,控制器view -> 控制器】来找出合适响应事件的 View,来响应相关的事件。那怎么就是真正的响应者呢?
如果当前的 View 有添加手势,那么直接响应相应的事件,不会继续向下寻找了,因为手势比响应链拥有更高的优先级
如果没有手势事件,那么会看其是否实现了如下的方法:
(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
经过验证一二步是并行的,甚至touchBegin响应更早一丢丢,所以只要有以上两个之一实现,都会阻断响应链传递
举例
蓝色圆加在红色方块上,重叠部分如果想响应红色块的点击事件,应该怎么办呢
- 重写蓝色圆的hiteTest
//self.btn为红色块
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *view = [super hitTest:point withEvent:event];
//蓝色的点转换给红色
CGPoint redBtnPoint = [self convertPoint:point toView:self.btn];
if([self.btn pointInside:redBtnPoint withEvent:event]) {
return _btn;
}
return view;
}
事件拦截
通常第一响应者都是响应链中最末端的响应者,事件拦截就是在响应链中截获事件,停止下发.将事件交由中间的某个响应者执行.比如这样:
通常点击红色 view 事件将交由 红色 view 处理.如果想让粉色 View 或者绿色 view 处理事件应该怎么办?
有两种办法
- 在红色 view 的的 touch 方法中调用父类或者 nextResponder 的
touch 方法 - 在需要拦截的 view 中重写 hitTest 方法改变第一响应者
首先来看第一种
- (void)touchesBegan:(NSSet *)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 * 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 方法转发事件.原理相同的东西就不再写了
参考地址https://www.jianshu.com/p/db3518be5ebb
https://blog.csdn.net/qiangshuting/article/details/90317936