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缩回键盘。
部分代码实现:
// 初始化部分
- (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的范围也会响应事件。
视图层级如下:
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。并且要求点击花边会执行一段动画。
视图层级如下:
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 {
// 执行一段动画
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。