iOS 事件传递&响应链
往往在子view超出父view时,超出的部分不会响应点击事件
原因就在于:
iOS的事件响应机制
一个触摸事件的响应过程如下:
- 用户触摸屏幕时,UIKit会生成UIEvent对象来描述触摸事件。对象内部包含了触摸点坐标等信息。
- 通过Hit Test确定用户触摸的是哪一个UIView。这个步骤通过- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法来完成。
- 找到被触摸的UIView之后,如果它能够响应用户事件,相应的响应函数就会被调用。如果不能响应,就会沿着响应链(Responder Chain)寻找能够响应的UIResponder对象(UIView是UIResponder的子类)来响应触摸事件。
寻找 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 {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
if (hitView) {
return hitView;
}
}
return self;
}
}
根据以上源码分析
确定一个 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无法根据这个触摸点找到父视图,自然也就无法找到子视图。
寻找过程有点像递归,可以理解理解。
其中通过这个方法看触摸点是否在view 中:
-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
返回视图是否包含指定的某个点
找到 hit-TestView 之后,事件就交给它来处理,hit-TestView 就是 firstResponder(第一响应者),如果它无法响应事件(不处理事件),则把事件交给它的 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通过同样的接口,把事件发送给hit-testview。
当我点击了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]。
刚才提到UIControl
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系列方法
如果你点击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响应更早一丢丢,所以只要有以上两个之一实现,都会阻断响应链传递
如果有实现那么就由此 View 响应,如果两个都没有实现,那么就会传递给他的下一个响应者【子view -> 父view,控制器view -> 控制器】, 这里我们可以做一个简单的验证,在默认情况下 UIView 是不响应事件的,UIControl 就算没有添加手势一样的会由他来响应, 这里可以使用 runtime查看 UIView 和 UIControl 的方法列表, 或 查看 UIKit 源码 可知, UIView 没有实现如上的 touchesBegan方法,而 UIControl 是实现了如上的相关方法,所以验证了刚才的 UIView 不响应,和 UIControl 的响应。一旦找到最合适响应的View就结束, 在执行响应的绑定的事件,如果没有就抛弃此事件。
所以是UIButton(继承于UIControl)就会直接响应点击事件
举例
蓝色圆加在红色方块上,重叠部分如果想响应红色块的点击事件,应该怎么办呢
- 重写蓝色圆的hitTest方法
//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;
}
完整代码
@interface UICustomButton : UIButton
@property (nonatomic,strong) UIButton *btn;
@end
@implementation UICustomButton
- (instancetype)initWithFrame:(CGRect)frame {
if(self = [super initWithFrame:frame]) {
self.layer.masksToBounds = YES;
self.layer.cornerRadius = frame.size.width / 2;
}
return self;
}
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *view = [super hitTest:point withEvent:event];
//蓝色的点转换给红色,
CGPoint redBtnPoint = [self convertPoint:point toView:self.btn];
//将像素point由point所在视图转换到目标视图view中,返回在目标视图view中的像素值
if([self.btn pointInside:redBtnPoint withEvent:event]) {
//返回红色btn为hitTestView
return _btn;
}
return view;
}
@end
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
_btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
_btn1.frame = CGRectMake(20, 20, 100, 100);
_btn1.backgroundColor = [UIColor redColor];
[self.view addSubview:_btn1];
[_btn1 addTarget:self action:@selector(clickBtn1) forControlEvents:UIControlEventTouchUpInside];
UICustomButton *btn2 = [[UICustomButton alloc] initWithFrame:CGRectMake(80, 80, 100, 100)];
btn2.backgroundColor = [UIColor blueColor];
[btn2 addTarget:self action:@selector(clickBtn2) forControlEvents:UIControlEventTouchUpInside];
btn2.btn = _btn1;
[self.view addSubview:btn2];
// Do any additional setup after loading the view, typically from a nib.
}
- (void)clickBtn1 {
NSLog(@"按钮1");
}
- (void)clickBtn2 {
NSLog(@"按钮2");
}
如果出现
上一篇: 使用lombok简化你的代码
下一篇: 热备份 配置vrrp多备份组