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 )。寻找过程总结如下(默认情况下) :

  • 寻找顺序如下:
  1. 从视图层级最底层的 window 开始遍历它的子 View。
  2. 默认的遍历顺序是按照 UIView 中 Subviews 的逆顺序。
  3. 找到 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 的过程如下:

  1. 如果 View 的 userInteractionEnabled = NO,enabled = NO( UIControl ),或者 alpha <= 0.01, hidden = YES 直接返回 nil(不再往下判断)。
  2. 如果触摸点不在 view 中,直接返回 nil。
  3. 如果触摸点在 view 中,逆序遍历它的子 View ,重复上面的过程,如果子View没有subView了,那子View就是hit-TestView。
  4. 如果 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如下:

  1. UIView 的 c 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果当前 View 不是 ViewController 直接管理的 View,则 nextResponder 是它的 superView( view.nextResponder = view.superView )。
  2. UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
  3. UIWindow 的 nextResponder 是 UIApplication 。
  4. 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,来响应相关的事件。那怎么就是真正的响应者呢?

  1. 如果当前的 View 有添加手势,那么直接响应相应的事件,不会继续向下寻找了,因为手势比响应链拥有更高的优先级

  2. 如果没有手势事件,那么会看其是否实现了如下的方法:

    • (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");
}

如果出现

  • 5
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值