前言
看关于这方面的文章基本没有能涉及到UIGestureRecognizers
相关的文章,因此决定写这样一篇文章。也是我的第一篇文章,如有什么不对请及时指正。
本文主要通过一些实际测试来便于大家理解。
正文
- IOKit.framework 为系统内核的库
- SpringBoard.app 相当于手机的桌面
- Source1 主要接收系统的消息
- Source0 - UIApplication - UIWindow
- 从window开始系统会调用
hitTest:withEvent:
和pointInside
来找到最优响应者,具体过程可参考下图:
- 比如我们在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和其子类与手势之间的冲突和关系。
- 默认view会走其
- 当该事件响应完毕,主线程的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];
点击redView:
yellowView -> grayView -> blueView -> redView
当点击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 响应链的组成
还用上面那个栗子:
点击redView:
redview -> grayView -> viewController -> ...
因为只实现到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
- 虽然点击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];
点击redview
redview -> tableView
- 从上面测试结果可以得出,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
打印结果如下图所示:
- 当通过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
从该栗子中可以看出即使下层view添加收拾依然会响应按钮的点击事件。
-
由此可以猜测原因:
- UIControl及其子类会阻断响应链。(后面验证是错误的)
- 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];
点击greenView
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];
点击blueView
- 点击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为最优响应者并且父控件没有手势时UIScrollView才可以自己处理点击事件。否则被父控件的gestureRecognizer占有。
- 从上面结果看出当父控件有手势时UIScrollView的touches方法都不执行,类似于设置delaysTouchesBegan为YES。
- 虽然UIScrollView及其子类和UIControl及其子类类似都可以阻断响应链,但是当UIScrollView及其子类为最优响应者时,如果父控件中有gestureRecognizer依然会被其占有。
UIScrollView点击穿透解决方案
当UIScrollView为最优响应者父控件有手势时,UIScrollView及其子类的点击代理方法和touchesBegan方法不响应。
解决方法:三种解决方式
可以通过给父控件手势设置cancelsTouchesInView为NO,则会同时响应gestureRecognizer的事件和UIScrollView及其子类的代理方法和touches事件。
给父控件中的手势的代理方法里面做一下判断,当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;
}
- 可以通过给UIScrollView及其子类添加gestureRecognizer,从而来调用需要处理的事情。
文章若有不对地方,欢迎批评指正