iOS 事件及手势处理流程
开篇叨叨
在iOS 事件模型中,由iOS事件源产生事件,而后顺着布局树进行Hit-Testing测试,判断哪些View可以响应这个事件,这些View组成一个事件响应链,产生的事件将沿着响应链一级一级传递,最终传递到最终的事件响应者中,由最终事件响应者提供的响应方法处理当前的事件。
这篇博客将主要针对如下问题进行展开:
- iOS中有哪些事件源类型
- 哪些对象会消费这些事件(哪些对象可以组成事件响应链上的节点)
- 事件是怎么传递的
- 如何判断谁是事件的最佳响应者
- 事件的处理
事件源
为满足用户需求,iOS 提供了例如点击、长按、摇晃、3D Touch 等多种事件,这些事件大体可以分成触摸事件,运动事件,远程控制事件,按压事件四类。
触摸事件:
长按手势 (UILongPressGestureRecognizer)
拖动手势 (UIPanGestureRecognizer)
捏合手势 (UIPinchGestureRecognizer)
响应屏幕边缘手势 (UIScreenEdgePanGestureRecognizer)
轻扫手势 (UISwipeGestureRecognizer)
旋转手势 (UIRotationGestureRecognizer)
点击手势 (UITapGestureRecognizer)运动事件
iPhone 内置陀螺仪、加速器和磁力仪,可以感知手机的运动情况。iOS 提供了 Core Motion 框架来处理这些运动事件。
其中陀螺仪主要用于测量设备绕 X-Y-Z 轴的自转速率,倾斜角度等,加速器主要用于测量设备在 X-Y-Z 轴速度的改变,磁力仪可以测量当前设备的磁极、方向、经纬度等数据。远程控制事件
远程控制事件指通过耳机去控制手机上的一些操作,比如上一曲/下一曲/播放/停止等。按压事件
iOS 9 提供了 3D Touch 事件,可以通过压力的不同来区分不同的操作。
事件响应者
在iOS中不是任何对象都能处理事件,只有继承了UIResponder的对象才能接受并处理事件,下面是iOS中UIResponder的子类,也就是说只有这些类以及这些类的子类才能响应并处理事件,UIResponder对象之间的联系靠nextResponder指针,组成一个响应链:
UIApplication |
UIResponder 中定义了一系列的触摸事件响应函数,我们可以通过覆写这些方法来提供自定义的响应:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; |
我们以最常见的触摸事件进行介绍:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;//一根或者多根手指开始触摸view |
这里还需注意的是:
- 不论多少根手指同时触摸View,都只会调用一次touchesBegan,但是touches里面会包裹着多个UITouch,每个手指对应一个UITouch对象。
- 如果多个手指一前一后触摸同一个View,那么会调用多次touchesBegan,每次只包含一个UITouch对象。
- 如果是处理UIView触摸事件,需要在其子View的中覆写对应的touch方法。如果是处理UIViewController的触摸事件,可以直接在UIViewController的文件中覆写对应的touch方法。
- 当我们手指按下后在屏幕上移动的时候会不断触发touchesMoved,一旦抬起来就会触发touchesEnded。
事件对象
了解了事件源,事件响应对象,我们还需要了解下事件对象,事件对象会携带者一系列的事件信息到事件响应对象,一个触摸事件可能是由多个手指同时触摸产生的。触摸对象集合通过 allTouches 属性获取。
- UIEvent
@interface UIEvent : NSObject |
这里的UIEventType是事件大的类别:
typedef NS_ENUM(NSInteger, UIEventType) { |
UIEventSubtype 为事件小的类别,比如远程控制事件会使用这个字段来区分具体是哪个子事件:
typedef NS_ENUM(NSInteger, UIEventSubtype) { |
里面包含了一系列的UITouch,可以通过touchesForWindow,touchesForView,touchesForGestureRecognizer来判断属于某个window,view,以及手势下的UITouch.
- UITouch
@interface UITouch : NSObject |
UITouchPhase 用于表示某个触摸是处于哪个阶段
typedef NS_ENUM(NSInteger, UITouchPhase) { |
事件的传递流程
阶段1 将事件传递到UIWindow
当一个事件产生的时候,事件会在底层由IOKit.framework 封装成IOHIDEvent对象。然后系统通过mach port将IOHIDEvent对象转发给SpringBoard.app。 SpringBoard.app 它有点像Android中的lancher,只接收按钮,触摸,加速等事件,SpringBoard会根据当前桌面的状态,判断应该由谁处理此次触摸事件.可能在事件产生的时候你在桌面翻页并没有应用在前台运行,这时候触发SpringBoard本身主线程runloop的source0事件源的回调,将事件交由桌面系统去消耗,如果有应用在前台运行那么会通过mach port 将IOHIDEvent 转发给对应的App.App主线程的RunLoop收到SpringBoard转发的消息后,触发Source1回调__IOHIDEventSystemClientQueueCallback。在这个方法中会触发Source0回调__UIApplicationHandleEventQueue,将IOHIDEvent转换为UIEvent.并通过UIApplication的sendEvent:方法将UIEvent传递给UIWindow.
阶段2 Hit-Testing
上一阶段UIWindow已经拿到了事件,但是面对着整个复杂的视图层级树,要先判断最先将事件最先传给谁(也就是判断first responder,当然first responder也可以我们直接指定),这就需要靠Hit-Testing来完成了,注意这里并未涉及到事件的处理,只是确定由哪个视图来首先处理 UITouch 事件。
上图中左边是界面结构,右边是对应的视图层级树,其中View B的子View,View B.1遮住了View A的子View,View A2.我们假设点击了View B.1区域。
下面是整个Hit-Testing 的流程。
首先UIWindow 会顺着层级树,到RootView,然后RootView有三个子View .由于三个子View的添加顺序为: A –> B –> C, 所以先从C开始,也就是从subViews的最后一个开始,从后往前进行遍历,为什么从后往前而不是从前往后是因为,在数组中所处的位置越后,在界面上所处的位置越上层,这是为了考虑视图遮挡的情况下先判断最上层的。View C调用hit test 判断不在它上面,所以转向B,hit-test测试在它上面,就继续遍历它的子view,子view的遍历过程也是从子view数组的最后一项,往前测试。在测试到View B.1的时候发现触点在它上面,并且它没有子View所以,到此位置终止测试。View B.1 作为Hit-testing最终的结果。
上面只是展示了正常的Hit-Testing 的流程,实际上,View的一些属性还会影响到Hit-Testing的结果,比如如下的情况:
视图的hidden等于 YES。 |
也就是当前待测试的视图不可见或者不处理交互,这些视图将会被忽略。因此整个hitTest的代码如下所示。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
下面需要重点看下这个阶段的两个重要方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; |
hitTest 返回的是包含触点的最适合的子view,如果hitTest:withEvent:方法中返回nil,那么调用该方法的控件本身和其子控件都不是最合适的view,也就是在自己身上没有找到更合适的view。如果同级的兄弟控件也没有合适的view,那么最合适的view就是该控件的父控件。
想让谁成为最合适的view就重写谁自己的父控件的hitTest:withEvent:方法返回指定的子控件,或者重写自己的hitTest:withEvent:方法 return self。但是,建议在父控件的hitTest:withEvent:中返回子控件作为最合适的view!因为有可能在还没遍历到返回self的那个view的时候就已经拿到触点的的view。
pointInside很好理解就是判断点是否在当前view上面,如果是返回YES,否则返回NO.这里需要注意的是,point 必须先转换为相对当前view的坐标系坐标。
- Hit-Testing 的应用
- 增加视图的touch区域
这里可以通过两种方式都可以实现:
一种是通过重写hitTest,一种是通过重写pointInside
重写hitTest
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
重写pointInside
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { |
- 透传事件
当时想在当前 view 处理事件,不想在对 subview 进行遍历,可以直接重写 [hitTest:withEvent:] 方法并 return self 即可。
- 指定某个视图处理事件
重写父视图的[hitTest:withEvent:],指定响应 View。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
阶段3 事件的响应
Touch 事件处理的传递过程与 Hit-Testing 过程正好相反。Hit-Tesing 过程是从父视图到子视图遍历;Touch 事件处理传递是从子视图到父视图传递。
首先Touch事件会被发送到first responder,first responder便拥有了对事件的绝对控制权:它可以选择独吞这个事件,也可以将这个事件往下传递给其他响应者.
整个过程如下所示:
* 如果当前view是控制器的RootView,那么控制器就是nextResponder,事件就传递给控制器. |
响应者对于接收到的事件有3种操作:
- 不拦截 :事件会自动沿着默认的响应链往下传递
- 拦截,不再往下分发事件 :重写 touchesBegan:withEvent: 进行事件处理,不调用父类的 touchesBegan:withEvent:
- 拦截,继续往下分发事件 :重写 touchesBegan:withEvent: 进行事件处理,同时调用父类的 touchesBegan:withEvent: 将事件往下传递
这里还需要注意的一点是如果我们的View有关联的手势识别器,那么在将touches发送给发生触摸的视图本身前,会先将touches发送给发生触摸的视图所关联的手势识别器,这部分内容将在下面讲手势的时候介绍。
触摸事件经历上面各个环节后要么被某个响应对象捕获后释放,要么没能找到能够响应的对象被丢弃。整个触摸事件就结束了。Runloop若没有其他事件需要处理,也将重新进入休眠,等待新的事件到来后唤醒。
手势
上面介绍了iOS的事件体系,紧接着讲下和事件相关的手势交互,iOS中的手势都是继承自UIGestureRecognizer,系统为了方便大家使用也内置了一系列的手势,下面是目前支持的几种手势,如果不够使用还可以通过继承UIGestureRecognizer来自定义手势。
手势 | 说明 |
---|---|
UITapGestureRecognizer | 轻拍手势 |
UISwipeGestureRecognizer | 轻扫手势 |
UILongPressGestureRecognizer | 长按手势 |
UIPanGestureRecognizer | 平移手势 |
UIPinchGestureRecognizer | 捏合(缩放)手势 |
UIRotationGestureRecognizer | 旋转手势 |
UIScreenEdgePanGestureRecognizer | 屏幕边缘平移 |
常用属性和方法
//设置代理,具体的协议后面会说 |
手势的初始化
各种手势都是通过initWithTarget:action进行初始化的
- (instancetype)initWithTarget:(nullable id)target action:(nullable SEL)action; |
还可以通过removeTarget:action将一个selector从手势对象上移除
- (void)removeTarget:(nullable id)target action:(nullable SEL)action; |
iOS系统允许一个手势对象可以添加多个selector触发方法,并且触发的时候,所有添加的selector都会被执行
- (void)addTarget:(id)target action:(SEL)action; |
这里需要注意的是UIGestureRecognizerSubclass.h头文件中定义了一个UIGestureRecognizer分类UIGestureRecognizerProtected,它里面也定义了一系列和UIResponder一样的方法,
可以供我们覆写以实现自定义效果。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event; |
手势代理
手势有其对应的代理UIGestureRecognizerDelegate 通过它可以指定很多特性,下面将对这些特性进行一一介绍:
@protocol UIGestureRecognizerDelegate <NSObject> |
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer; |
开始进行手势识别时调用的方法,当手势识别器识别到手势,准备从UIGestureRecognizerStatePossible状态开始转换时.调用此代理,如果返回YES,那么就继续识别,如果返回NO,那么手势识别器将会将状态置为UIGestureRecognizerStateFailed.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch |
手指触摸屏幕后回调的方法,返回NO则不再进行手势识别,方法触发此方法在window对象在有触摸事件发生时,调用gesture recognizer的touchesBegan:withEvent:方法之前调用,如果返回NO,则gesture recognizer不会看到此触摸事件。(默认情况下为YES)
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePress:(UIPress *)press; |
手指按压屏幕后回调的方法,返回NO则不再进行手势识别,方法触发等
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer; |
是否支持多手势触发,返回YES,则可以多个手势一起触发方法,返回NO则为互斥.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer; |
这个方法返回YES,第一个手势和第二个互斥时,第一个会失效
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer; |
这个方法返回YES,第一个和第二个互斥时,第二个会失效
手势状态
和其他事件一样手势也是有个状态机,它用一个state属性描述。
@property(nonatomic,readonly) UIGestureRecognizerState state; |
它的描述值如下:
typedef NS_ENUM(NSInteger, UIGestureRecognizerState) { |
左侧是非连续手势(比如单击)的状态机,右侧是连续手势(比如滑动)的状态机。所有的手势的开始状态都是UIGestureRecognizerStatePossible。
非连续的手势要么识别成功(UIGestureRecognizerStateRecognized),要么识别失败(UIGestureRecognizerStateFailed)。
连续的手势识别到第一个手势时,变成UIGestureRecognizerStateBegan,然后变成UIGestureRecognizerStateChanged,并且不断地在这个状态下循环,当用户最后一个手指离开view时,变成UIGestureRecognizerStateEnded,当然如果手势不再符合它的模式的时候,状态也可能变成UIGestureRecognizerStateCancelled。
手势与事件处理
我们前面讲到了当一个view有关联手势的时候,手势的优先级会比事件来的高,因此在我们触摸带手势的view的时候,如果先被手势识别器识别了,事件就不会传递给view 的UIResponder进行处理,但是我们知道手势是有状态的,也就是说手势识别是有一个过程的,因此在手势未被完全识别之前,事件是会被同时发送到UIResponder,以及UIGestureRecognizer,一旦手势识别器识别了某个手势之后,UIResponder就会的touchesCancelled:withEvent就会被调用,此后的事件都被UIGestureRecognizer独占,也就是只发往UIGestureRecognizer。如果识别失败手势状态将会被识别为UIGestureRecognizerStateFailed,这时候事件会继续发送给UIResponder,直到结束。
简单得说就是,在手势识别器未判定手势识别成功之前,事件会发给手势识别器和UIResponder,一旦手势识别器判定为识别成功就拦截了整个事件,UIResponder会收到cancel的信号,后续就不会继续收到对应的事件了,所有的后续事件都交给手势识别器进行处理。
同样在手势识别器未判定手势识别成功之前,事件会发给手势识别器和UIResponder,如果手势识别器识别失败,那么就会被标记为UIGestureRecognizerStateFailed,然后后续的事件都交给UIResponder进行处理。
- cancelsTouchesInView
默认为YES。表示当手势识别器成功识别了手势之后,会通知Application取消响应链对事件的响应,并不再传递事件给UIResponder。若设置成NO,表示手势识别成功后不取消响应链对事件的响应,事件依旧会传递给UIResponder。
手势其他用法
我们添加的两个手势都是单击手势,会产生冲突,触发是很随机的,如果我们想设置一下当手势互斥时要优先触发的手势,可以使用如下的方法:
- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;
比如我们在秀场直播间,双击屏幕会产生关注,如果单击屏幕会有点赞效果,就可以通过这种方式解决。
// 单击的 Recognizer
UITapGestureRecognizer* singleRecognizer;
singleRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:selfaction:@selector(handleSingleTapFrom)];
singleTapRecognizer.numberOfTapsRequired = 1; // 单击
[self.view addGestureRecognizer:singleRecognizer];
// 双击的 Recognizer
UITapGestureRecognizer* double;
doubleRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:selfaction:@selector(handleDoubleTapFrom)];
doubleTapRecognizer.numberOfTapsRequired = 2; // 双击
[self.view addGestureRecognizer:doubleRecognizer];
// 如果双击确定检测失败才会触发单击
[singleRecognizer requireGestureRecognizerToFail:doubleRecognizer];
[singleRecognizer release];
[doubleRecognizer release];
```
老规矩上图
![](./iOS-事件及手势处理流程/000007.png)