前段时间项目有一个需求,要在点击闪屏的时候做一些处理,刚接到这个需求觉得很简单啊,在原有的view上加个button或者手势识别啥的,后面实现的时候发现还是有点坑。无论我在闪屏上面加button还是手势都无法响应到touch事件,后来也想了很多种可能,比如是否消息传递到了其他视图,可最终发现确是我自己把button从父视图remove的时候把消息也给remove了,具体原因是闪屏显示完成的时候我把button也remove了,而同时显示闪屏的时候项目也做了很多初始化工作,很占用主线程,导致UIApplication sendEvent被阻塞了,当主线程闲置下来的时候,我又把button从父视图remove了,从而一直接收不到touch事件。
主要也是对事件的整个分发过程不是很了解,查找问题无从下手,因此写下此文对事件的分发做一个详细说明,同时也可以在此基础上做一些拓展。
1.TouchEvents分发流程
APP的很多页面跳转,视图切换都是通过触摸事件来触发的,比如按钮的点击,view的手势等等,那我们点击屏幕,系统是如何判断我们点击的是哪个view的?答案就是HitTest,下面就给大家介绍下整个流程。
a)当我们点击设备的屏幕,UIKit就会生成一个事件对象UIEvent,然后会把这个Event分发给当前APP.
b)当前APP接收到事件后,UIApplication就会去事件队列中取最新的事件,然后通过sendEvent分发给能够处理该事件的对象,但是谁能处理该事件,这个就得靠HitTest来确认了。
c)HitTest会检测这个点击的点是不是发生在这个View上,如果是,就会去遍历这个View的subviews,直到找到最小的能够处理事件的view,如果找了一遍没找到能够处理的view,则返回自身。当确定了Hit-Test View时,如果当前的application没有忽略触摸事件 (UIApplication:isIgnoringInteractionEvents),则application就会去分发事件(sendEvent:->keywindow:sendEvent:)。
下面举例来说明下,如下图所示:
步骤:
1)当我们点击了图中的view 6,因为触摸点在view 1内,所以检查view 1的subview view 2 和view 3
2)触摸点不在view 2内,触摸点在view 3内,所以检查view 3的subview view 5和view 6
3)触摸点不在view 5内,触摸点在view 6 内,并且view 6没有subview,所以view 6是view 1中包含这个点的最小单位,所以view 6变成了这次触摸事件的hit-TestView
说明:
1)默认的hit-testing顺序是按照UIView中Subviews的逆顺序
2)如果View的同级别Subview中有重叠的部分,则优先检查顶部的Subview,如果顶部的Subview返回nil, 再检查底部的Subview
3)Hit-Test也是比较聪明的,检测过程中有这么一点,就是说如果点击没有发生在某View中,那么该事件就不可能发生在View的Subview中,所以检测过程中发现该事件不在ViewB内,也直接就不会检测在不在ViewF内。也就是说,如果你的Subview设置了clipsToBounds=NO,实际显示区域可能超出了superView的frame,你点击超出的部分,是不会处理你的事件的,就是这么任性!
4)view提供了两个方法来配合HitTest:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver‘s coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds
5)当一个View收到hitTest消息时,会调用自己的pointInside:withEvent:方法,如果pointInside返回YES,则表明触摸事件发生在我自己内部,则会遍历自己的所有Subview去寻找最小单位(没有任何子view)的UIView,如果当前View.userInteractionEnabled = NO,enabled=NO(UIControl),或者alpha<=0.01, hidden等情况的时候,hitTest就不会调用自己的pointInside了,直接返回nil,然后系统就回去遍历兄弟节点,如下代码所示:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if (self.alpha <= 0.01 || !self.userInteractionEnabled || self.hidden) { return nil; } BOOL inside = [self pointInside:point withEvent:event]; UIView *hitView = nil; if (inside) { NSEnumerator *enumerator = [self.subviews reverseObjectEnumerator]; for (UIView *subview in enumerator) { hitView = [subview hitTest:point withEvent:event]; if (hitView) { break; } } if (!hitView) { hitView = self; } return hitView; } else { return nil; } }
6)hit-Test 是事件分发的第一步,就算你的app忽略了事件,也会发生hit-Test。确定了hit-TestView之后,才会开始进行下一步的事件分发。
2.应用
我们可以利用hit-Test做一些事情.
1) 比如我们点击了ViewA,我们想让ViewB响应,这个时候,我们只需要重写View’s hitTest方法,返回ViewB就可以了,虽然可能用不到,但是偶尔还是会用到的。大概代码如下:
@interface STPView : UIView @end @implementation STPView - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom]; button.frame = CGRectMake(0, 0, CGRectGetWidth(frame), CGRectGetHeight(frame) / 2); button.tag = 10001; button.backgroundColor = [UIColor grayColor]; [button setTitle:@"Button1" forState:UIControlStateNormal]; [self addSubview:button]; [button addTarget:self action:@selector(_buttonActionFired:) forControlEvents:UIControlEventTouchDown]; UIButton *button2 = [UIButton buttonWithType:UIButtonTypeCustom]; button2.frame = CGRectMake(0, CGRectGetHeight(frame) / 2, CGRectGetWidth(frame), CGRectGetHeight(frame) / 2); button2.tag = 10002; button2.backgroundColor = [UIColor darkGrayColor]; [button2 setTitle:@"Button2" forState:UIControlStateNormal]; [self addSubview:button2]; [button2 addTarget:self action:@selector(_buttonActionFired:) forControlEvents:UIControlEventTouchDown]; } return self; } - (void)_buttonActionFired:(UIButton *)button { NSLog(@"=====Button Titled %@ ActionFired ", [button titleForState:UIControlStateNormal]); } - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { UIView *hitView = [super hitTest:point withEvent:event]; if (hitView == [self viewWithTag:10001]) { return [self viewWithTag:10002]; } return hitView; } @end
2) 这里给大家提供一个Category,来自STKit,这个category的目的就是方便的编写hitTest方法,由于hitTest方法是override,而不是delegate,所以使用默认的实现方式就比较麻烦。Category如下
/** * @abstract hitTestBlock * * @param 其余参数 参考UIView hitTest:withEvent: * @param returnSuper 是否返回Super的值。如果*returnSuper=YES,则代表会返回 super hitTest:withEvent:, 否则则按照block的返回值(即使是nil) * * @discussion 切记,千万不要在这个block中调用self hitTest:withPoint,否则则会造成递归调用。这个方法就是hitTest:withEvent的一个代替。 */ typedef UIView * (^STHitTestViewBlock)(CGPoint point, UIEvent *event, BOOL *returnSuper); typedef BOOL (^STPointInsideBlock)(CGPoint point, UIEvent *event, BOOL *returnSuper); @interface UIView (STHitTest) /// althought this is strong ,but i deal it with copy @property(nonatomic, strong) STHitTestViewBlock hitTestBlock; @property(nonatomic, strong) STPointInsideBlock pointInsideBlock; @end
@implementation UIView (STHitTest) const static NSString *STHitTestViewBlockKey = @"STHitTestViewBlockKey"; const static NSString *STPointInsideBlockKey = @"STPointInsideBlockKey"; + (void)load { method_exchangeImplementations(class_getInstanceMethod(self, @selector(hitTest:withEvent:)), class_getInstanceMethod(self, @selector(st_hitTest:withEvent:))); method_exchangeImplementations(class_getInstanceMethod(self, @selector(pointInside:withEvent:)), class_getInstanceMethod(self, @selector(st_pointInside:withEvent:))); } - (UIView *)st_hitTest:(CGPoint)point withEvent:(UIEvent *)event { NSMutableString *spaces = [NSMutableString stringWithCapacity:20]; UIView *superView = self.superview; while (superView) { [spaces appendString:@"----"]; superView = superView.superview; } NSLog(@"%@%@:[hitTest:withEvent:]", spaces, NSStringFromClass(self.class)); UIView *deliveredView = nil; // 如果有hitTestBlock的实现,则调用block if (self.hitTestBlock) { BOOL returnSuper = NO; deliveredView = self.hitTestBlock(point, event, &returnSuper); if (returnSuper) { deliveredView = [self st_hitTest:point withEvent:event]; } } else { deliveredView = [self st_hitTest:point withEvent:event]; } // NSLog(@"%@%@:[hitTest:withEvent:] Result:%@", spaces, NSStringFromClass(self.class), NSStringFromClass(deliveredView.class)); return deliveredView; } - (BOOL)st_pointInside:(CGPoint)point withEvent:(UIEvent *)event { NSMutableString *spaces = [NSMutableString stringWithCapacity:20]; UIView *superView = self.superview; while (superView) { [spaces appendString:@"----"]; superView = superView.superview; } NSLog(@"%@%@:[pointInside:withEvent:]", spaces, NSStringFromClass(self.class)); BOOL pointInside = NO; if (self.pointInsideBlock) { BOOL returnSuper = NO; pointInside = self.pointInsideBlock(point, event, &returnSuper); if (returnSuper) { pointInside = [self st_pointInside:point withEvent:event]; } } else { pointInside = [self st_pointInside:point withEvent:event]; } return pointInside; } - (void)setHitTestBlock:(STHitTestViewBlock)hitTestBlock { objc_setAssociatedObject(self, (__bridge const void *)(STHitTestViewBlockKey), hitTestBlock, OBJC_ASSOCIATION_COPY); } - (STHitTestViewBlock)hitTestBlock { return objc_getAssociatedObject(self, (__bridge const void *)(STHitTestViewBlockKey)); } - (void)setPointInsideBlock:(STPointInsideBlock)pointInsideBlock { objc_setAssociatedObject(self, (__bridge const void *)(STPointInsideBlockKey), pointInsideBlock, OBJC_ASSOCIATION_COPY); } - (STPointInsideBlock)pointInsideBlock { return objc_getAssociatedObject(self, (__bridge const void *)(STPointInsideBlockKey)); } @end
代码很简单,就是利用iOS的runtime能力,在hitTest执行之前,插入了一个方法。
3) iOS7原生的自带NavigationController可以实现从最左侧拖动PopViewController(大约13pt),不管当前可见的ViewController有没有其他的滑动手势或者事件,这是为什么?如何实现。下面简单介绍下:
如果我们触摸点的坐标 point.x < 13, 我们就让hit-Test 返回NavigationController.view, 把所有的事件入口交给他,否则就返回super,该怎么处理怎么处理,这样就能满足我们的条件,即使当前的VC上面有ScrollView,但是由于点击特定区域的时候,ScrollView根本得不到事件,所以系统会专心处理NavigationController的拖拽手势,而不是ScrollView的事件,当没有点击特定区域的时候,NavigationController的手势不会触发,系统会专心处理ScrollView的事件,互不影响。
虽然iOS8新增了UIScreenEdgePanGestureRecognizer 手势,但是单纯的用这个手势无法解决当前VC上面有ScrollView的问题。
当我们确定了HitTestView之后,我们的事件分发就正式开始了,如果hitTestView可以直接处理的,就处理,不能处理的,则交给 The Responder Chain/ GestureRecognizer。
附上一些测试查找hitTestView过程中打印的日志,可以观察一下:
STPWindow:[hitTest:withEvent:] ----UIView:[hitTest:withEvent:] --------STPView:[hitTest:withEvent:] --------UICollectionView:[hitTest:withEvent:] ------------UIImageView:[hitTest:withEvent:] ------------UIImageView:[hitTest:withEvent:] ------------STDefaultRefreshControl:[hitTest:withEvent:] ------------STPFeedCell:[hitTest:withEvent:] ------------STPFeedCell:[hitTest:withEvent:] ----------------UIView:[hitTest:withEvent:] --------------------UIImageView:[hitTest:withEvent:] ------------------------UIImageView:[hitTest:withEvent:] ------------------------UIView:[hitTest:withEvent:] ------------------------STImageView:[hitTest:withEvent:]
其中—-表示View的层次结构
参考:http://suenblog.duapp.com/blog/100031/iOS事件分发机制(一)%20hit-Testing