- 响应者对象UIResponder
- 事件传递
- 事件传递过程
- 关于hitTest:withEvent:方法解析
- 事件响应者链条
- 应用举例:
- 手势的共存和互斥
- 综合案例
- 手势和View的点击事件关系
一. 响应者对象UIResponder
在用户使用APP的过程中,会产生各种各样的事件 ,iOS中的事件可以分为3大类型 :
在iOS中不是任何对象都能处理事件的,只有继承了UIResponder
的对象才能接收并处理事件,我们称之为响应者对象
。
那么为什么继承自UIResponder
的类就能够接收并处理事件呢?因为该类中提供了以下4个对象方法来处理触摸事件:
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
二. 事件传递
下面我们通过一张图来看看iOS中事件的产生和传递过程:
- 当发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的队列事件中
- 然后UIApplication对象会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常会先将该事件发送给应用程序的主窗口(keyWindow)
- 主窗口会在视图层次结构中找到一个最合适的视图来处理该触摸事件
- 找到合适的视图控件后,就会调用视图控件的touches方法来作事件的具体处理:touchesBegin... touchesMoved...touchesEnded等
- 这些touches方法默认的做法是将事件顺着响应者链条向上传递,将事件交给上一个相应者进行处理
下面我们举个例子来演示下具体的传递过程,如图:
一般事件的传递是从父控件传递到子控件的,如果父控件接受不到触摸事件,那么子控件就不可能接收到触摸事件
例如:点击了绿色的View,传递过程如下:UIApplication->Window->白色View->绿色View
点击蓝色的View,传递过程如下:UIApplication->Window->白色View->橙色View->蓝色View
关于hitTest:withEvent:方法
iOS系统检测到手指触摸操作时会将其放入当前活动
Application
的事件队列,UIApplication会从事件队列中取出触摸事件并传递给key window
处理,window对象首先会调用hitTest:withEvent:
方法, 而该方法内部会调用pointInside:withEvent:
方法,该方法内部通过倒叙便利的方式也就是最先便利最后加入的子视图,从而来判断触摸点是否在该View区域内,如果pointInside
返回YES,则表明触摸事件发生在该View内部,此时系统会遍历该View的所有Subview 寻找最小单位的UIView如果当前
View.userInteractionEnabled = NO
,enabled=NO(UIControl)
或者alpha<=0.01
,hidden
等情况的时候,hitTest就不会调用自己的pointInside
,直接返回nil,然后系统就会去遍历兄弟节点。
注意:UIImageView
的userInteractionEnabled
默认就是NO,因此UIImageView
以及它的子控件默认是不能接收到触摸事件的。-
如果一个子视图的区域超过父视图的区域,比如下图,tabBar 中间的item
正常情况下对超出tabBar区域的触摸操作不会被识别,因为tabBar的pointInside:withEvent:
方法会返回NO,这样就不会继续向下遍历子视图了。当然,我们可以重写pointInside:withEvent:
方法来处理这种情况,下文会详细描述。
判断下当前这个点在不在方法调用者上,注意:这个点必须是方法调用者上的坐标系,才会判断准确。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds
下面我们用代码来模拟下这个过程:
// 作用:寻找最合适view
// point:表示方法调用者坐标系上的点
// 什么时候调用:只要一个事件传递给一个控件,就会调用这个控件的hitTest方法,该方法返回谁,谁就是最合适view
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 1.判断下自己能否接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
// 2.判断下点在不在当前控件上
if ([self pointInside:point withEvent:event] == NO) return nil; // 点不在当前控件
// 3.从后往前遍历自己的子控件
int count = (int)self.subviews.count;
for (int i = count - 1; i >= 0; i--) {
// 获取子控件
UIView *childView = self.subviews[I];
// 把当前坐标系上的点转换成子控件上的点
CGPoint childP = [self convertPoint:point toView:childView];
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView) {
return fitView;
}
}
// 4.如果没有比自己合适的子控件,最合适的view就是自己
return self;
}
三. 事件响应者链条
所谓的事件响应者链条就是由多个响应者对象连接起来的链条,大致如下:
事件的完整处理过程
- 当用户点击屏幕后产生触摸事件,系统先将事件对象由上往下传递,也就是由父控件传递给子控件,直到找到最合适的控件来处理这个事件。
- 找到最合适的视图控件后,调用该控件的
touches...
系列方法来作具体的事件处理- 如果该视图控件中调用了
[super touches...]
,则将事件顺着响应者链条往上传递,传递给上一个响应者对象,依次类推 - 如果该控件没有实现
touches...
系列方法,则将事件顺着响应者链条往上传递,传递给上一个响应者对象,依次类推
- 如果该视图控件中调用了
注意:
事件的传递是从上到下,由父控件到子控件,而事件的响应是从下到上,是顺着响应者链条向上传递,由子控件到父控件的。他们是相反的。
应用举例:
1、扩大UIButton的响应热区
有时候因为控件太小,我们想扩大他的点击响应区域,此时我们可以:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
CGRect frame = [self getScaleFrame];
return CGRectContainsPoint(frame, point);
}
- (CGRect)getScaleFrame {
CGRect rect = self.bounds;
if (rect.size.width < 40.f) {
rect.origin.x -= (40-rect.size.width)/2;
}
if (rect.size.height < 40.f) {
rect.origin.y -= (40-rect.size.height)/2;
}
rect.size.width = 40.f;
rect.size.height = 40.f;
return rect;
}
2、子view超出了父view的bounds响应事件
项目中常常遇到button已经超出了父view的范围但仍需可点击的情况,比如自定义Tabbar中间的大按钮,点击超出Tabbar bounds的区域也需要响应
//重写UITabBar的pointInside方法
-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
// 1. 转换点击在tabbar上的坐标点, 到中间按钮上
CGPoint pointInMiddleBtn = [self convertPoint:point toView:self.middleView];
// 2. 确定中间按钮的圆心
CGPoint middleBtnCenter = CGPointMake(33, 33);
// 3. 计算点击的位置距离圆心的距离
CGFloat distance = sqrt(pow(pointInMiddleBtn.x - middleBtnCenter.x, 2) + pow(pointInMiddleBtn.y - middleBtnCenter.y, 2));
// 4. 判定中间按钮区域之外
if (distance > 33 && pointInMiddleBtn.y < 18) {
return NO;
}
return YES;
}
3、方形按钮的内切圆点击
如下图 是一个正方形的UIButton,但是此时我们只想让它的内切圆接收点击事件,而4个角落是不接受点击事件的
@implementation CustomButton
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (!self.userInteractionEnabled ||[self isHidden] ||self.alpha <= 0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
//遍历当前对象的子视图
__block UIView *hit = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
// 坐标转换
CGPoint vonvertPoint = [self convertPoint:point toView:obj];
//调用子视图的hittest方法
hit = [obj hitTest:vonvertPoint withEvent:event];
// 如果找到了接受事件的对象,则停止遍历
if (hit) {
*stop = YES;
}
}];
if (hit) {
return hit;
}
else{
return self;
}
}
else{
return nil;
}
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
CGFloat x1 = point.x;
CGFloat y1 = point.y;
CGFloat x2 = self.frame.size.width / 2;
CGFloat y2 = self.frame.size.height / 2;
//圆的标准方程(x-a)²+(y-b)²=r²中, ab为圆心,r为半径
double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
// 67.923
if (dis <= self.frame.size.width / 2) {
return YES;
}
else{
return NO;
}
}
@end
四. 手势的共存和互斥
首先我们来看看下面这段代码:
- (void)viewDidLoad {
[super viewDidLoad];
GSViewOne *viewOne = [[GSViewOne alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
viewOne.backgroundColor = [UIColor redColor];
[self.view addSubview:viewOne];
GSViewTwo *viewTwo = [[GSViewTwo alloc] initWithFrame:CGRectMake(20, 20, 160, 160)];
viewTwo.backgroundColor = [UIColor yellowColor];
[viewOne addSubview:viewTwo];
//添加手势
GSGestureOne *gestureOne = [[GSGestureOne alloc] initWithTarget:self action:@selector(panOne)];
[viewOne addGestureRecognizer:gestureOne];
GSGestureTwo *gestureTwo = [[GSGestureTwo alloc] initWithTarget:self action:@selector(panTwo)];
[viewTwo addGestureRecognizer:gestureTwo];
}
-(void)panOne{
NSLog(@"panOne--redView");
}
-(void)panTwo{
NSLog(@"panTwo--yellowView");
}
效果图如下:
手势共存
当我们的手指在黄色View上拖拽的时候发现只识别了黄色区域的手势,那么现在有一个需求,当手指在黄色区域拖拽的时候我要黄色和红色区域的手势都识别该如何实现?
此时我们只需要实现UIGestureRecognizerDelegate
协议,实现如下方法即可:
//允许手势共存,只要有一个手势返回了YES,那么就是共存
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer{
return YES;
}
手势互斥
当我们手指在黄色区域拖拽的时候我希望红色区域手势识别而黄色区域手势不识别 ,此时就用到了手势互斥。
//gestureTwo的响应需要gestureOne响应失败
[gestureTwo requireGestureRecognizerToFail:gestureOne];
或者是用代理方法也可以:
///otherGestureRecognizer它要识别,需要gestureRecognizer被响应失败
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
综合案例
来看下面这段代码:
- (void)viewDidLoad {
[super viewDidLoad];
NSArray *imageArr = @[@"0", @"1", @"2", @"3", @"4"];
CGFloat scrollViewW = self.view.bounds.size.width - 60.f;
CGFloat margin = 20;
TopView *topView = [[TopView alloc] initWithFrame:CGRectMake(0, 100, self.view.bounds.size.width, scrollViewW/2)];
[self.view addSubview:topView];
//添加ScrollView
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(margin, 0, scrollViewW, scrollViewW/2)];
scrollView.contentSize = CGSizeMake(scrollViewW * imageArr.count, 0 );
scrollView.pagingEnabled = YES;
scrollView.clipsToBounds = NO;
scrollView.showsHorizontalScrollIndicator = NO;
[topView addSubview:scrollView];
//添加图片
CGFloat imgViewW = scrollViewW - margin;
for (int i=0; i
实现效果:
其实就是一个简单的图片轮播效果,这里我简单描述下,上图红色区域是UIScrollView
,红色区域是添加在黄色的View上的。
其实上述代码来说实现的效果是有几个严重问题的,读者可以将上述代码复制下来运行一下,可以发现如下几个问题:
问题一:当我们用手指拖拽红色区域外的图片的时候,发现根本拖拽不动,关于这个问题我相信大家都明白,因为拖拽的点不在
UIScrollView
之上,所以事件传递的原因,UIScrollView
根本捕捉不到拖拽事件
问题二:当我们手指拖拽屏幕左边边缘的图片的时候发现直接返回到上个控制器了。
问题三:返回到上个控制器的时候图片还是显示的。具体如下图:
首先让我们来解决问题一,重写TopView
的hitTest:withEvent:
方法,扩大ScrollView
的事件响应区域即可。
-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
UIView *temView = [super hitTest:point withEvent:event];
if (temView) {
temView = temView.subviews[0];
}
return temView;
}
问题二:因为导航栏和UIScrollView
都有拖拽手势,我们可以使用手势互斥的知识来解决这个问题
[self.navigationController.interactivePopGestureRecognizer requireGestureRecognizerToFail:scrollView.panGestureRecognizer];
问题三:这个比较好解决 ,直接让 self.view.clipsToBounds = YES;
即可
五. 手势和View的点击事件关系
现在有一个案例,BaseVC中添加了一个手势
@implementation BaseVC
- (void)viewDidLoad {
[super viewDidLoad];
UITapGestureRecognizer *tapGes = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapClick)];
[self.view addGestureRecognizer:tapGes];
}
-(void)tapClick{
NSLog(@"%s",__func__);
}
@end
ViewController
继承自BaseVC
,在ViewController
中添加了一个tableView
,并且实现了didSelectRowAtIndexPath:
方法
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
NSLog(@"%s",__func__);
}
运行起来之后,点击Cell,发现只执行了基类的点击手势,而没有执行Cell的点击事件,这是因为什么原因呢?
其实这是因为手势的cancelsTouchesInView
属性,该属性默认值为YES
,表示识别手势之后,是否取消view的touch事件,我们只需设置该属性为NO即可
tapGes.cancelsTouchesInView = NO;//识别手势之后,是否取消view的touch事件,默认值为YES
但是当点击Cell的时候我们只想执行Cell的点击事件而不想执行父类的手势事件,该如何操作呢?
我们只需要实现手势的代理方法即可:
// called before touchesBegan:withEvent: is called on the gesture recognizer for a new touch. return NO to prevent the gesture recognizer from seeing this touch
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch{
return ![touch.view isKindOfClass:NSClassFromString(@"UITableViewCellContentView")];
}