iOS事件机制整理


目录

  • 相关概念
  • 事件冲突
  • 使用案例

相关概念

UIResponder

UIResponder类负责处理事件传递,UIView、UIViewController均继承自它。

// 下一个响应者
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

// 是否可以获得第一响应 默认NO
@property(nonatomic, readonly) BOOL canBecomeFirstResponder;
// 获得第一响应
- (BOOL)becomeFirstResponder;

// 是否已注销第一响应 默认YES
@property(nonatomic, readonly) BOOL canResignFirstResponder;
// 注销第一响应
- (BOOL)resignFirstResponder;

// 是否是第一响应者
@property(nonatomic, readonly) BOOL isFirstResponder;

用来处理三种事件:touch点击、press按压、motion摇动

// 点击
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);

// 按压
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);

// 摇动
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);

Hit-Testing

当runloop监听到有触摸事件发生时,会先走hit test过程找到最先响应事件的视图。

hit-test的过程:递归的方式从根节点遍历(会调用多次,原因不明)

1.从application开始出发,递归遍历并判断是否满足pointInside返回值为YES的条件;
2.如果满足条件且所有子节点不满足,则返回该节点。遍历子节点的规则是从最晚加入的开始遍历;
3.如果满足条件且为叶子节点,则返回该节点,停止遍历;
4.如果不满足条件,则返回nil;
5.最终会找到一个节点(view),然后会给这个view发送事件。

在UIView类中有以下两个方法:

// 用于判断触摸点是否在自身坐标范围内,如果是则返回yes,如果不是则返回no
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

// 返回当前视图的hittest结果
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; 

关于hitTest方法的判断过程:
1.如果pointInside返回结果==NO,返回nil
2.如果userInteractionEnabled == NO || hidden == YES || alpha < 0.01,返回nil
3.根据晚加入(addSubview)先判断的原则,依次执行子视图的hitTest方法,当返回非nil时,返回这个结果。
4.如果所有的子视图的hitTest方法都返回nil,则返回当前视图

事件响应链:

当通过hit test找到最先响应事件的视图后,则开始根据响应链执行响应方法(touchesBegan等方法),响应链的建立是通过UIResponder类中的nextResponder属性。

// 下一个响应者
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

规则:

1.普通UIView实例的nextResponder指向其父视图
view.nextResponder -> view.superview
2.UIViewController实例的view的nextResponder指向该控制器实例,控制器实例的nextResponder指向它的view的父视图
vc.view.nextResponder -> vc
vc.nextResponder -> view.superview(如果是最底层,则为window)
3.UIWindow实例的nextResponder指向UIApplication实例
window.nextResponder -> application

例如:在下面的视图层级关系中,每个类都实现了如下的touchesBegan方法,点击view2

window
    —— zcpview
        —— zcpview1
        —— zcpview2
        —— imageview
        —— label

// 以上每个类都实现该方法
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%@", self);
    // 注意此处调用了super的touchesBegan方法,如果不调则不会根据响应链继续执行下去。
    [super touchesBegan:touches withEvent:event];
}

结果为:

点击了<ZCPView2: 0x7fdeef51a460; frame = (10 -50; 80 100); layer = <CALayer: 0x600003ff65e0>>
点击了<ZCPView: 0x7fdeef519e50; frame = (100 500; 100 100); layer = <CALayer: 0x600003ff6580>>
点击了<ZCPWindow: 0x7fdeef51aa70; baseClass = UIWindow; frame = (0 0; 414 896); gestureRecognizers = <NSArray: 0x6000031b08a0>; layer = <UIWindowLayer: 0x600003ff6620>>

响应链为:

view2 -> view -> window

手势识别器

手势优先级比事件响应链高,当有事件时会先判断是否为手势,如果不满足手势,则走响应链。

实际上判断是否为手势需要一段时间,在这段时间会先走响应链,如果手势被识别,则会取消响应链。

ps:关于具体内容iOS 七种手势详解(动图+Demo下载)

UIControl

UIKit框架中提供的那些UIControl的子类如UIButton、UISwitch、UISlider等,使用了target-action模式,当点击这些视图时,它们和继承自它们的子类都会阻止其父视图(可以隔代)的手势识别器行为

需要注意的是:

1.UIControl的target-action响应阻止的是它的父视图的手势识别器

2.自己写的继承自UIControl的类不行。(猜测阻止手势识别器的处理是写在UIControl的继承类中的,所以自己写的继承自UIControl的类无法阻止手势识别器)

3.如果这些类重写了touches系列方法,且began和end方法只要有一个没有执行super调用,就不会走action了。(猜测这些类重写了UIView的touches系列方法,然后在其中处理了action事件)

事件冲突

案例一 手势影响响应链的情况

1.现象与解释:

我们来看一个简单的案例,下图是一个很简单的首页,在输入框内输入关键字,点击搜索按钮进行搜索,点击+号展开列表。另外的要求是,点击输入框弹出键盘,点击view缩回键盘。

clipboard.png

部分代码实现:

// 初始化部分
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 给view添加手势
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapSelfView)];
    self.view.userInteractionEnabled = YES;
    [self.view addGestureRecognizer:tap];
    
    // 点击搜索按钮
    [self.searchButton addTarget:self action:@selector(clickSearchButton) forControlEvents:UIControlEventTouchUpInside];
}

// view的手势事件处理
- (void)tapSelfView {
    NSLog(@"tapSelfView");
    // 缩回键盘
    [self.view endEditing:YES];
}

// 搜索按钮的点击事件处理
- (void)clickSearchButton {
    NSLog(@"点击了搜索按钮");
}

// 列表cell的点击事件
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    NSLog(@"点击了第%li个cell", indexPath.row);
}

然后我们操作一番后会发现一系列现象:
现象1:点击搜索按钮可以触发clickSearchButton方法;
现象2:点击搜索框可以触发弹出键盘;
现象3:点击文本输入框可以触发弹出键盘;
现象4:无论如何单击cell,都不会触发cell的点击事件;
现象5:按住cell不放不超过1.5秒然后松开,可以看到cell的selectionStyle效果,但不会触发cell的点击事件;
现象6:长时间按住cell不放然后松开,可以发现触发了cell的点击事件。

现象1和现象2:

由于UIButton和UITextField都继承自UIControl,点击它们时它们会阻止其父视图(也就是view)的手势识别器行为。所以button和textfield可以正常响应事件。

现象3

在ZCPTextView中实现touches系列方法,加断点后可以发现,猜测应该是响应链被手势中断了。

textView touchesBegan
textView touchesCancelled

然后在ZCPTextView中实现UIGestureRecognizerDelegate代理方法。加断点可以看到走了这些方法。可以推断出,UITextView使用了手势,并没有走响应链,所以不会受self.view的手势影响。

现象4

因为手势优先级比事件响应链高,当有个touch事件时会先判断是否为手势,如果不满足手势,则走响应链。此处满足手势,然后被view处理,走tapSelfView方法。而点击cell的响应链被取消,从而导致并未触发cell的点击事件。

现象5

为了探究这个现象,我们来加一些代码用来观察cell被点击的过程。

// ZCPCell.m
// cell重写以下几个touch方法
// 开始点击cell事件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"cell touchesBegan");
    [super touchesBegan:touches withEvent:event];
}
// 结束点击cell事件
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"cell touchesEnded");
    [super touchesEnded:touches withEvent:event];
}
// 取消点击cell事件
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"cell touchesCancelled");
    [super touchesCancelled:touches withEvent:event];
}

打印结果如下:

cell touchesBegan
tapSelfView
cell touchesCancelled

解释:
实际上判断是否为手势需要一段时间,在这段时间会先走响应链,所以会先走cell的 touchedBegan方法,然后松开手后手势被识别,然后响应链被取消(touches:Cancelled),所以会出现如上的现象和结果。

现象6

同样添加探究现象5时的代码。

打印结果如下:

cell touchesBegan
cell touchesEnded
点击了第0个cell

解释:
由于是长按,所以单击手势识别失败,响应链会继续走下去。因此也就正常触发了cell点击事件。

2.解决办法:

那么对于上面手势冲突的问题该如何解决呢?

我们可以在控制器中实现UIGestureRecognizerDelegate代理,实现gestureRecognizer:shouldReceiveTouch:方法,然后作如下判断。

#pragma mark UIGestureRecognizerDelegate

// 当返回YES时处理本次手势事件,当返回为NO时不处理本次手势事件
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    if (touch.view != self.view) {
        return NO;
    }
    return YES;
}

这样当判断点击的视图不为view时,就不处理本次手势。响应链也就不会被取消。

案例二 多个手势之间的冲突

1.几种情况的描述:

当一个页面上存在多个手势时,他们之间可能会出现一些干扰导致较差的用户体验。

情况1:在一个视图上同时加上轻扫手势(swipe)和拖动手势(pan)时,会出现轻扫手势较难触发的问题。(swipe与pan手势冲突)
情况2:有一个很多app都有的小功能,在页面上加一个悬浮的小球,然后可以拖动点击它,如果拖动使用了拖动手势(pan),同时它的父视图上又加了轻扫手势(swipe),比如视图控制器的轻扫返回,那么在快速拖动小球时可能会触发轻扫手势。(pan与swipe手势冲突)
情况3:同上的悬浮小球,如果它自身或者它的父视图加了长按手势(longPress),按住小球时会触发长按而不触发拖动,导致小球无法拖动。(pan与longPress手势冲突)
情况4:假如页面中有一个小视图,小视图上有一个点击手势点击后做一个动画。此外给控制器的view添加点击手势,用于处理输入框缩回键盘。结果是点击小视图只会走它自己的响应方法,不会处理缩回键盘。(pan与longPress手势冲突)

2.解决办法:

对于情况1、情况2、情况3都可以通过设置手势之间的优先关系来解决。

设置手势之间的优先关系有两种方法:

// 第一种:
// 使用requireGestureRecognizerToFail:方法
// 表示当手势2识别失败时才会识别手势1(手势2优先于手势1)
[gestureRecognizer1 requireGestureRecognizerToFail:gestureRecognizer2];

// 第二种:
// 实现手势的代理方法
gestureRecognizer.delegate = self

// 返回YES时表示,gestureRecognizer优先于otherGestureRecognizer
// 返回NO时意义相反
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    // 可在这里使用gestureRecognizer和otherGestureRecognizer做一些自己想要的判断
    return YES;
}

// 返回YES时表示,otherGestureRecognizer优先于gestureRecognizer
// 返回NO时意义相反
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    // 可在这里使用gestureRecognizer和otherGestureRecognizer做一些自己想要的判断
    return YES;
}

// 如果上面的两个方法都实现了,那么只会走gestureRecognizer:shouldRequireFailureOfGestureRecognizer:方法。

所以以上情况可以进行如下设置:

// 情况1 设置视图的轻扫手势识别失败时才会识别它上面的拖动手势
[pan requireGestureRecognizerToFail:swipe];
// 情况2 设置小球的拖动手势优于父视图的轻扫
[swipe requireGestureRecognizerToFail:pan];
// 情况3 设置小球的拖动手势优于父视图的长按手势
[lonePress requireGestureRecognizerToFail:pan];

对于情况4:

可实现如下的手势代理方法,使两个手势的事件都触发

// 当遇到设置代理的手势和其他手势发生冲突时,会走该方法
// 当返回YES时,两个手势的响应事件都会触发
// 当返回NO时,会根据后续的优先级来判断触发哪个手势
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    return YES;
}

使用案例

扩大按钮的点击范围

如下图所示,有一个按钮,想要实现的效果是,点击按钮会响应,并且点击按钮超过四周20的范围也会响应事件。
clipboard.png

视图层级如下:

window
    ——view
        ——button

分析:
因为button完全包裹在view中,所以如果想要按钮能够响应四周20范围内的区域,只需要重写button的pointInside方法扩大范围即可。

实现如下:

// ZCPButton.m
// 重写pointInside方法,扩大判断范围
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    CGRect extendRect = CGRectMake(-20, -20, self.frame.size.width + 40, self.frame.size.height + 40);
    BOOL result = CGRectContainsPoint(extendRect, point);
    return result;
}

扩大TabBar的响应范围

如下图所示,通过在tabbar上加一个imageview实现一个带花边的tabbar。并且要求点击花边会执行一段动画。

clipboard.png

视图层级如下:

window
    ——tabbar
        ——imageview
    ——view

我们首先来分析一下:
我们知道hit test的过程要先判断tabbar,如果tabbar可以响应则会判断子视图。
与上面那个button扩大范围的案例不同的是,这里的imageview超出了tabbar的frame范围,因此点击imageview的区域时,因为点击位置并不在tabbar的frame范围内,所以hit test会在tabbar这里返回nil。

hit test过程如下:

application hittest
-> window hitTest(判断子视图,最终返回view)
-> tabbar hittest(这里返回nil)
-> view hitTest(返回view)

因此,如果我们想要imageview能够响应点击。则需要扩大tabbar的响应范围,将事件传递到imageview。

实现如下:

// ZCPTabBar.m
// 重写ZCPTabBar的pointInside方法,扩大其触发范围。
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    
    // 此rect为花边的rect
    CGRect laceRect = CGRectMake(0, -50, self.frame.size.width, 50);
    BOOL result = [super pointInside:point withEvent:event];
    BOOL result2 = CGRectContainsPoint(laceRect, point);
    
    return result || result2;
}

// ZCPImageView.m
// 重写touchesEnded方法,执行自己想要的操作
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 执行一段动画
}

参考文章

iOS触摸事件全家桶

iOS点击事件和手势冲突

深入浅出iOS事件机制


花飞蝶舞剑
123 声望72 粉丝

Zcp大官人的iOS小站