iOS-手势

iOS中所有的手势操作都继承于UIGestureRecognizer,这个类本身不能直接使用。这个类中定义了这几种手势公有的一些属性和方法。

一.UIGestureRecognizer基类

1. 属性和方法

// 指定初始化器
- (instancetype)initWithTarget:(nullable id)target action:(nullable SEL)action NS_DESIGNATED_INITIALIZER; 
// 添加 target/actions
- (void)addTarget:(id)target action:(SEL)action;    
// 移除target/actions
- (void)removeTarget:(nullable id)target action:(nullable SEL)action; 

// 手势状态
@property(nonatomic,readonly) UIGestureRecognizerState state;  
// 手势代理
@property(nullable,nonatomic,weak) id <UIGestureRecognizerDelegate> delegate; 

//比如:关闭导航栏的侧滑返回
//self.navigationController.interactivePopGestureRecognizer.enabled = NO;
 // 手势是否可用,默认可用
@property(nonatomic, getter=isEnabled) BOOL enabled; 
 // 手势被添加的视图,一般拿到这个视图做事情
@property(nullable, nonatomic,readonly) UIView *view;          

// 默认为YES,这种情况下当手势识别器识别到touch之后,会发送touchesCancelled给hit-test view以取消hit-test view对touch的响应,这个时候只有手势识别器响应touch。设置为NO,好让手势传播到其他控件上
@property(nonatomic) BOOL cancelsTouchesInView; 

// 默认是NO,这种情况下当发生一个touch时,手势识别器先捕捉到到touch,然后发给hit-testview,两者各自做出响应。如果设置为YES,手势识别器在识别的过程中(注意是识别过程),不会将touch发给hit-test view,即hit-testview不会有任何触摸事件。只有在识别失败之后才会将touch发给hit-testview,这种情况下hit-test view的响应会延迟约0.15ms。
@property(nonatomic) BOOL delaysTouchesBegan; 

// 默认为YES。这种情况下发生一个touch时,在手势识别成功后,发送给touchesCancelled消息给hit-test view,手势识别失败时,会延迟大概0.15ms,期间没有接收到别的touch才会发送touchesEnded。如果设置为NO,则不会延迟,即会立即发送touchesEnded以结束当前触摸。
@property(nonatomic) BOOL delaysTouchesEnded; 

// 允许Touch的数组类型
@property(nonatomic, copy) NSArray<NSNumber *> *allowedTouchTypes NS_AVAILABLE_IOS(9_0); /

// 允许按压的数组类型
@property(nonatomic, copy) NSArray<NSNumber *> *allowedPressTypes NS_AVAILABLE_IOS(9_0); 

//是否同时只接受一种触摸类型, 默认YES
@property (nonatomic) BOOL requiresExclusiveTouchType NS_AVAILABLE_IOS(9_2); 

//这个方法可以指定某个手势执行的前提是后一个手势识别失败。(设置优先级,后面优先级大于前面的)
- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;

// 手指在视图上的位置(x,y)就是手指在视图本身坐标系的位置
- (CGPoint)locationInView:(nullable UIView*)view;                               

// 触摸点的个数
@property(nonatomic, readonly) NSUInteger numberOfTouches;                                          

//(touchIndex 是第几个触摸点)用来获取多触摸点在view上位置信息的方法  
- (CGPoint)locationOfTouch:(NSUInteger)touchIndex inView:(nullable UIView*)view; // the location of a particular touch

// 给手势加一个名字,以方便调式(iOS11 or later可以用)
@property (nullable, nonatomic, copy) NSString *name API_AVAILABLE(ios(11.0), tvos(11.0)); // name for debugging to appear in logging

重要属性详解:

① cancelsTouchesInView / delaysTouchesBegan / delaysTouchesEnded**

解释说明
1.这3个属性是作用于GestureRecognizers(手势识别)与触摸事件之间联系的属性。
2.对于触摸事件,window只会有一个控件来接收touch。这个控件是首先接触到touch的并且重写了触摸事件方法(一个即可)的控件。
3.手势识别和触摸事件是两个独立的事,只是可以通过这3个属性互相影响。
在这3个属性都处于默认值的情况下,如果触摸window,首先由window上最先符合条件的控件(该控件记为hit-test view)接收到该touch并触发触摸事件touchesBegan。同时如果某个控件的手势识别器接收到了该touch,就会进行识别。手势识别成功之后发送触摸事件touchesCancelled给hit-test view,hit-test view不再响应touch。

cancelsTouchesInView:

默认为YES,这种情况下当手势识别器识别到touch之后,会发送touchesCancelled给hit-test view以取消hit-test view对touch的响应,这个时候只有手势识别器响应touch。
当设置成NO时,手势识别器识别到touch之后不会发送touchesCancelled给hit-test,这个时候手势识别器和hit-test view均响应touch。

举个例子:
ViewController代码如下,点击空白

- (void)addPanGesture
{
    //拖动手势
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panHandler:)];
    pan.cancelsTouchesInView = YES;
    [self.view addGestureRecognizer:pan];
}
- (void)panHandler:(UIPanGestureRecognizer *)sender
{
    NSLog(@"panHandler 调用了");
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"touchesBegin调用了");
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"touchesMoved调用了");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
    NSLog(@"touchesEnded调用了");
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
    NSLog(@"touchesCancelled调用了");
}

打印结果为:

pan.cancelsTouchesInView = YES;
2019-10-14 14:51:53.477277+0800 点击事件测试[2968:5316495] touchesBegin调用了
2019-10-14 14:51:53.619030+0800 点击事件测试[2968:5316495] touchesMoved调用了
2019-10-14 14:51:53.642291+0800 点击事件测试[2968:5316495] touchesMoved调用了
2019-10-14 14:57:06.496434+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.518827+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:51:53.665728+0800 点击事件测试[2968:5316495] panHandler 调用了
2019-10-14 14:51:53.666045+0800 点击事件测试[2968:5316495] touchesCancelled调用了
2019-10-14 14:51:53.687465+0800 点击事件测试[2968:5316495] panHandler 调用了
2019-10-14 14:51:53.687801+0800 点击事件测试[2968:5316495] panHandler 调用了
2019-10-14 14:51:53.879677+0800 点击事件测试[2968:5316495] panHandler 调用了
2019-10-14 14:51:53.879677+0800 点击事件测试[2968:5316495] panHandler 调用了

pan.cancelsTouchesInView = NO;
2019-10-14 14:57:06.272598+0800 点击事件测试[3074:5324828] touchesBegin调用了
2019-10-14 14:57:06.450891+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.472362+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.496434+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.518827+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.585744+0800 点击事件测试[3074:5324828] panHandler 调用了
2019-10-14 14:57:06.586023+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.608386+0800 点击事件测试[3074:5324828] panHandler 调用了
2019-10-14 14:57:06.608978+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.630915+0800 点击事件测试[3074:5324828] panHandler 调用了
2019-10-14 14:57:06.631178+0800 点击事件测试[3074:5324828] touchesMoved调用了
2019-10-14 14:57:06.834212+0800 点击事件测试[3074:5324828] panHandler 调用了
2019-10-14 14:57:06.834497+0800 点击事件测试[3074:5324828] touchesEnded调用了
  1. 例子中pan.cancelsTouchesInView = YES时,为什么会打印"touchesMoved调用了"呢?这是因为手势识别是有一个过程的,拖拽手势需要一个很小的手指移动的过程才能被识别为拖拽手势,而在一个手势触发之前,是会一并发消息给事件传递链的,所以才会有最开始的几个touchMoved方法被调用,当识别出拖拽手势以后,就会终止touch事件的传递。
  2. 当pan.cancelsTouchsInView = NO,不会终止touch事件的传递touchesMoved和panHandler依次被打印出来,touch事件继续响应。
delaysTouchesBegan:

默认是NO,这种情况下当发生一个touch时,手势识别器先捕捉到touch,然后发给hit-testview,两者各自做出响应。如果设置为YES,手势识别器在识别的过程中(注意是识别过程),不会将touch发给hit-test view,即hit-testview不会有任何触摸事件。只有在识别失败之后才会将touch发给hit-testview,这种情况下hit-test view的响应会延迟约0.15ms,(就是设置为YES的时候,手势识别器有优先的感觉)。

举个例子:

- (void)addPanGesture
{
    //拖动手势
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panHandler:)];
    pan.delaysTouchesBegan = YES;
    [self.view addGestureRecognizer:pan];
}
- (void)panHandler:(UIPanGestureRecognizer *)sender
{
    NSLog(@"panHandler 调用了");
}

//pan.delaysTouchesBegan = YES;  控制台输出如下:
2018-07-26 16:06:59.682302+0800 GestureDemo[82294:1669777] panHandler 调用了
2018-07-26 16:06:59.689734+0800 GestureDemo[82294:1669777] panHandler 调用了
2018-07-26 16:06:59.689973+0800 GestureDemo[82294:1669777] panHandler 调用了
2018-07-26 16:06:59.697302+0800 GestureDemo[82294:1669777] panHandler 调用了
2018-07-26 16:06:59.697675+0800 GestureDemo[82294:1669777] panHandler 调用了

//pan.delaysTouchesBegan = NO;  控制台输出如下:
2019-10-14 17:56:08.831884+0800 点击事件测试[7174:5509207] -[ViewController touchesBegan:withEvent:]
2019-10-14 17:56:08.907463+0800 点击事件测试[7174:5509207] -[ViewController touchesMoved:withEvent:]
2019-10-14 17:56:08.930231+0800 点击事件测试[7174:5509207] -[ViewController panHandler:]
2019-10-14 17:56:08.930687+0800 点击事件测试[7174:5509207] -[ViewController touchesCancelled:withEvent:]
2019-10-14 17:56:08.952144+0800 点击事件测试[7174:5509207] -[ViewController panHandler:]
2019-10-14 17:56:08.952485+0800 点击事件测试[7174:5509207] -[ViewController panHandler:]
2019-10-14 17:56:08.977125+0800 点击事件测试[7174:5509207] -[ViewController panHandler:]
2019-10-14 17:56:08.997982+0800 点击事件测试[7174:5509207] -[ViewController panHandler:]
2019-10-14 17:56:09.020301+0800 点击事件测试[7174:5509207] -[ViewController panHandler:]

当delaysTouchesBegan 设置为YES时,手势识别成功之前都不会调用touches相关方法,因为手势识别成功了,所以控制台只打印了"panHandler 调用了"的信息,如果手势识别失败了,就会打印touchesMoved方法里的信息

delaysTouchesEnded:

默认为YES,这种情况下发生一个touch时,在手势识别成功后,发送给touchesCancelled消息给hit-test view,手势识别失败时,会延迟大概0.15ms,期间没有接收到别的touch才会发送touchesEnded。如果设置为NO,则不会延迟,即会立即发送touchesEnded以结束当前触摸。
举个例子:

- (void)addTapGesture
{
     //连续三次点击手势
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapHandler:)];
    tap.numberOfTapsRequired = 3;
    tap.delaysTouchesEnded = YES;
    [self.view addGestureRecognizer:tap];
}
- (void)tapHandler:(UITapGestureRecognizer *)sender
{
    NSLog(@"tapHandler 点击了");
}

delaysTouchesEnded = YES
2019-10-14 15:42:52.693015+0800 点击事件测试[4222:5380031] touchesBegin调用了
2019-10-14 15:42:52.713909+0800 点击事件测试[4222:5380031] touchesMoved调用了
2019-10-14 15:42:52.722584+0800 点击事件测试[4222:5380031] touchesMoved调用了
2019-10-14 15:42:52.731320+0800 点击事件测试[4222:5380031] touchesMoved调用了
2019-10-14 15:42:52.739306+0800 点击事件测试[4222:5380031] touchesMoved调用了
2019-10-14 15:42:52.747152+0800 点击事件测试[4222:5380031] touchesMoved调用了
2019-10-14 15:42:52.755081+0800 点击事件测试[4222:5380031] touchesMoved调用了
2019-10-14 15:42:53.191136+0800 点击事件测试[4222:5380031] tapHandler 点击了
2019-10-14 15:42:53.191487+0800 点击事件测试[4222:5380031] touchesCancelled调用了

delaysTouchesEnded = NO
2019-10-14 15:43:33.320133+0800 点击事件测试[4272:5382236] touchesBegin调用了
2019-10-14 15:43:33.338324+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.346445+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.354754+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.363870+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.372995+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.397715+0800 点击事件测试[4272:5382236] touchesEnded调用了
2019-10-14 15:43:33.534690+0800 点击事件测试[4272:5382236] touchesBegin调用了
2019-10-14 15:43:33.557135+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.565328+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.573337+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.580876+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.597362+0800 点击事件测试[4272:5382236] touchesEnded调用了
2019-10-14 15:43:33.712002+0800 点击事件测试[4272:5382236] touchesBegin调用了
2019-10-14 15:43:33.732732+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.741955+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.750436+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.773844+0800 点击事件测试[4272:5382236] touchesMoved调用了
2019-10-14 15:43:33.783851+0800 点击事件测试[4272:5382236] tapHandler 点击了
2019-10-14 15:43:33.784363+0800 点击事件测试[4272:5382236] touchesCancelled调用了

设置为YES,会等待一个很短的时间,如果没有接收到新的手势识别任务,才会发送touchesEnded消息到事件传递链。
设置为NO,则会立马调用touchEnd:withEvent这个方法

② requireGestureRecognizerToFail

用法:[A requireGestureRecognizerToFail:B] 当A、B两个手势同时满足响应手势方法的条件时,B优先响应,A不响应。如果B不满足条件,A满足响应手势方法的条件,则A响应。其实这就是一个设置响应手势优先级的方法。

如果一个view上添加了多个手势对象的,默认这些手势是互斥的,一个手势触发了就会默认屏蔽其他手势动作。比如,单击和双击手势并存时,如果不做处理,它就只能发送出单击的消息。为了能够优先识别双击手势,我们就可以用requireGestureRecognizerToFail:这个方法设置优先响应双击手势。

2. UIGestureRecognizerDelegate代理方法

这里讲的不详细,以后慢慢整理.

//手势已经识别,通过这个方法的返回值,看是否响应, YES响应, NO不响应
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer;

//是否响应手势的四个touch方法
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch;

//同上
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePress:(UIPress *)press;

//Simultaneously同时的
//两个手势是否共存(一起响应),A手势和B手势,只要这两个手势有一个手势的这个代理方法返回的YES,那么就是共存
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;

//优先级:gestureRecognizer它要响应,必须得满足otherGestureRecognizer响应失败,才可以,otherGestureRecognizer的优先级最高
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer NS_AVAILABLE_IOS(7_0);

//优先级:otherGestureRecognizer它要响应,需要gestureRecognizer响应失败,才可以,gestureRecognizer的优先级最高
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer NS_AVAILABLE_IOS(7_0);

二. UIGestureRecognizer子类

在iOS中有六种手势操作:
UITapGestureRecognizer : 点击手势
UIPinchGestureRecognizer : 捏合手势
UIPanGestureRecognizer : 拖动手势
UISwipeGestureRecognizer : 轻扫手势
UIRotationGestureRecognizer : 旋转手势
UILongPressGestureRecognizer : 长按手势
所有的手势操作都继承于UIGestureRecognizer

1. 手势的分类

  • 离散手势
    离散手势只会触发一次,而且一旦识别就无法取消,比如UITapGestureRecgnier
  • 连续手势
    连续手势会一直向action method发送消息,告诉值改变了,除UITapGestureRecgnier外的手势都是连续手势

    从下图可以看出离散手势和连续手势action method的调用次数
    调用次数

2. 手势的状态

  • UIGestureRecognizerState
typedef NS_ENUM(NSInteger, UIGestureRecognizerState) {
    UIGestureRecognizerStatePossible,   // 尚未识别是何种手势,但是可能已经触发触摸事件,默认状态
    UIGestureRecognizerStateBegan,      // 手势已经被识别,手势开始,但这个过程可能发生改变,手势操作尚未完成,action方法将在下一轮运行循环调用
    UIGestureRecognizerStateChanged,    // 手势发生改变,action方法将在下一轮运行循环调用
    UIGestureRecognizerStateEnded,      // 手势结束,action方法将在下一轮运行循环调用,之后变成默认状态
    UIGestureRecognizerStateCancelled,  // 手势取消,action方法将在下一轮运行循环调用,之后变成默认状态
    UIGestureRecognizerStateFailed,     // 手势失败,不会调用action方法将,之后变成默认状态
    UIGestureRecognizerStateRecognized = UIGestureRecognizerStateEnded  //同UIGestureRecognizerStateEnded
};
  1. 对于离散型手势UITapGestureRecognizer要么被识别,要么失败,点按(假设点按次数设置为1,并且没有添加长按手势)下去一次不松开则此时什么也不会发生,松开手指立即识别并调用操作事件,并且状态为3(已结束)。
  2. 但是连续手势要复杂一些,就拿旋转手势来说,如果两个手指点下去不做任何操作,此时并不能识别手势(因为我们还没有旋转)但是其实已经出发了触摸开始事件,此时处于状态0;如果此时旋转会被识别,也就会调用对应的操作事件,同时状态变成1(手势开始),但是状态1只有一瞬间;紧接着变成状态2(因为我们的旋转需要持续一会),并且重复调用操作事件(如果在事件中打印状态会重复打印2);松开手指,此时状态变为3,并调用1次操作事件。

为了大家更好的理解这个状态的变化,不妨在操作事件中打印事件状态,会发现在操作事件中的状态永远不可能为0(默认状态),因为只要调用操作事件就说明已经被识别了。前面也说过,手势识别的根本还是调用触摸事件而完成的,连续手势之所以会发生状态转换完全是由于触摸事件中的移动事件造成的,没有移动事件也就不存在这个过程中状态变化。

大家通过苹果官方的分析图再理解一下手势状态:
离散手势和连续手势

3. 各种手势介绍

① 点击手势——UITapGestureRecognizer

//设置点击次数,默认为单击
@property (nonatomic) NSUInteger  numberOfTapsRequired; 
//设置同时点击的手指数
@property (nonatomic) NSUInteger  numberOfTouchesRequired;

② 捏合手势——UIPinchGestureRecognizer

//设置缩放比例
@property (nonatomic)          CGFloat scale; 
//捏合速度,只读
@property (nonatomic,readonly) CGFloat velocity;

③ 滑动手势——UIPanGestureRecognzer

//设置触发拖拽的最少触摸点,默认为1
@property (nonatomic)          NSUInteger minimumNumberOfTouches; 
//设置触发拖拽的最多触摸点
@property (nonatomic)          NSUInteger maximumNumberOfTouches;  
//手指在视图上移动的位置(x,y)向下和向右为正,向上和向左为负
- (CGPoint)translationInView:(nullable UIView *)view;            
//设置当前移动位置
- (void)setTranslation:(CGPoint)translation inView:(nullable UIView *)view;
//手指在视图上移动的速度(x,y), 正负也是代表方向,值得一提的是在绝对值上|x| > |y| 水平移动, |y|>|x| 竖直移动
- (CGPoint)velocityInView:(nullable UIView *)view;

更多信息可参考:
开发中的疑惑点---手势位置locationInView、velocityInView、translationInView

④ 旋转手势——UIRotationGestureRecognizer

//设置旋转角度
@property (nonatomic)          CGFloat rotation;
//设置旋转速度 
@property (nonatomic,readonly) CGFloat velocity;

⑤ 轻扫手势——UISwipeGestureRecognizer

//设置触发滑动手势的触摸点数
@property(nonatomic) NSUInteger                        numberOfTouchesRequired; 
//设置滑动方向
@property(nonatomic) UISwipeGestureRecognizerDirection direction;  
//枚举如下
typedef NS_OPTIONS(NSUInteger, UISwipeGestureRecognizerDirection) {
    UISwipeGestureRecognizerDirectionRight = 1 << 0,
    UISwipeGestureRecognizerDirectionLeft  = 1 << 1,
    UISwipeGestureRecognizerDirectionUp    = 1 << 2,
    UISwipeGestureRecognizerDirectionDown  = 1 << 3
};

⑥ 长按手势——UILongPressGestureRecognizer

//设置触发前的点击次数
@property (nonatomic) NSUInteger numberOfTapsRequired;    
//设置触发的触摸点数
@property (nonatomic) NSUInteger numberOfTouchesRequired; 
//设置最短的长按时间
@property (nonatomic) CFTimeInterval minimumPressDuration; 
//设置在按触时时允许移动的最大距离 默认为10像素
@property (nonatomic) CGFloat allowableMovement;

⑦ 边缘拖动手势——UIScreenEdgePanGestureRecognizer

这是一个继承UIPanGestureRecognizer的手势,只有一个属性,手势方向

//手势的方向
@property (readwrite, nonatomic, assign) UIRectEdge edges; //< The edges on which this gesture recognizes, relative to the current interface orientation

关于它的简单使用如下图:
边缘拖动.png

self.view上添加黄色View,黄色View上面添加红色View,红色View上添加边缘拖动手势,从红色View右侧边缘往左拖动,实现以上效果,代码如下:

#import "EOCEventCase_ScreenGesture.h"
@interface EOCEventCase_ScreenGesture () {
    CGFloat center_x;
    CGFloat center_y;
}

@property(nonatomic, strong)UIView *backgroundView;
@property(nonatomic, strong)UIView *showView;
@end

@implementation EOCEventCase_ScreenGesture

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    self.navigationItem.title = @"UIScreenPanGesture";
    
    center_x = self.view.bounds.size.width/2;
    center_y = self.view.bounds.size.height/2;
    
    [self.view addSubview:self.backgroundView];
    [self.backgroundView addSubview:self.showView];
    [self createScreenGestureView];

    NSLog(@"self.view是%p,红色view是%p,黄色View是%p", self.view, self.showView, self.backgroundView);
}

- (void)createScreenGestureView {
    UIScreenEdgePanGestureRecognizer *screenEdgePanGesture = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(panAction:)];
    
    //让EdgePanGestureRecognizer优先级最高
    NSArray *gestureArray = self.navigationController.view.gestureRecognizers;
    for (UIGestureRecognizer *gesture in gestureArray) {
        if ([gesture isKindOfClass:[UIScreenEdgePanGestureRecognizer class]]) {
            [gesture requireGestureRecognizerToFail:screenEdgePanGesture];
        }
    }
    
    screenEdgePanGesture.edges = UIRectEdgeRight;
    [self.showView addGestureRecognizer:screenEdgePanGesture];
}

#pragma mark - event response
- (void)panAction:(UIScreenEdgePanGestureRecognizer *)gesture
{
    UIView *view = [self.view hitTest:[gesture locationInView:gesture.view] withEvent:nil];
    NSLog(@"响应者view是%p", view);

    if (UIGestureRecognizerStateBegan == gesture.state || UIGestureRecognizerStateChanged == gesture.state) {
        CGPoint translationPoint = [gesture translationInView:gesture.view];
        _backgroundView.center = CGPointMake(center_x+translationPoint.x, center_y);
    } else {
        [UIView animateWithDuration:.3f animations:^{
            _backgroundView.center = CGPointMake(center_x, center_y); 
        }];
    }
}

#pragma mark get method
- (UIView *)showView {
    if (!_showView) {
        _showView = [[UIView alloc] initWithFrame:CGRectMake(0.f, 0.f, self.view.frame.size.width, 200.f)];
        _showView.backgroundColor = [UIColor redColor];
    }
    return _showView;
}

- (UIView *)backgroundView {
    if (!_backgroundView) {
        _backgroundView = [[UIView alloc] initWithFrame:self.view.bounds];
        _backgroundView.backgroundColor = [UIColor yellowColor];
    }
    return _backgroundView;
}
@end

上面代码,在红色View右侧一小块区域向左滑就可以实现上面的效果。打印如下:

self.view是0x121f08870,红色view是0x123501bd0,黄色View是0x123502610
......
响应者view是0x123501bd0
......
响应者view是0x121f08870

可以发现,在红色view右侧的一小块区域向左滑动时,响应者是红色view,超出红色view右侧的那一小块区域,响应者就是self.view。

三. UIGestureRecognizer、UIResponder、UIControl

先看继承关系:

UIGestureRecognizer : NSObject
UILabel : UIView : UIResponder : NSObject
UIButton : UIControl : UIView : UIResponder : NSObject

可以看出UIGestureRecognizer和UIResponder都是直接继承NSObject的
而UIButton最终继承于UIResponder。

1. UIGestureRecognizer、UIResponder之间的优先级

UIGestureRecognizer优先级大于UIResponder

当前self.view上添加一个oneView, oneView上再添加一个tap手势
oneView

代码如下;

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIView *one = [[oneView alloc]initWithFrame:CGRectMake(60, 60, 100, 100)];
    one.backgroundColor = [UIColor yellowColor];
    [self.view addSubview:one];
    self.oenView = one;
    
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapHandler:)];
    [self.oenView addGestureRecognizer:tap];
}
- (void)tapHandler:(UITapGestureRecognizer *)sender
{
    NSLog(@"tapHandler 点击了");
}

oneView.m实现如下方法

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"one-touchesBegin调用了");
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"one-touchesMoved调用了");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
    NSLog(@"one-touchesEnded调用了");
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
    NSLog(@"one-touchesCancelled调用了");
}

点击oneView打印结果如下:

2019-10-14 16:50:32.967674+0800 点击事件测试[5637:5441763] one-touchesBegin调用了
2019-10-14 16:50:32.983451+0800 点击事件测试[5637:5441763] one-touchesMoved调用了
2019-10-14 16:50:32.991280+0800 点击事件测试[5637:5441763] one-touchesMoved调用了
2019-10-14 16:50:32.999145+0800 点击事件测试[5637:5441763] one-touchesMoved调用了
2019-10-14 16:50:33.007548+0800 点击事件测试[5637:5441763] one-touchesMoved调用了
2019-10-14 16:50:33.019674+0800 点击事件测试[5637:5441763] one-touchesMoved调用了
2019-10-14 16:50:33.023782+0800 点击事件测试[5637:5441763] one-touchesMoved调用了
2019-10-14 16:50:33.050233+0800 点击事件测试[5637:5441763] tapHandler 点击了
2019-10-14 16:50:33.050510+0800 点击事件测试[5637:5441763] one-touchesCancelled调用了

从日志上看出oneView最后cancel了对触摸事件的响应,而正常应当是触摸结束后,oneView的touchesEnded:withEvent:方法被调用才对。另外,期间还执行了手势识别器绑定的action。对于这种现象,官方文档上有这么一段描述:

A window delivers touch events to a gesture recognizer before it delivers them to the hit-tested view attached to the gesture recognizer. Generally, if a gesture recognizer analyzes the stream of touches in a multi-touch sequence and doesn’t recognize its gesture, the view receives the full complement of touches. If a gesture recognizer recognizes its gesture, the remaining touches for the view are cancelled. The usual sequence of actions in gesture recognition follows a path determined by default values of the cancelsTouchesInView, delaysTouchesBegan, delaysTouchesEnded properties:

这段描述的意思是:UIWindow会先把touch事件分发给手势识别器,然后再分发给hit-tested view即oneView,如果一个手势识别器分析了这一系列的点击事件之后没有识别出该手势,hit-tested view将会接收完整的点击事件。如果手势识别器识别了该手势,hit-tested view将会取消这次点击。由此可以看出:手势识别器比UIResponder具有更高的事件响应优先级。

按照这个解释,UIWindow在将事件传递给hit-tested view即oneView之前,先传递给了手势识别器。手势识别器成功识别了该事件,通知application取消oneView对事件的响应。

然而看日志,却是oneView的touchesBegan:withEvent:先调用了,既然手势识别器先响应,不应该上面的action先执行吗?实际上这个认知是错误的。手势识别器的action的调用时机并不是手势识别器接收到事件的时机,而是手势识别器成功识别事件后的时机,即手势识别器的状态变为UIGestureRecognizerStateRecognized。要证明UIWindow先将事件传递给了手势识别器,还是需要看手势识别器中这四个熟悉的方法的调用结果。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

不过不要误会,UIGestureRecognizer并不继承于UIResponder类,他们只是方法名相同而已。
这样,我们就可以自定义一个继承自UITapGestureRecognizer的子类,重写这四个方法,观察事件分发的顺序。上面的四个方法声明在SubTapGesture.h中

#import "SubTapGesture.h"
@implementation SubTapGesture

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 这里需要调用一下父类的touchesBegan方法,否则事件会被拦截消耗掉
    [super touchesBegan:touches withEvent:event];
    NSLog(@"%s", __func__);
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesMoved:touches withEvent:event];
    NSLog(@"%s", __func__);
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesEnded:touches withEvent:event];
    NSLog(@"%s", __func__);
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesCancelled:touches withEvent:event];
    NSLog(@"%s", __func__);
}
@end

最后打印结果为:

2019-10-14 17:20:04.007387+0800 点击事件测试[6395:5471705] -[SubTapGesture touchesBegan:withEvent:]
2019-10-14 17:20:04.008743+0800 点击事件测试[6395:5471705] -[oneView touchesBegan:withEvent:]
2019-10-14 17:20:04.052897+0800 点击事件测试[6395:5471705] -[SubTapGesture touchesMoved:withEvent:]
2019-10-14 17:20:04.053203+0800 点击事件测试[6395:5471705] -[oneView touchesMoved:withEvent:]
2019-10-14 17:20:04.078489+0800 点击事件测试[6395:5471705] -[SubTapGesture touchesEnded:withEvent:]
2019-10-14 17:20:04.079131+0800 点击事件测试[6395:5471705] tapHandler 点击了
2019-10-14 17:20:04.079532+0800 点击事件测试[6395:5471705] -[oneView touchesCancelled:withEvent:]

可以看到,确实是手势识别器先接收到了事件,然后hit-tested view接收到事件。接着手势识别器识别了手势,执行action,再由Application取消了oneView对事件的响应。

UIGestureRecognizer分为离散型手势和持续型手势,我们上面的demo用的是离散型手势,那么如果是持续型手势又会有什么样的结果呢?我们把UITapGestureRecognizer用UIPanGestureRecognizer替换,然后在oneView上面执行一次滑动,输出结果如下

2020-08-17 19:41:24.101631+0800 点击事件测试[91897:16080172] -[SubTapGesture touchesBegan:withEvent:]
2020-08-17 19:41:24.102897+0800 点击事件测试[91897:16080172] -[oneView touchesBegan:withEvent:]
2020-08-17 19:41:24.121522+0800 点击事件测试[91897:16080172] -[SubTapGesture touchesMoved:withEvent:]
2020-08-17 19:41:24.122074+0800 点击事件测试[91897:16080172] -[oneView touchesMoved:withEvent:]
2020-08-17 19:41:24.195710+0800 点击事件测试[91897:16080172] -[SubTapGesture touchesMoved:withEvent:]
2020-08-17 19:41:24.196211+0800 点击事件测试[91897:16080172] -[oneView touchesMoved:withEvent:]
2020-08-17 19:41:24.212155+0800 点击事件测试[91897:16080172] -[SubTapGesture touchesMoved:withEvent:]
2020-08-17 19:41:24.212670+0800 点击事件测试[91897:16080172] tapHandler 点击了
2020-08-17 19:41:24.213111+0800 点击事件测试[91897:16080172] -[oneView touchesCancelled:withEvent:]
2020-08-17 19:41:24.221225+0800 点击事件测试[91897:16080172] -[SubTapGesture touchesMoved:withEvent:]
2020-08-17 19:41:24.221555+0800 点击事件测试[91897:16080172] tapHandler 点击了
2020-08-17 19:41:24.237961+0800 点击事件测试[91897:16080172] -[SubTapGesture touchesMoved:withEvent:]
2020-08-17 19:41:24.238276+0800 点击事件测试[91897:16080172] tapHandler 点击了

在一开始滑动的过程中,手势识别器处在识别手势阶段,滑动产生的连续事件既会传递给手势识别器又会传递给oneView,因此oneView的touchesMoved:withEvent:在开始一段时间内会持续调用;当手势识别器成功识别了该滑动手势时,手势识别器的action开始调用,同时通知Application取消oneView对事件的响应。之后仅由滑动手势识别器接收事件并响应,oneView不再接收事件。另外,在滑动的过程中,若手势识别器未能识别手势,则事件在触摸滑动过程中会一直传递给hit-tested view,直到触摸结束。

总结:

总结一下UIGestureRecognizer与UIResponder对于事件响应的联系:

  1. 当触摸发生或者触摸的状态发生变化时,UIWindow都会传递事件寻求响应。
  2. UIWindow先将绑定了触摸对象的事件传递给触摸对象上绑定的手势识别器,再发送给触摸对象对应的hit-tested view。
  3. 手势识别器识别手势期间,若触摸对象的触摸状态发生变化,事件都是先发送给手势识别器再发送给hit-test view。
  4. 手势识别器若成功识别了手势,则通知Application取消hit-tested view对于事件的响应,并停止向其发送事件。
  5. 若手势识别器未能识别手势,而此时触摸并未结束,则停止向手势识别器发送事件,仅向hit-tested view发送事件。
  6. 若手势识别器未能识别手势,且此时触摸已结束,则向hit-tested view发送end状态的touch事件以停止对事件的响应。

注意:手势的种类怎么分辨出来:通过tap、pan、swipe手势的touchesBegan:withEvent等四个方法来识别。

2. 手势冲突

① 同一个视图中的不同手势之间的冲突

如果在同一个视图上添加不同的手势时,也有可能会发生冲突。照例先上代码:

#import "ViewController.h"
#import "oneView.h"
#import "twoView.h"
#import "SubTapGesture.h"

@interface ViewController ()
@property (nonatomic,strong) UIImageView *imageView;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    UIImageView *imageV = [[UIImageView alloc] initWithFrame:CGRectMake(100, 200, 150, 150)];
    imageV.backgroundColor = [UIColor orangeColor];
    imageV.userInteractionEnabled = YES;
    [self.view addSubview:imageV];
    self.imageView = imageV;
    
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
    [self.imageView addGestureRecognizer:pan];
    
    UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(swipe:)];
   // [pan requireGestureRecognizerToFail:swipe];
    [self.imageView addGestureRecognizer:swipe];
}

- (void)pan:(UIPanGestureRecognizer *)gestureRecognizer
{
    if (gestureRecognizer.state == UIGestureRecognizerStateChanged) {
        CGPoint point = [gestureRecognizer translationInView:self.imageView];
        self.imageView.transform = CGAffineTransformMakeTranslation(point.x, point.y);
    }else if (gestureRecognizer.state == UIGestureRecognizerStateEnded){
        [UIView animateWithDuration:0.3 animations:^{
            self.imageView.transform = CGAffineTransformIdentity;
        }];
    }
    NSLog(@"%s", __func__);
}

- (void)swipe:(UISwipeGestureRecognizer *)gestureRecognizer
{
    static BOOL flag = NO;
    self.imageView.backgroundColor = flag ? [UIColor redColor] : [UIColor yellowColor];
    flag = !flag;
    NSLog(@"%s", __func__);
}
@end

代码很简单,最初我们的目的是在imageView上面添加一个拖动手势,一个轻扫手势。拖动的时候改变图片的位置,轻扫的时候切换图片背景色。

可以发现只能拖动,并不能轻扫,打印结果如下:

2019-10-15 11:33:54.879326+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:54.921684+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:54.921876+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:54.946425+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:54.968451+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:54.991616+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:55.004011+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:55.914669+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:55.936952+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:55.937278+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:55.964659+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:55.997283+0800 点击事件测试[12889:5831620] -[ViewController pan:]
2019-10-15 11:33:56.014573+0800 点击事件测试[12889:5831620] -[ViewController pan:]

补充:手势的状态

typedef NS_ENUM(NSInteger, UIGestureRecognizerState) {
    UIGestureRecognizerStatePossible,   // 尚未识别是何种手势,但是可能已经触发触摸事件,默认状态
    UIGestureRecognizerStateBegan,      // 手势已经被识别,手势开始,但这个过程可能发生改变,手势操作尚未完成,action方法将在下一轮运行循环调用
    UIGestureRecognizerStateChanged,    // 手势发生改变,action方法将在下一轮运行循环调用
    UIGestureRecognizerStateEnded,      // 手势结束,action方法将在下一轮运行循环调用,之后变成默认状态
    UIGestureRecognizerStateCancelled,  // 手势取消,action方法将在下一轮运行循环调用,之后变成默认状态
    UIGestureRecognizerStateFailed,     // 手势失败,不会调用action方法将,之后变成默认状态
    UIGestureRecognizerStateRecognized = UIGestureRecognizerStateEnded  //同UIGestureRecognizerStateEnded
};

可以看到,尽管我减小了轻扫的幅度,加快了速度,轻扫手势依然没有起作用,就是因为轻扫和拖动这两个手势起了冲突。冲突的原因很简单,拖动手势的操作事件是在手势的开始状态(状态1)识别执行的,而轻扫手势的操作事件只有在手势结束状态(状态3)才能执行,因此轻扫手势就作为了牺牲品没有被正确识别。要解决这个冲突可以利用requireGestureRecognizerToFail:方法来完成,这个方法可以指定某个手势执行的前提是另一个手势识别失败。

这里我们把拖动手势设置为轻扫手势识别失败之后执行,这样一来我们手指轻轻滑动时系统会优先考虑轻扫手势,如果最后发现该操作不是轻扫,那么就会执行拖动。
只需要添加代码:

[pan requireGestureRecognizerToFail:swipe];

会发现就可以实现拖动图片和轻扫改变图片颜色.

② 不同视图上的手势冲突

在上面响应者链的学习中,我们知道了UIResponder响应事件的时候是有优先级的,上层触摸事件执行后就不再向下传播。默认情况下手势也是类似的,先识别的手势会阻断手势识别操作继续传播。下面我们用代码验证一下:
我们在控制器的视图上面添加一个黄色的子视图,然后在黄色视图上面添加一个自定义的滑动手势,在控制器的view上面也添加一个自定义的滑动手势。在自定义的滑动手势里面重写touchBegan:withEvent:这四个相关的方法.

@interface GestureRecognizer : UIPanGestureRecognizer
@property (nonatomic, strong) NSString *panName;
@end

@implementation GestureRecognizer

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesBegan:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesMoved:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesEnded:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesCancelled:touches withEvent:event];
    NSLog(@"%s--%@", __func__, self.panName);
}
@end

ViewController.m代码

@interface ViewController ()
@property (weak, nonatomic) IBOutlet YellowView *yellowView;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    GestureRecognizer *pan = [[GestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
    pan.panName = @"第一个";
    [self.yellowView addGestureRecognizer:pan];

    GestureRecognizer *panBottom = [[GestureRecognizer alloc] initWithTarget:self action:@selector(panBottom:)];
    panBottom.panName = @"第二个";
    [self.view addGestureRecognizer:panBottom];
}

- (void)pan:(UIPanGestureRecognizer *)gestureRecognizer
{
    NSLog(@"%s", __func__);
}

- (void)panBottom:(UIGestureRecognizer *)gestureRecognizer{
    NSLog(@"%s", __func__);
}

在黄色视图上滑动,输出以下结果:
滑动黄色视图.png

可以看出,在手势识别期间,UIWindow会依次向两个手势识别器和hit-test view发送事件,在第一个滑动手势(pan)识别成功后,UIWindow会停止向视图上的第二个滑动手势(panBottom)发送事件,所以导致其action无法被调用,从而产生冲突。

Window怎么知道要把事件传递给哪些手势识别器?
为什么停止接收事件的是第二个滑动手势呢?

之前探讨过Application怎么知道要把event传递给哪个Window,以及Window怎么知道要把event传递给哪个hit-tested view的问题,答案是这些信息都保存在event所绑定的touch对象上。
手势识别器也是一样的,event绑定的touch对象上维护了一个手势识别器数组,里面的手势识别器毫无疑问是在hit-testing的过程中收集的。在自定义Window中重写sendEvent方法, 打个断点看一下touch上绑定的手势识别器数组gestureRecognizers,手势识别的优先级跟数组的数据是保持一致的。


gestureRecognizers.png

我们可以看到,第一个滑动手势储存在数组的最前面,他的优先级比较高,所以会首先被响应。

那么如何让两个有层次关系并且都添加了手势的控件都能正确识别手势呢?答案就是利用手势代理的gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:代理方法。

苹果官方是这么描述这个方法的:

Asks the delegate if two gesture recognizers should be allowed to recognize gestures simultaneously.
This method is called when recognition of a gesture by either gestureRecognizer or otherGestureRecognizer would block the other gesture recognizer from recognizing its gesture. Note that returning YES is guaranteed to allow simultaneous recognition; returning NO, on the other hand, is not guaranteed to prevent simultaneous recognition because the other gesture recognizer's delegate may return YES.

这个方法主要是为了询问手势的代理,是否允许两个手势识别器同时识别该手势。返回YES可以确保允许同时识别手势,返回NO的话不能保证一定不能同时识别,因为其他手势的代理也有可能返回YES。
在上面demo的ViewController中遵循UIGestureRecognizerDelegate协议,设置第一个手势的代理为self,添加如下代码:

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(nonnull UIGestureRecognizer *)otherGestureRecognizer
{
    return YES;
}

打印:
pan和panbottom都会响应.png

可以看到两个手势的action同时被调用了。

3. UIControl

先看继承关系:

UIGestureRecognizer : NSObject
UILabel : UIView : UIResponder : NSObject
UIButton : UIControl : UIView : UIResponder : NSObject

UIControl是系统提供的能够以target-action模式处理触摸事件的控件,iOS中UIButton、UISegmentControl、UISwitch等控件都是UIControl的子类。值得注意的是,UIControl是UIView的子类,因此本身也具有UIResponder的属性。UIControl是一个抽象基类,我们不能直接使用UIControl类来实例化控件,它只是为控件子类定义一些通用的接口,并提供一些基础实现。

UIControl作为能够响应事件的控件,必然也需要待事件交互符合条件时才去响应,因此也会跟踪事件发生的过程,不同于UIResponder以及UIGestureRecognizer通过touches系列方法跟踪,UIControl有其独特的跟踪方式:

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)endTrackingWithTouch:(nullable UITouch *)touch withEvent:(nullable UIEvent *)event;
- (void)cancelTrackingWithEvent:(nullable UIEvent *)event;

乍一看,这四个方法和UIResponder那四个方法几乎吻合,只不过UIControl只能接收单点触控,因此这四个方法的参数是单个的UITouch对象。这四个方法的职能也和UIResponder一致,用来跟踪触摸的开始、滑动、结束、取消。不过,UIControl本身也是UIResponder,因此同样有touches系列的4个方法。事实上,UIControl的Tracking系列方法是在touch系列方法内部调用的。比如beginTrackingWithTouch是在touchesBegan方法内部调用的。这个我们也可以验证:
自定义一个继承于UIControl的子类,重写beginTrackingWithTouch和touchesBegan:withEvent:方法,并且给重写的Control添加action和target。
代码如下:

//ViewController.m代码
#import "ViewController.h"
#import "subControl.h"

@interface ViewController ()
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    subControl *control = [[subControl alloc]initWithFrame:CGRectMake(50, 50, 100, 100)];
    control.backgroundColor = UIColor.redColor;
    [control addTarget:self action:@selector(subControlClicked) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:control];
}

- (void)subControlClicked{
    NSLog(@"点击了subcontrol");
}
@end

//subControl.m代码
@implementation subControl
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"进入%s", __func__);
    [super touchesBegan:touches withEvent:event];
    NSLog(@"离开%s", __func__);
}

- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
    return [super beginTrackingWithTouch:touch withEvent:event];
}
@end

点击自定义的Control,输出以下结果:

2019-10-15 13:57:33.457752+0800 自定义UICOntrol[14246:5894041] 进入-[subControl touchesBegan:withEvent:]
2019-10-15 13:57:33.458154+0800 自定义UICOntrol[14246:5894041] -[subControl beginTrackingWithTouch:withEvent:]
2019-10-15 13:57:33.472355+0800 自定义UICOntrol[14246:5894041] 离开-[subControl touchesBegan:withEvent:]
2019-10-15 13:57:33.585599+0800 自定义UICOntrol[14246:5894041] 点击了subcontrol

可以看出,先调用touchsBegin方法, touchesBegan方法再调用了beginTrackingWithTouch方法,最后再调控件的action方法。这也说明了另外一个问题,UIControl的touchesBegan方法的实现与UIResponder的touchesBegan方法是有区别的。

当UIControl跟踪事件的过程中,识别出事件交互符合响应条件,(就是根据四个touch方法来识别的)就会触发target-action进行响应。UIControl控件通过addTarget:action:forControlEvents:添加事件处理的target和action,当事件发生时,UIControl会调用sendAction:to:forEvent:来将event发送给UIApplication对象,再由UIApplication对象调用其sendAction:to:fromSender:forEvent:方法来将消息分发到指定的target上。
下面是方法调用栈:

方法调用栈.png

因此,我们可以通过重写UIControl的sendAction:to:forEvent:或sendAction:to:from:forEvent:自定义事件执行的target及action。

If you specify nil for the target object, the control searches the responder chain for an object that defines the specified action method.

另外,若不指定target,即addTarget:action:forControlEvents:时target传空,那么当事件发生时,UIControl会在响应链从下往上寻找定义了指定action方法的对象来响应该action。

可以简单总结button事件的响应流程

  1. 通过hitTest方法找到button
  2. 通过四个touch方法来识别action
  3. button再调用sendAction:to:forEvent:来将event发送给UIApplication对象
  4. UIApplication对象调用其sendAction:to:fromSender:forEvent:方法来将消息分发到指定的target上

4. UIResponder、UIGestureRecognizer、UIControl之间的优先级

上面我们已经分析过了,UIGestureRecognizer的优先级是比UIResponder的优先级高的,那么如果再加上一个UIControl呢?

我们先来比较一下UIControl和UIResponder之间的优先级关系,这里的UIResponder我们用UIView来代替。

首先如果UIControl添加在UIView上面的时候,毋庸置疑,UIControl会首先响应,参照button添加在视图上。

那么如果把UIView添加在UIControl上面的时候,谁会响应事件呢?我们用代码来验证一下:
自定义一个UIView,重写touchesBegan:方法

@implementation YellowView
- (void)touchesBegan:(NSSet<UITouch *> *)touches   withEvent:(UIEvent *)event
{
    NSLog(@"%s", __func__);
}
@end

自定义一个UIControl,里面什么都不用写。

在ViewController里面添加如下代码:

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    Control *control = [[Control alloc] initWithFrame:CGRectMake(100, 100, 100, 200)];
    control.backgroundColor = [UIColor redColor];
    [control addTarget:self action:@selector(clickControl:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:control];

    YellowView *yellowView = [[YellowView alloc] initWithFrame:CGRectMake(10, 10, 50, 50)];
    yellowView.backgroundColor = [UIColor yellowColor];
    [control addSubview:yellowView];
}

- (void)clickControl:(UIControl *)control
{
    NSLog(@"click Control");
}
@end

效果图:
control里面添加View.png

红色是Control中间添加一个黄色的view,点击黄色View和红色Control打印如下:

//点击黄色View
2019-10-15 14:42:16.336325+0800 自定义UICOntrol[15540:5955840] -[YellowView touchesBegan:withEvent:]

//点击红色Control
2019-10-15 14:42:23.004017+0800 自定义UICOntrol[15540:5955840] click Control

可以看到点击什么响应什么, 看来自定义的UIControl与UIResponder之间的优先级还是遵循响应链的层级的,这就表示UIResponder和UIControl的优先级是相同的,而UIGestureRecognizer的优先级比UIControl高,由此推断的话,UIGestureRecognizer的优先级好像是比UIControl高的,具体是什么样子的,我们还是来验证一下。

现在我们把层级交换一下,把UIControl添加到yellowView上面,然后给yellowView添加一个tap手势,代码如下:

#import "ViewController.h"
#import "SubControl.h"
#import "YellowView.h"

@interface ViewController ()
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    YellowView * yellowView = [[YellowView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    yellowView.backgroundColor = [UIColor yellowColor];
    [self.view addSubview: yellowView];
    
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tap:)];
    [blueView addGestureRecognizer:tap];
    
    SubControl *control = [[SubControl alloc] initWithFrame:CGRectMake(50, 50, 100, 100)];
    control.backgroundColor = [UIColor redColor];
    [control addTarget:self action:@selector(clickControl:) forControlEvents:UIControlEventTouchUpInside];
    [yellowView addSubview:control];
}

- (void)clickControl:(UIControl *)control
{
    NSLog(@"click Control");
}

- (void)tap:(UIGestureRecognizer *)gestureRecognizer
{
    NSLog(@"tap");
}
@end

效果图:
view里添加control.png

可以发现无论点击红色Control还是黄色View都打印:

2019-10-15 14:57:33.966344+0800 自定义UICOntrol[15953:5973486] tap
2019-10-15 14:57:34.732795+0800 自定义UICOntrol[15953:5973486] tap

可以看到,系统执行了手势的tap方法,并没有执行UIControl的action,这好像跟我们上面预测的手势的优先级比UIControl高是一致的。但是真的是这样吗?我们把自定义的UIControl替换成UIButton,其它地方不变,再点击一次button,打印结果变成了这样:

//点击button
2019-10-15 15:05:02.579222+0800 自定义UICOntrol[16155:5987541] button click
//点击黄色View
2019-10-15 15:05:25.832473+0800 自定义UICOntrol[16155:5987541] tap

同样都是继承于UIControl,这control和control的差别咋就那么大捏???
别急,苹果爸爸已经给了我们合理的解释:

In iOS 6.0 and later, default control actions prevent overlapping gesture recognizer behavior. For example, the default action for a button is a single tap. If you have a single tap gesture recognizer attached to a button’s parent view, and the user taps the button, then the button’s action method receives the touch event instead of the gesture recognizer.This applies only to gesture recognition that overlaps the default action for a control, which includes:
A single finger single tap on a UIButton, UISwitch, UIStepper, UISegmentedControl, and UIPageControl.
A single finger swipe on the knob of a UISlider, in a direction parallel to the slider.
A single finger pan gesture on the knob of a UISwitch, in a direction parallel to the switch.

在iOS6以后,默认的control actions会阻止与该action操作相同的手势的识别。例如:UIButton的默认操作是单击,如果你在这个button的父视图上面添加了一个tap手势,用户单击button,系统会调用button的action而不是手势的action。这种规则仅仅适用于手势操作和UIcontrol的默认操作相同的情况下,包含以下几种情况:

单击:UIButton,UISwitch,UIStepper,UISegmentedControl , UIPageControl,==> 手势的tap单点操作
滑动:UISlider,==> 手势的滑动方向与slider平行
拖动:UISwitch,==> 手势拖动方向与switch平行

这里提到了两点:

  1. 第一是手势和UIControl的默认操作相同,也就是说如果UIControl没有默认操作(比如我们自定义的UIControl)或者是默认操作和添加的手势不同,那么手势识别器的识别优先级高,UIControl不会阻止手势识别。
  2. 第二是在UIButton的父视图上添加手势,如果你把一个添加了手势的视图盖在UIButton上面,那么UIButton是不能阻止该手势识别的。这两点读者可以自行验证。

总结:

① UIGestureRecognizer优先级最高, 自定义的UIControl和UIResponder的优先级相同。
② 有默认操作的UIControl会阻止添加在父视图上面的有相同操作的手势的识别(苹果封装好的控件不能不能点击啊)。

5. tableView父视图添加tap手势分析

ViewController的View添加一个tap手势和一个自定义的tableView,在tableView.m里面重写touchsBegin方法
ViewController的View里面再添加一个自定义按钮,并且在自定义按钮里面重写touchsBegin方法, 如下图:


tableView.png

自定义tableView和自定义按钮都添加如下代码:

#import "MyTableView.h"
@implementation MyTableView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"%s调用", __func__);
    [super touchesBegan:touches withEvent:event];
}
//- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
//    NSLog(@"%s调用", __func__);
//    [super touchesMoved:touches withEvent:event];
//}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
    NSLog(@"%s调用", __func__);
    [super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
    NSLog(@"%s调用", __func__);
    [super touchesCancelled:touches withEvent:event];
}
@end

控制器代码如下:

#import "ViewController.h"
#import "MyTableView.h"
#import "Custombtn.h"

@interface ViewController () <UITableViewDelegate,UITableViewDataSource>

@property (nonatomic, strong) MyTableView *securityCenterTableView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(actionTapView)];
    //tap.cancelsTouchesInView = NO;
    [self.view addGestureRecognizer:tap];
    [self.view addSubview:self.securityCenterTableView];
    
    Custombtn *btn = [Custombtn buttonWithType:UIButtonTypeCustom];
    btn.frame = CGRectMake(200, 700, 100, 100);
    btn.backgroundColor = [UIColor blueColor];
    [btn addTarget:self action:@selector(btnClicked) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:btn];
}

- (void)actionTapView {
    NSLog(@"backView的tap手势调用");
}

- (void)btnClicked {
    NSLog(@"点击按钮");
}

- (MyTableView *)securityCenterTableView {
    if (!_securityCenterTableView) {
        _securityCenterTableView = [[MyTableView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height) style:UITableViewStyleGrouped];
        _securityCenterTableView.separatorStyle = UITableViewCellSeparatorStyleSingleLine;
        _securityCenterTableView.showsVerticalScrollIndicator = NO;
        _securityCenterTableView.backgroundColor = [UIColor whiteColor];
        _securityCenterTableView.delegate = self;
        _securityCenterTableView.dataSource = self;
        _securityCenterTableView.accessibilityIdentifier = @"SecurityCenterTableView";
        _securityCenterTableView.estimatedRowHeight = 0;
        _securityCenterTableView.estimatedSectionFooterHeight = 0;
        _securityCenterTableView.estimatedSectionHeaderHeight = 0;
        if (@available(iOS 11.0, *)) {
            [_securityCenterTableView setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentNever];
        }
    }
    return _securityCenterTableView;
}

#pragma mark - UITableViewDataSource & UITableViewDelegate

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 10;
}

- (UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    static NSString *rid = @"SecurityCenterCellIdentify";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:rid];
    if (cell == nil) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
    }
    cell.backgroundColor = [UIColor orangeColor];
    cell.textLabel.text = @"点我";
    return cell;
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    NSLog(@"点击了cell的didSelectRowAtIndexPath方法");
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return 60;
}
@end

打印结果如下:
现象一:
快速点击cell

2019-10-16 10:49:24.677693+0800 tableView点击[27682:6352478] backView的tap手势调用

现象二:
短按cell

2019-10-16 10:49:24.402479+0800 tableView点击[27682:6352478] -[MyTableView touchesBegan:withEvent:]调用
2019-10-16 10:49:24.677693+0800 tableView点击[27682:6352478] backView的tap手势调用
2019-10-16 10:49:24.678054+0800 tableView点击[27682:6352478] -[MyTableView touchesCancelled:withEvent:]调用

现象三:
长按cell

2019-10-16 10:50:27.551722+0800 tableView点击[27682:6352478] -[MyTableView touchesBegan:withEvent:]调用
2019-10-16 10:50:31.244846+0800 tableView点击[27682:6352478] -[MyTableView touchesEnded:withEvent:]调用
2019-10-16 10:50:31.245862+0800 tableView点击[27682:6352478] 点击了cell的didSelectRowAtIndexPath方法

现象四:
点击按钮

2019-10-16 11:27:27.007714+0800 tableView点击[28595:6393497] -[Custombtn touchesBegan:withEvent:]调用
2019-10-16 11:27:27.099852+0800 tableView点击[28595:6393497] -[Custombtn touchesEnded:withEvent:]调用
2019-10-16 11:27:27.100396+0800 tableView点击[28595:6393497] btnClicked点击了按钮

先看现象二, 短按后,View上的手势识别器先接收到事件,之后事件传递给hit-tested view,作为响应者链中一员的myTableView的 touchesBegan:withEvent: 被调用;而后手势识别器成功识别了点击事件,action执行,同时通知Application取消响应链中的事件响应,myTableView的 touchesCancelled:withEvent: 被调用。
因为事件被取消了,因此Cell无法响应点击。

再看现象三,长按cell能够响应
长按的过程中,一开始事件同样被传递给手势识别器和hit-tested view,作为响应链中一员的myTableView的 touchesBegan:withEvent: 被调用;此后在长按的过程中,手势识别器一直在识别手势,直到一定时间后手势识别失败,才将事件的响应权完全交给响应链。当触摸结束的时候,myTableView的 touchesEnded:withEvent: 被调用,同时Cell响应了点击。

OK,现在回到现象一。按照之前的分析,快速点击cell,讲道理不管是表现还是日志都应该和现象二一致才对。然而日志仅仅打印了手势识别器的action执行结果。分析一下原因:myTableView的 touchesBegan 没有调用,说明事件没有传递给hit-tested view。那只有一种可能,就是事件被某个手势识别器拦截了。目前已知的手势识别器拦截事件的方法,就是设置 delaysTouchesBegan 为YES,在手势识别器未识别完成的情况下不会将事件传递给hit-tested view。然后事实上并没有进行这样的设置,那么问题可能出在别的手势识别器上。
Window的 sendEvent: 打个断点查看event上的touch对象维护的手势识别器数组:

1510019-786581b9d7281d92.png

捕获可疑对象:UIScrollViewDelayedTouchesBeganGestureRecognizer ,光看名字就觉得这货脱不了干系。从类名上猜测,这个手势识别器大概会延迟事件向响应链的传递。github上找到了该私有类的头文件

@interface UIScrollViewDelayedTouchesBeganGestureRecognizer : UIGestureRecognizer {
    UIView<UIScrollViewDelayedTouchesBeganGestureRecognizerClient> * _client;
    struct CGPoint { 
        float x; 
        float y; 
    }  _startSceneReferenceLocation;
    UIDelayedAction * _touchDelay;
}
- (void).cxx_destruct;
- (id)_clientView;
- (void)_resetGestureRecognizer;
- (void)clearTimer;
- (void)dealloc;
- (void)sendDelayedTouches;
- (void)sendTouchesShouldBeginForDelayedTouches:(id)arg1;
- (void)sendTouchesShouldBeginForTouches:(id)arg1 withEvent:(id)arg2;
- (void)touchesBegan:(id)arg1 withEvent:(id)arg2;
- (void)touchesCancelled:(id)arg1 withEvent:(id)arg2;
- (void)touchesEnded:(id)arg1 withEvent:(id)arg2;
- (void)touchesMoved:(id)arg1 withEvent:(id)arg2;
@end

有一个_touchDelay变量,大概是用来控制延迟事件发送的。另外,方法列表里有个 sendTouchesShouldBeginForDelayedTouches: 方法,听名字似乎是在一段时间延迟后向响应链传递事件用的。为一探究竟,我创建了一个类hook了这个方法:

//TouchEventHook.m
+ (void)load{
    Class aClass = objc_getClass("UIScrollViewDelayedTouchesBeganGestureRecognizer");
    SEL sel = @selector(hook_sendTouchesShouldBeginForDelayedTouches:);
    Method method = class_getClassMethod([self class], sel);
    class_addMethod(aClass, sel, class_getMethodImplementation([self class], sel), method_getTypeEncoding(method));
    exchangeMethod(aClass, @selector(sendTouchesShouldBeginForDelayedTouches:), sel);
}

- (void)hook_sendTouchesShouldBeginForDelayedTouches:(id)arg1{
    [self hook_sendTouchesShouldBeginForDelayedTouches:arg1];
}

void exchangeMethod(Class aClass, SEL oldSEL, SEL newSEL) {
    Method oldMethod = class_getInstanceMethod(aClass, oldSEL);
    Method newMethod = class_getInstanceMethod(aClass, newSEL);
    method_exchangeImplementations(oldMethod, newMethod);
}

断点看一下点击cell后 hook_sendTouchesShouldBeginForDelayedTouches: 调用时的信息:
1510019-bb69397f67b4eb44.png

可以看到这个手势识别器的 _touchDelay 变量中,保存了一个计时器,以及一个长得很像延迟时间间隔的变量m_delay。现在,可以推测该手势识别器截断了事件并延迟0.15s才发送给hit-tested view。为验证猜测,我分别在Window的 sendEvent: ,hook_sendTouchesShouldBeginForDelayedTouches: 以及TableView的 touchesBegan: 中打印时间戳,若猜测成立,则应当前两者的调用时间相差0.15s左右,后两者的调用时间很接近。短按Cell后打印结果如下(不能快速点击,否则还没过延迟时间触摸就结束了,无法验证猜测):

-[GLWindow sendEvent:]调用时间戳 :
525252194779.07ms
-[TouchEventHook hook_sendTouchesShouldBeginForDelayedTouches:]调用时间戳 :
525252194930.91ms
-[TouchEventHook hook_sendTouchesShouldBeginForDelayedTouches:]调用时间戳 :
525252194931.24ms
-[myTableView touchesBegan:withEvent:]调用时间戳 :
525252194931.76ms

这样就都解释得通了。现象一由于点击后,UIScrollViewDelayedTouchesBeganGestureRecognizer 拦截了事件并延迟了0.15s发送。又因为点击时间比0.15s短,在发送事件前触摸就结束了,因此事件没有传递到hit-tested view,导致TableView的 touchBegin 没有调用。而现象二,由于短按的时间超过了0.15s,手势识别器拦截了事件并经过0.15s后,触摸还未结束,于是将事件传递给了hit-tested view,使得TableView接收到了事件。

至于现象四 ,你现在应该已经觉得理所当然了才对。

但是现在又有两个问题了:

  1. 如果想要点击cell时候tap和cellselected都响应,怎么办?

可以设置tap.cancelsTouchesInView = NO; 如果为YES,手势识别了,会取消touch事件。

设置之后打印如下, 可以发现都响应了

2019-10-16 16:02:06.473031+0800 tableView点击[33500:6608421] backView的tap手势调用
2019-10-16 16:02:06.476210+0800 tableView点击[33500:6608421] -[MyTableView touchesBegan:withEvent:]调用
2019-10-16 16:02:06.477194+0800 tableView点击[33500:6608421] -[MyTableView touchesEnded:withEvent:]调用
2019-10-16 16:02:06.479013+0800 tableView点击[33500:6608421] 点击了cell的didSelectRowAtIndexPath方法
  1. 如何点击cell的时候只让cell的didSelected方法响应,不让tap方法响应。

可以在tap的代理方法里面写如下代码, 根据点击的是哪个View来让手势是否失效

//- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
//    //找到你这个手势的view,如果这个view是cell,那么手势不响应
//    UIView *view = gestureRecognizer.view;  //gestureRecognizer.view = 永远获取的是你这个手势绑定的view,并不是点击的View. 所以不用这个方法
//    return NO;
//}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    UIView *view = touch.view;
    //如果点击的了cell 就返回NO
    //实现点击cell调用didselectedCell方法,不调用tap方法
    return ![view isKindOfClass:NSClassFromString(@"UITableViewCellContentView")];
}

6. scrollView相关分析

① 写一个简单的轮播图

效果图如下:
简单轮播图.png

上面虚线区域是scrollView的区域,图片区域是scrollView的x坐标往右移动20px的区域,我们想让scrollView可点击的区域变成整个屏幕的宽度,代码如下:

ViewController.m代码如下:

#import "EOCEventCase_ScrollView.h"
#import "EOCScrollView.h"
@interface EOCEventCase_ScrollView () {
    NSArray *imageArr;
}
@end

@implementation EOCEventCase_ScrollView

- (void)viewDidLoad {
    
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    self.navigationItem.title = @"简单轮播图";
    imageArr = @[@"0", @"1", @"2", @"3", @"4"];
    [self scrollViewDemo];  
}

- (void)scrollViewDemo {
    NSInteger count = 5;
    //侧滑返回不显示图片,裁剪掉
    self.view.clipsToBounds = YES; 
    
    EOCScrollView *scrollView = [[EOCScrollView alloc] initWithFrame:CGRectMake(20.f, 100.f, self.view.eocW-60.f, (self.view.eocW-80.f)/2)];
    scrollView.pagingEnabled = YES;
    scrollView.contentSize = CGSizeMake((self.view.eocW-60.f)*5, (self.view.eocW-80.f)/2);
    scrollView.clipsToBounds = NO;  //不自动裁剪,好让左右都显示
    [self.view addSubview:scrollView];
    
    // 手势的互斥 滑动手势优先级大于左侧滑返回
    [self.navigationController.interactivePopGestureRecognizer requireGestureRecognizerToFail:scrollView.panGestureRecognizer];
    
    //添加图片
    for (int i =0; i<count; i++) {
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(20.f+i*(self.view.eocW-60.f), 0.f, self.view.eocW-80.f, (self.view.eocW-80.f)/2)];
        imageView.image = [UIImage imageNamed:imageArr[I]];
        [scrollView addSubview:imageView];
    }
}
@end

自定义的ScrollView代码如下:

//把scrollView的可点击区域变大,左右都可以点击
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    
    CGRect rect = self.bounds;
    rect.origin.x -= 20.f; //让响应区域x变大
    rect.size.width = [UIScreen mainScreen].bounds.size.width;  //让响应的区域宽度变为整个屏幕的宽度
    if (CGRectContainsPoint(rect, point)) {
        return YES;
    }
    
    return [super pointInside:point withEvent:event];
}

上图的虚线区域是scrollView的区域,但是我们想让scrollView的可点击区域变为整个屏幕的宽度,所以重写scrollView的pointInside:withEvent:方法,如上。

② scrollView上添加子视图

self.view上添加灰色View,灰色View上添加红色scrollView,红色scrollView上添加白色View,如下图:

示例.png

代码如下:

#import "EOCEventCase_ScrollView.h"
#import "EOCScrollView.h"
#import "EOCLightGrayView.h"
#import "EOCView.h"

@interface EOCEventCase_ScrollView ()
@property(nonatomic, strong)EOCLightGrayView *lightGrayView;
@property(nonatomic, strong)EOCView *customView;
@end

@implementation EOCEventCase_ScrollView

- (void)viewDidLoad {
    [super viewDidLoad];
    [self.view addSubview:self.lightGrayView];

    EOCScrollView *scrollView = [[EOCScrollView alloc] initWithFrame:self.view.bounds];
    scrollView.contentSize = CGSizeMake(self.view.eocW, 2*self.view.eocH);
    scrollView.backgroundColor = [UIColor redColor];

    //这两个属性控制滑动白色View的时候scrollView不动,滑动其他部分scrollView会动
    //类似于scrollView上添加sliderView
    scrollView.delaysContentTouches = NO;
    scrollView.canCancelContentTouches = NO;

    [self.lightGrayView addSubview:scrollView];
    [scrollView addSubview:self.customView];
}

- (EOCView *)customView {
    if (!_customView) {
        _customView = [[EOCView alloc] initWithFrame:CGRectMake(0.f, 100.f, self.view.eocW, (self.view.eocW-60.f)/2)];
        _customView.backgroundColor = [UIColor whiteColor];
    }
    return _customView;
}

- (EOCLightGrayView *)lightGrayView {
    if (!_lightGrayView) {
        CGFloat width = [UIScreen mainScreen].bounds.size.width;
        CGFloat height = [UIScreen mainScreen].bounds.size.height;
        _lightGrayView = [[EOCLightGrayView alloc] initWithFrame:CGRectMake(0.f, 0.f, width, height)];
        _lightGrayView.backgroundColor = [UIColor lightGrayColor];
    }
    return _lightGrayView;
}
@end

上面代码有两点可说:

  1. 设置scrollView.delaysContentTouches = NO;和scrollView.canCancelContentTouches = NO;是为了达到,滑动白色View的时候scrollView不动,滑动其他部分scrollView会动。关于这两个属性更详细的解释,可以参考:delaysContentTouches和canCancelContentTouches

  2. 点击白色View,最后面的lightGrayView不会调用touchesBegan方法,因为scrollView内部给阻止了,如果想要lightGrayView也调用touchesBegan方法,可以重写scrollView的touchesBegan方法,如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"EOCScrollView touchBegan");
    [self.nextResponder touchesBegan:touches withEvent:event];
}

同理:如果我们在scrollView上添加一个slider,如果想要实现滑动slider的时候scrollView不滚动,也可以设置scrollView.delaysContentTouches = NO;和scrollView.canCancelContentTouches = NO,代码如下:

#import "EOCEventCase_UISlider.h"

@interface EOCEventCase_UISlider ()

@property(nonatomic, strong)UIScrollView *scrollView;
@property(nonatomic, strong)UISlider *slider;

@end

@implementation EOCEventCase_UISlider

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    self.navigationItem.title = @"UISlider的事件处理";
    
    [self.view addSubview:self.scrollView];
    [self.scrollView addSubview:self.slider];
    
    //实现滑动sliderView后面的scrollView不滑动
    self.scrollView.delaysContentTouches = NO;
    self.scrollView.canCancelContentTouches = NO;
}

#pragma mark - event response
- (void)valueChange
{
    NSLog(@"valueChange");
}

#pragma mark - getter方法
- (UIScrollView *)scrollView
{
    if (!_scrollView) {
        _scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0.f, 0.f, self.view.eocW, self.view.eocH)];
        _scrollView.backgroundColor = [UIColor lightGrayColor];
        _scrollView.contentSize = CGSizeMake(2*self.view.eocW, self.view.eocH);
    }
    return _scrollView;
}

- (UISlider *)slider {
    
    if (!_slider) {
        _slider = [[UISlider alloc] initWithFrame:CGRectMake(50.f, 100.f, 200.f, 40.f)];
        [_slider addTarget:self action:@selector(valueChange) forControlEvents:UIControlEventValueChanged];
    }
    return _slider;
}
@end

四. 触摸事件的生命周期

看完本文和上一篇文章iOS触摸事件应该就能理解触摸事件的生命周期了。
手指触摸屏幕,触摸事件的传递大概经历了3个阶段,系统响应阶段-->SpringBoard.app处理阶段-->前台App处理阶段,大致的流程如下图:

触摸事件的生命周期.png

起始:cpu处于睡眠阶段,等待事件发生,然后手指触摸屏幕

1. 系统响应阶段

  1. 屏幕感应到触摸事件,并将感应到的事件传递给IOKit(用来操作硬件和驱动的框架,这是一个私有API,知道这个是干嘛的就行了)。
  2. IOKit.framework封装整个触摸事件为IOHIDEvent对象,直接通过mach port转发给SpringBoard.app。(Mach属于硬件层,仅提供了诸如处理器调度、IPC进程通信等非常少量的基础服务。在Mach中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为“对象”,Mach的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。消息是Mach中最基础的概念,消息在两个端口(port)之间传递,mach port就是IPC进程间通信的核心。更多内容请查看这篇文章)。

2. SpringBoard.app处理阶段

  1. SpringBoard.app的主线程Runloop收到IOKit.framework转发来的消息苏醒,并触发对应mach port的Source1回调__IOHIDEventSystemClientQueueCallback()。
  2. 如果SpringBoard.app监测到有App在前台(记为xxx.app),SpringBoard.app再通过mach port转发给xxx.app,如果SpringBoard.app监测到前台没有App运行,则SpringBoard.app进入App内部响应阶段,触发自身主线程runloop的Source0时间源的回调。

SpringBoard.app是一个系统进程,可以理解为桌面系统,可以统一管理和分发系统接收到的触摸事件。

3. App内部响应阶段

  1. 前台App主线程Runloop收到SpringBoard.app转发来的消息而苏醒,并触发对应mach port的Source1回调__IOHIDEventSystemClientQueueCallback()。
  2. Source1回调内部,触发Source0回调__UIApplicationHandleEventQueue()。
  3. Source0回调内部,封装IOHIDEvent为UIEvent。
  4. Source0回调内部,调用UIApplication的sendEvent:方法,将UIEvent传给UIWindow,接下来就是寻找最佳响应者的过程,也就是命中测试hit-testing。
  5. 寻找到最佳响应者后,接下来就是事件在响应链中的传递和响应了。需要注意的是,事件除了可以被响应者处理之外,还有可能被手势识别器或者target-action捕捉并处理,这涉及到一个优先级的问题(文章前面已经解释过了),如果触摸事件在响应链中没有找到能够响应该事件的对象,最终将被释放。
  6. 事件被处理或者释放之后,runloop如果没有其他事件进行处理,将会再次进入休眠状态。

Demo地址:https://github.com/iamkata/gesture

本文部分参考以下链接,如有侵权请联系删除。
iOS触摸事件处理
iOS触摸事件全家桶

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