iOS事件传递和响应链

前言

看关于这方面的文章基本没有能涉及到UIGestureRecognizers相关的文章,因此决定写这样一篇文章。也是我的第一篇文章,如有什么不对请及时指正。
本文主要通过一些实际测试来便于大家理解。

正文

事件传递和响应链流程图.png
  • IOKit.framework 为系统内核的库
  • SpringBoard.app 相当于手机的桌面
  • Source1 主要接收系统的消息
  • Source0 - UIApplication - UIWindow
  • 从window开始系统会调用hitTest:withEvent:pointInside来找到最优响应者,具体过程可参考下图:
    hitTest.png
  • 比如我们在self.view 上依次添加view1、view2、view3(3个view是同级关系),那么系统用hitTest以及pointInside时会先从view3开始便利,如果pointInside返回YES就继续遍历view3的subviews(如果view3没有子视图,那么会返回view3),如果pointInside返回NO就开始遍历view2。反序遍历,最后一个添加的subview开始。也算是一种算法优化。后面会具体介绍hitTest的内部实现和具体使用场景。
  • UITouch会给gestureRecognizers和最优响应者也就是hitTestView发送消息
    • 默认view会走其touchBegan:withEvent:等方法,当gestureRecognizers找到识别的gestureRecognizer后,将会独自占有该touch,即会调用其他gestureRecognizer和hitTest view的touchCancelled:withEvent:方法,并且它们不再收到该touche事件,也就不会走响应链流程。下面会具体阐述UIContol和UIScrollView和其子类与手势之间的冲突和关系。
  • 当该事件响应完毕,主线程的Runloop开始睡眠,等待下一个事件。

1.hitTest:withEvent:和pointInside

1.1 hitTest:withEvent:和pointInside 演练

  • 测试hitTest和pointInside执行过程

    GSGrayView *grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    [self.view addSubview:grayView];
    
    GSRedView *redView = [[GSRedView alloc] initWithFrame:CGRectMake(0, 0, grayView.bounds.size.width / 2, grayView.bounds.size.height / 3)];
    [grayView addSubview:redView];
    
    GSBlueView *blueView = [[GSBlueView alloc] initWithFrame:CGRectMake(grayView.bounds.size.width/2, grayView.bounds.size.height * 2/3, grayView.bounds.size.width/2, grayView.bounds.size.height/3)];
    
    // blueView.userInteractionEnabled = NO;
    // blueView.hidden = YES;
    // blueView.alpha = 0.1;//0.0;
    [grayView addSubview:blueView];
    
    GSYellowView *yellowView = [[GSYellowView alloc] initWithFrame:CGRectMake(CGRectGetMinX(grayView.frame), CGRectGetMaxY(grayView.frame) + 20, grayView.bounds.size.width, 100)];
    [self.view addSubview:yellowView];
    
    hitTest测试.png

    点击redView:
    yellowView -> grayView -> blueView -> redView


    hitTest测试结果.png
  • 当点击redView时,因为yellowView和grayView同级,yellowView比grayView后添加,所以先打印yellowView,由于触摸点不在yellowView中因此打印grayView,然后遍历grayView的subViews分别打印blueView和redView。

  • 当hitTest返回nil时,也不会打印pointInside。因此可以得出pointInside是在hitTest后面执行的。

  • 当view的userInteractionEnabled为NO、hidden为YES或alpha<=0.1时,也不会打印pointInside方法。因此可以推断出在hitTest方法内部会判断如果这些条件一个成立则会返回nil,也不会调用pointInside方法。

  • 如果在grayView的hitTest返回[super hitTest:point event:event],则会执行gery.subviews的遍历(subviews 的 hitTest 与 pointInside),grayView的pointInside是判断触摸点是否在grayView的bounds内,grayView的hitTest是判断是否需要遍历他的subviews.

  • pointInside只是在执行hitTest时,会在hitTest内部调用的一个方法。也就是说pointInside是hitTest的辅助方法。

  • hitTest是一个递归函数

1.2 hitTest:withEvent:内部实现代码还原

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"-----%@",self.nextResponder.class);
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) return nil;
    //判断点在不在这个视图里
    if ([self pointInside:point withEvent:event]) {
        //在这个视图 遍历该视图的子视图
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            //转换坐标到子视图
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            //递归调用hitTest:withEvent继续判断
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                //在这里打印self.class可以看到递归返回的顺序。
                return hitTestView;
            }
        }
        //这里就是该视图没有子视图了 点在该视图中,所以直接返回本身,上面的hitTestView就是这个。
        NSLog(@"命中的view:%@",self.class);
        return self;
    }
    //不在这个视图直接返回nil
    return nil;
}

1.3 pointInside运用:增大热区范围

  • 在开发过程中难免会遇到需要增大UIButton等的热区范围,假如UIButton的布局不允许修改,那么就需要用到pointInside来增大UIButton的点击热区范围。具体实现代码如下:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    
    NSLog(@"%@ -- pointInside",self.class);
    CGRect bounds = self.bounds;
    //若原热区小于200x200,则放大热区,否则保持原大小不变
    //一般热区范围为40x40 ,此处200是为了便于检测
    CGFloat widthDelta = MAX(200 - bounds.size.width, 0);
    CGFloat heightDelta = MAX(200 - bounds.size.height, 0);
    bounds = CGRectInset(bounds, -0.5 * widthDelta, -0.5 * heightDelta);
    return CGRectContainsPoint(bounds, point);
    
}
  • 也就是说如果button的size小于200*200,则点击button相对中心位置上下左右各100的范围内即使超出button,也可以响应点击事件

2.响应链

2.1 响应链的组成

响应链.png

还用上面那个栗子:
点击redView:
redview -> grayView -> viewController -> ...


touches事件测试结果.png

因为只实现到controller的touches事件方法因此只打印到Controller。

  • 响应链是通过nextResponder属性组成的一个链表。
    • 点击的view有 superView,nextResponder就是superView;
    • view.nextResponder.nextResponder是viewController 或者是 view.superView. view
    • view.nextResponder.nextResponder.nextResponder是 UIWindow (非严谨,便于理解)
    • view.nextResponder.nextResponder.nextResponder. nextResponder是UIApplication、UIAppdelate、直到nil (非严谨,便于理解)
  • touch事件就是根据响应链的关系来层层调用(我们重写touch 要记得 super 调用,不然响应链会中断)。
  • 比如我们监听self.view的touch事件,也是因为subviews的touch都在同一个响应链里。

2.2 UIControl阻断响应链

把上面栗子中的grayView替换成一个Button:

    GSExpandButton *expandButton = [[GSExpandButton alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    expandButton.backgroundColor = [UIColor lightGrayColor];
    [expandButton setTitle:@"点我啊" forState:UIControlStateNormal];
    [expandButton addTarget:self action:@selector(expandButtonClick:) forControlEvents:UIControlEventTouchDown];
    [self.view addSubview:expandButton];
    
    self.redView = [[GSRedView alloc] initWithFrame:CGRectMake(0, 0, expandButton.bounds.size.width / 2, expandButton.bounds.size.height / 3)];
    [expandButton addSubview:self.redView];
    
    self.blueView = [[GSBlueView alloc] initWithFrame:CGRectMake(expandButton.bounds.size.width/2, expandButton.bounds.size.height * 2/3, expandButton.bounds.size.width/2, expandButton.bounds.size.height/3)];
    
    //    blueView.userInteractionEnabled = NO;
    //    blueView.hidden = YES;
    //    blueView.alpha = 0.1;//0.0;
    [expandButton addSubview:self.blueView];
    
    self.yellowView = [[GSYellowView alloc] initWithFrame:CGRectMake(CGRectGetMinX(expandButton.frame), CGRectGetMaxY(expandButton.frame) + 20, expandButton.bounds.size.width, 100)];
    [self.view addSubview:self.yellowView];

点击redView:
redview -> expandButton


UIControl阻断touches事件传递测试结果.png
  • 虽然点击redView,虽然button的touches事件方法也走了但是依然不会响应button的target的action方法,只是会传递到button而已,因为最佳响应着依然是redView。
  • 从上面测试结果可以看出,UIControl会阻断响应链的传递,也就是说在响应UIContol的touches事件时并不会调用nextResponder的对应的方法。
  • 通过在Button子类中重写touches的方法,发现如果不调用super的touches对应的方法则不会响应点击事件。由此可以大致推断出UIControl其子类响应点击原理大致为:根据添加target:action:时设置的UIControlEvents,在touches的合适方法调用target的action方法。

2.3UIScrollView阻断响应链

self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth, kScreenHeight * 3 / 4)];
    [self.view addSubview:self.grayView];
    
    self.tableView = [[GSTableView alloc] initWithFrame:CGRectMake(0, 20, kScreenWidth, self.grayView.bounds.size.height / 2)];
    self.tableView.dataSource = self;
    self.tableView.backgroundColor = [UIColor darkGrayColor];
    self.tableView.delegate = self;
    [self.grayView addSubview:self.tableView];
    
    self.redView = [[GSRedView alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth/2, self.tableView.bounds.size.height/2)];
    [self.tableView addSubview:self.redView];
UIScrollView阻断响应链.png

点击redview
redview -> tableView


UIScrollView阻断响应链结果.png
  • 从上面测试结果可以得出,UIScrollView也会阻断响应链,也就是说在响应UIScrollView自身对touch的处理方式并不会调用nextResponder对应的方法。
  • 通过重写tableView子类的touches方法,发现如果不调用super的touches对应的方法则不会走tableview:didSelectRowAtIndexPath:方法。由此可以大致推断出UIScrollView其子类是在其touches方法中处理点击事件的。

3.手势

3.1手势的探索以及和touch事件的关系

在上面栗子中的view增加gestureRecognizer:

- (void)addGesture {
    GSGrayGestureRecognizer *grayGesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:grayGesture];
    
    GSRedGestureRecognizer *redGesture = [[GSRedGestureRecognizer alloc] initWithTarget:self action:@selector(redViewClick:)];
    [self.redView addGestureRecognizer:redGesture];
    
    GSBlueGestureRecognizer *blueGesture = [[GSBlueGestureRecognizer alloc] initWithTarget:self action:@selector(blueViewClick:)];
    [self.blueView addGestureRecognizer:blueGesture];
}

点击redView
打印结果如下图所示:


gesture响应结果.png
  • 当通过hitTest和pointInside找到最优响应者后,会给gestureRecognizers和相应的view同时发送touchBegin消息,如果找到合适gestureRecognizer则会独有该touches,即调用view的touheCancel消息,接着有gestreRecognizer来响应事件。
  • 上面为默认情况下手势和touches之间的关系,其实我们可以通过gestureRecognizer的属性来控制它们之间的一些关系。
// default is YES. causes touchesCancelled:withEvent: or pressesCancelled:withEvent: to be sent to the view for all touches or presses recognized as part of this gesture immediately before the action method is called.
@property(nonatomic) BOOL cancelsTouchesInView; 

// default is NO.  causes all touch or press events to be delivered to the target view only after this gesture has failed recognition. set to YES to prevent views from processing any touches or presses that may be recognized as part of this gesture      
@property(nonatomic) BOOL delaysTouchesBegan;         

 // default is YES. causes touchesEnded or pressesEnded events to be delivered to the target view only after this gesture has failed recognition. this ensures that a touch or press that is part of the gesture can be cancelled if the gesture is recognized
@property(nonatomic) BOOL delaysTouchesEnded;        
  • cancelsTouchesInView:默认为YES。表示当手势识别成功后,取消最佳响应者对象对于事件的响应,并不再向最佳响应者发送事件。若设置为No,则表示在手势识别器识别成功后仍然向最佳响应者发送事件,最佳响应者仍响应事件。
  • delaysTouchesBegan:默认为NO,即在手势识别器识别手势期间,触摸对象状态发生变化时,都会发送给最佳响应者,若设置成yes,则在识别手势期间,触摸状态发生变化时不会发送给最佳响应者。
  • delaysTouchesEnded:默认为NO。默认情况下当手势识别器未能识别手势时,若此时触摸已经结束,则会立即通知Application发送状态为end的touch事件给最佳响应者以调用 touchesEnded:withEvent: 结束事件响应;若设置为YES,则会在手势识别失败时,延迟一小段时间(0.15s)再调用响应者的 touchesEnded:withEvent:。

3.2手势和UIControl的关系

  • 上面已经说了UIContol会阻断响应链。那么我们再来进一步探索UIControl的阻断和手势之间的关系。
// button在上面
    self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    GSGrayGestureRecognizer *graygesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:graygesture];
    [self.view addSubview:self.grayView];
    
    GSExpandButton *expandButton = [[GSExpandButton alloc] initWithFrame:CGRectMake(30, kTopHeight, 100, 100)];
    expandButton.backgroundColor = [UIColor redColor];
    [expandButton setTitle:@"点我啊" forState:UIControlStateNormal];
    [expandButton addTarget:self action:@selector(expandButtonClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.grayView addSubview:expandButton];
  ![手势和UIControl的关系.png](https://upload-images.jianshu.io/upload_images/2452209-1016b9c7d7cbc1c8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

点击button


button上gesture下点击结果.png
  • 从该栗子中可以看出即使下层view添加收拾依然会响应按钮的点击事件。

  • 由此可以猜测原因:

    1. UIControl及其子类会阻断响应链。(后面验证是错误的)
    2. UIControl及其子类为最优响应者时会优先处理它们的事件。(后面验证成功)
    • 验证猜测一:
    • 有手势的view上增加一个阻断响应链的view
    self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    GSGrayGestureRecognizer *graygesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:graygesture];
    [self.view addSubview:self.grayView];
    GSCancelledTouchView *cancelTouchView = [[GSCancelledTouchView alloc] initWithFrame:CGRectMake(30, kTopHeight, 100, 100)];
    [self.grayView addSubview:cancelTouchView];
    
UIControl验证一.png

点击greenView


UIControl验证一结果.png
  • greenView是一个阻断响应链的view(即重新超类touches方法没用调用超类方法),但是依然响应gestureRecognizer的target:action:方法,并且调用touches事件的toucesCancelled的方法。因此猜测1是错误的。

  • 验证猜测二:

  • 有收拾的view上增加一个button,button上增加一个view

        // 验证不取消button的touches事件猜测二
    self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth/2, 400)];
    GSGrayGestureRecognizer *graygesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    [self.grayView addGestureRecognizer:graygesture];
    [self.view addSubview:self.grayView];
    
    GSExpandButton *expandButton = [[GSExpandButton alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth/3, 200)];
    expandButton.backgroundColor = [UIColor redColor];
    [expandButton setTitle:@"点我啊" forState:UIControlStateNormal];
    [expandButton addTarget:self action:@selector(expandButtonClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.grayView addSubview:expandButton];
    
    
    self.blueView = [[GSBlueView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
    [expandButton addSubview:self.blueView];
    
    UIControl验证二.png

    点击blueView


    UIControl验证二结果.png
    • 点击blueview虽然expandButton会阻断响应链但是依然会执行在grayview上的手势方法并且会调用touchesCancelled方法,因此可以验证猜想二是正确的。
    • 把grayview上的gestureRecognizer去掉,依然不会响应expandButton上的点击事件,因为最优响应者不是expandButton。
  • UIControl及其子类能够执行点击事件而不是走底层的手势的原因为:在识别到相应的gestureRecognizer后如果当前的最优响应者是UIControl及其子类并且当前的gestureRecognizer不是UIContol上的手势,则会响应UIControl的target:action:的方法。否则则会响应gestureRecognizer的target:action:的方法。

3.3 手势和UIScrollView的关系

  • UITableView是UIScroll子类的常用类,因此拿UITableView来举栗子。
self.grayView = [[GSGrayView alloc] initWithFrame:CGRectMake(0, kTopHeight, kScreenWidth, kScreenHeight * 3 / 4)];
    GSGrayGestureRecognizer *grayGesture = [[GSGrayGestureRecognizer alloc] initWithTarget:self action:@selector(grayViewClick:)];
    //    grayGesture.delaysTouchesBegan = YES;
    //    grayGesture.cancelsTouchesInView = NO;
    //    grayGesture.delaysTouchesEnded = YES;
    [self.grayView addGestureRecognizer:grayGesture];
    [self.view addSubview:self.grayView];
    
    self.tableView = [[GSTableView alloc] initWithFrame:CGRectMake(0, 20, kScreenWidth, self.grayView.bounds.size.height / 2)];
    self.tableView.dataSource = self;
    self.tableView.backgroundColor = [UIColor darkGrayColor];
    self.tableView.delegate = self;
    [self.grayView addSubview:self.tableView];
  ![UIScrollView点击事件探索.png](https://upload-images.jianshu.io/upload_images/2452209-6af7b895293d42e6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

点击tableView
当父控件没有手势时


UIScrollView点击事件探索结果.png

当父控件有手势时


UIScrollView点击事件探索结果2.png
  • 由上面的例子可以得出当UIScrollView为最优响应者并且父控件没有手势时UIScrollView才可以自己处理点击事件。否则被父控件的gestureRecognizer占有。
  • 从上面结果看出当父控件有手势时UIScrollView的touches方法都不执行,类似于设置delaysTouchesBegan为YES。
  • 虽然UIScrollView及其子类和UIControl及其子类类似都可以阻断响应链,但是当UIScrollView及其子类为最优响应者时,如果父控件中有gestureRecognizer依然会被其占有。
UIScrollView点击穿透解决方案

当UIScrollView为最优响应者父控件有手势时,UIScrollView及其子类的点击代理方法和touchesBegan方法不响应。

解决方法:三种解决方式

  1. 可以通过给父控件手势设置cancelsTouchesInView为NO,则会同时响应gestureRecognizer的事件和UIScrollView及其子类的代理方法和touches事件。

  2. 给父控件中的手势的代理方法里面做一下判断,当touch的view是我们需要触发的view的时候,return NO ,这样就不会走手势方法,而去触发这个touch.view这个对象的方法了。

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    if ([NSStringFromClass([touch.view class])    isEqualToString:@"UITableViewCellContentView"]) {
        return NO;
    }
    return YES;
}
  1. 可以通过给UIScrollView及其子类添加gestureRecognizer,从而来调用需要处理的事情。

文章若有不对地方,欢迎批评指正

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容