官方文档说明:《Event Handling Guide for iOS》,本文参考转载文章,并参照官方文档补充说明。
本篇内容将围绕iOS中事件及其传递机制进行学习和分析。在iOS中,事件分为三类:
- 触控事件(单点、多点触控以及各种手势操作)
- 传感器事件(重力、加速度传感器等)
- 远程控制事件(远程遥控iOS设备多媒体播放等)
这三类事件共同构成了iOS设备丰富的操作方式和使用体验,本次就首先来针对第一类事件:触控事件,进行学习和分析。
Gesture Recognizers
Gesture Recognizers是一类手势识别器对象,它可以附属在你指定的View上,并且为其设定指定的手势操作,例如是点击、滑动或者是拖拽。当触控事件 发生时,设置了Gesture Recognizers的View会先通过识别器去拦截触控事件,如果该触控事件是事先为View设定的触控监听事件,那么Gesture Recognizers将会发送动作消息给目标处理对象,目标处理对象则对这次触控事件进行处理,先看看如下流程图。
在iOS中,View就是我们在屏幕上看到的各种UI控件,当一个触控事件发生时,Gesture Recognizers会先获取到指定的事件,然后发送动作消息(action message)给目标对象(target),目标对象就是ViewController,在ViewController中通过事件方法完成对该事件的处理。Gesture Recognizers能设置诸如单击、滑动、拖拽等事件,通过Action-Target这种设计模式,好处是能动态为View添加各种事件监听,而不用去实现一个View的子类去完成这些功能。
以上过程就是我们在开发中在方法中常见的设置action和设置target,例如为UIButton设置监听事件等。
常用手势识别类
在UIKit框架中,系统为我们事先定义好了一些常用的手势识别器,包括点击、双指缩放、拖拽、滑动、旋转以及长按。通过这些手势识别器我们可以构造丰富的操作方式。
在上表中可以看到,UIKit框架中已经提供了诸如UITapGestureRecognizer在内的六种手势识别器,如果你需要实现自定义的手势识别器,也可以通过继承UIGestureRecognizer类并重写其中的方法来完成,这里我们就不详细讨论了。
每一个Gesture Recognizer关联一个View,但是一个View可以关联多个Gesture Recognizer,因为一个View可能还能响应多种触控操作方式。为了使gesture recognizer识别发生在view上面的手势,你必须attach gesture recognizer to that view。当一个触控事件发生时,Gesture Recognizer接收一个touch发生的消息要先于View本身,结果就是Gesture Recognizer可以代表view回应视图上的touches事件。
当gesture recognizer识别了一个特定手势,它就会发送一条动作消息(action message)给它的target,所以创建gesture recognizer时候,需要initialize it with a targer and action。
连续和不连续动作
触控动作同时分为连续动作(continuous)和不连续动作(discrete),连续动作例如滑动和拖拽,它会持续一小段时间,而不连续动作例如单击,它瞬间就会完成,在这两类事件的处理上又稍有不同。对于不连续动作,Gesture Recognizer只会给ViewContoller发送一个单一的动作消息(action message),而对于连续动作,Gesture Recognizer会发送多条动作消息给ViewController,直到所有的事件都结束。
添加GestureRecognizer
为一个View添加GestureRecognizer有两种方式,一种是通过InterfaceBuilder实现,另一种就是通过代码实现,我们看看通过代码来如何实现。
1 - (void)viewDidLoad { 2 [super viewDidLoad]; 3 4 // 创建并初始化手势对象 5 UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] 6 initWithTarget:self action:@selector(respondToTapGesture:)]; 7 8 // 指定操作为单击一次 9 tapRecognizer.numberOfTapsRequired = 1; 10 11 // 为当前View添加GestureRecognizer 12 [self.view addGestureRecognizer:tapRecognizer]; 13 14 // ... 15 }
通过上述代码,我们实现了为当前MyViewController的View添加一个单击事件,首先构造了UITapGestureRecognizer对象,指定了target为当前ViewController本身,action就是后面自己实现的处理方法,这里就呼应了前文提到的Action-Target模式。
在事件处理过程中,这两种方式所处的状态又各有不同,首先,所有的触控事件最开始都是处于可用状态(Possible),对应UIKit里面的UIGestureRecognizerStatePossible类,如果是不连续动作事件,则状态只会从Possible转变为已识别状态(Recognized,UIGestureRecognizerStateRecognized)或者是失败状态(Failed,UIGestureRecognizerStateFailed)。例如一次成功的单击动作,就对应了Possible-Recognized这个过程。
如果是连续动作事件,如果事件没有失败并且连续动作的第一个动作被成功识别(Recognized),则从Possible状态转移到Began(UIGestureRecognizerStateBegan)状态,这里表示连续动作的开始,接着会转变为Changed(UIGestureRecognizerStateChanged)状态,在这个状态下会不断循环的处理连续动作,直到动作执行完成变转变为Recognized已识别状态,最终该动作会处于完成状态(UIGestureRecognizerStateEnded),另外,连续动作事件的处理状态会从Changed状态转变为Canceled(UIGestureRecognizerStateCancelled)状态,原因是识别器认为当前的动作已经不匹配当初对事件的设定了。每个动作状态的变化,Gesture Recognizer都会发送消息(action message)给Target,也就是ViewController,它可以根据这些动作消息进行相应的处理。例如一次成功的滑动手势动作就包括按下、移动、抬起的过程,分别对应了Possible-Began-Changed-Recognized这个过程。
UITouch & UIEvent
在屏幕上的每一次动作事件都是一次Touch,在iOS中用UITouch对象表示每一次的触控,多个Touch组成一次Event,用UIEvent来表示一次事件对象。
在上述过程中,完成了一次双指缩放的事件动作,每一次手指状态的变化都对应事件动作处理过程中得一个阶段。通过Began-Moved-Ended这几个阶段的动作(Touch)共同构成了一次事件(Event)。在事件响应对象UIResponder中有对应的方法来分别处理这几个阶段的事件。
- touchesBegan:withEvent:
- touchesMoved:withEvent:
- touchesEnded:withEvent:
- touchesCancelled:withEvent:
后面的参数分别对应UITouchPhaseBegan、UITouchPhaseMoved、UITouchPhaseEnded、UITouchPhaseCancelled这几个类。用来表示不同阶段的状态。
事件传递
如上图,iOS中事件传递首先从App(UIApplication)开始,接着传递到Window(UIWindow),在接着往下传递到View之前,Window会将事件交给GestureRecognizer,如果在此期间,GestureRecognizer识别了传递过来的事件,则该事件将不会继续传递到View去,而是像我们之前说的那样交给Target(ViewController)进行处理。
响应者链(Responder Chain)
通常,一个iOS应用中,在一块屏幕上通常有很多的UI控件,也就是有很多的View,那么当一个事件发生时,如何来确定是哪个View响应了这个事件呢,接下来我们就一起来看看。
寻找hit-test view
什么是hit-test view呢?简单来说就是你触发事件所在的那个View,寻找hit-test view的过程就叫做Hit-Testing。那么,系统是如何来执行Hit-Testing呢,首先假设现在有如下这么一个UI布局,一种有ABCDE五个View。
假设一个单击事件发生在了View D里面,系统首先会从最顶层的View A开始寻找,发现事件是在View A或者其子类里面,那么接着从B和C找,发现事件是在C或者其子类里面,那么接着到C里面找,这时发现事件是在D里面,并且D已经没有子类了,那么hit-test view就是View D啦。
响应者对象(Responsder Object)
响应者对象是能够响应并且处理事件的对象,UIResponder是所有响应者对象的父类,包括UIApplication、UIView和UIViewController都是UIResponder的子类。也就意味着所有的View和ViewController都是响应者对象。
第一响应者(First Responder)
第一响应者是第一个接收事件的View对象,我们在Xcode的Interface Builder画视图时,可以看到视图结构中就有First Responder。
这里的First Responder就是UIApplication了。另外,我们可以控制一个View让其成为First Responder,通过实现 canBecomeFirstResponder方法并返回YES可以使当前View成为第一响应者,或者调用View的becomeFirstResponder方法也可以,例如当UITextField调用该方法时会弹出键盘进行输入,此时输入框控件就是第一响应者。
事件传递机制
如上所说,,如果hit-test view不能处理当前事件,那么事件将会沿着响应者链(Responder Chain)进行传递,知道遇到能处理该事件的响应者(Responsder Object)。通过下图,我们来看看两种不同情况下得事件传递机制。
左边的情况,接收事件的initial view如果不能处理该事件并且她不是顶层的View,则事件会往它的父View进行传递。initial view的父View获取事件后如果仍不能处理,则继续往上传递,循环这个过程。如果顶层的View还是不能处理这个事件的话,则会将事件传递给它们的ViewController,如果ViewController也不能处理,则传递给Window(UIWindow),此时Window不能处理的话就将事件传递给Application(UIApplication),最后如果连Application也不能处理,则废弃该事件。
右边图的流程唯一不同就在于,如果当前的ViewController是由层级关系的,那么当子ViewController不能处理事件时,它会将事件继续往上传递,直到传递到其Root ViewController,后面的流程就跟之前分析的一样了。
这就是事件响应者链的传递机制,通过这些内容,我们可以更深入的了解事件在iOS中得传递机制,对我们在实际开发中更好的理解事件操作的原理有很大的帮助,也对我们实现复杂布局进行事件处理时增添了多一份的理解。
事件处理方法
UIResponder中定义了一系列对事件的处理方法,他们分别是:
- –(void)touchesBegan:(NSSet )touches withEvent:(UIEvent )event
- –(void)touchesMoved:(NSSet )touches withEvent:(UIEvent )event
- –(void)touchesEnded:(NSSet )touches withEvent:(UIEvent )event
- –(void)touchesCancelled:(NSSet )touches withEvent:(UIEvent )event
从方法名字可以知道,他们分别对应了屏幕事件的开始、移动、结束和取消几个阶段,前三个阶段理解都没问题,最后一个取消事件的触发时机是在诸如突然来电话或是系统杀进程时调用。这些方法的第一个参数定义了UITouch对象的一个集合(NSSet),它的数量表示了这次事件是几个手指的操作,目前iOS设备支持的多点操作手指数最多是5。第二个参数是当前的UIEvent对象。下图展示了一个UIEvent对象与多个UITouch对象之间的关系。
一、点击事件
首先,新建一个自定义的View继承于UIView,并实现上述提到的事件处理方法,我们可以通过判断UITouch的tapCount属性来决定响应单击、双击或是多次点击事件。
1 #import "MyView.h" 2 @implementation MyView 3 4 -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 5 { 6 7 } 8 9 -(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 10 { 11 12 } 13 14 -(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event 15 { 16 for (UITouch *aTouch in touches) { 17 if (aTouch.tapCount == 2) { 18 // 处理双击事件 19 [self respondToDoubleTapGesture]; 20 } 21 } 22 } 23 24 -(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event 25 { 26 27 } 28 29 -(void)respondToDoubleTapGesture 30 { 31 NSLog(@"respondToDoubleTapGesture"); 32 } 33 34 @end
二、滑动事件
滑动事件一般包括上下滑动和左右滑动,判断是否是一次成功的滑动事件需要考虑一些问题,比如大部分情况下,用户进行一次滑动操作,这次滑动是否是在一条直线上?或者是否是基本能保持一条直线的滑动轨迹。或者判断是上下滑动还是左右滑动等。另外,滑动手势一般有一个起点和一个终点,期间是在屏幕上画出的一个轨迹,所以需要对这两个点进行判断。我们修改上述的MyView.m的代码来实现一次左右滑动的事件响应操作。
1 #import "MyView.h" 2 3 #define HORIZ_SWIPE_DRAG_MIN 12 //水平滑动最小间距 4 #define VERT_SWIPE_DRAG_MAX 4 //垂直方向最大偏移量 5 6 @implementation MyView 7 8 -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 9 { 10 UITouch *aTouch = [touches anyObject]; 11 // startTouchPosition是一个CGPoint类型的属性,用来存储当前touch事件的位置 12 self.startTouchPosition = [aTouch locationInView:self]; 13 } 14 15 -(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 16 { 17 18 } 19 20 -(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event 21 { 22 UITouch *aTouch = [touches anyObject]; 23 CGPoint currentTouchPosition = [aTouch locationInView:self]; 24 25 // 判断水平滑动的距离是否达到了设置的最小距离,并且是否是在接近直线的路线上滑动(y轴偏移量) 26 if (fabsf(self.startTouchPosition.x - currentTouchPosition.x) >= HORIZ_SWIPE_DRAG_MIN && 27 fabsf(self.startTouchPosition.y - currentTouchPosition.y) <= VERT_SWIPE_DRAG_MAX) 28 { 29 // 满足if条件则认为是一次成功的滑动事件,根据x坐标变化判断是左滑还是右滑 30 if (self.startTouchPosition.x < currentTouchPosition.x) { 31 [self rightSwipe];//右滑响应方法 32 } else { 33 [self leftSwipe];//左滑响应方法 34 } 35 //重置开始点坐标值 36 self.startTouchPosition = CGPointZero; 37 } 38 } 39 40 -(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event 41 { 42 //当事件因某些原因取消时,重置开始点坐标值 43 self.startTouchPosition = CGPointZero; 44 } 45 46 -(void)rightSwipe 47 { 48 NSLog(@"rightSwipe"); 49 } 50 51 -(void)leftSwipe 52 { 53 NSLog(@"leftSwipe"); 54 } 55 56 @end
三、拖拽事件
在屏幕上我们可以拖动某一个控件(View)进行移动,这种事件成为拖拽事件,其实现原理就是在不改变View的大小尺寸的前提下改变View的显示坐标值,为了达到动态移动的效果,我们可以在move阶段的方法中进行坐标值的动态更改,还是重写MyView.m的事件处理方法,这次在touchesMove方法中进行处理。
1 #import "MyView.h" 2 @implementation MyView 3 4 -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 5 { 6 7 } 8 9 -(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 10 { 11 UITouch *aTouch = [touches anyObject]; 12 //获取当前触摸操作的位置坐标 13 CGPoint loc = [aTouch locationInView:self]; 14 //获取上一个触摸点的位置坐标 15 CGPoint prevloc = [aTouch previousLocationInView:self]; 16 17 CGRect myFrame = self.frame; 18 //改变View的x、y坐标值 19 float deltaX = loc.x - prevloc.x; 20 float deltaY = loc.y - prevloc.y; 21 myFrame.origin.x += deltaX; 22 myFrame.origin.y += deltaY; 23 //重新设置View的显示位置 24 [self setFrame:myFrame]; 25 } 26 27 -(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event 28 { 29 30 } 31 32 -(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event 33 { 34 35 } 36 37 @end
四、双指缩放
之前提到过UIEvent包含了一系列的UITouch对象构成一次事件,当设计多点触控操作时,可与对UIEvent对象内的UITouch对象进行处理,比如实现一个双指缩放的功能。
1 #import "MyView.h" 2 @implementation MyView 3 { 4 BOOL pinchZoom; 5 CGFloat previousDistance; 6 CGFloat zoomFactor; 7 } 8 9 -(id)init 10 { 11 self = [super init]; 12 if (self) { 13 pinchZoom = NO; 14 //缩放前两个触摸点间的距离 15 previousDistance = 0.0f; 16 zoomFactor = 1.0f; 17 } 18 return self; 19 } 20 21 -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event 22 { 23 if(event.allTouches.count == 2) { 24 pinchZoom = YES; 25 NSArray *touches = [event.allTouches allObjects]; 26 //接收两个手指的触摸操作 27 CGPoint pointOne = [[touches objectAtIndex:0] locationInView:self]; 28 CGPoint pointTwo = [[touches objectAtIndex:1] locationInView:self]; 29 //计算出缩放前后两个手指间的距离绝对值(勾股定理) 30 previousDistance = sqrt(pow(pointOne.x - pointTwo.x, 2.0f) + 31 pow(pointOne.y - pointTwo.y, 2.0f)); 32 } else { 33 pinchZoom = NO; 34 } 35 } 36 37 -(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event 38 { 39 if(YES == pinchZoom && event.allTouches.count == 2) { 40 NSArray *touches = [event.allTouches allObjects]; 41 CGPoint pointOne = [[touches objectAtIndex:0] locationInView:self]; 42 CGPoint pointTwo = [[touches objectAtIndex:1] locationInView:self]; 43 //两个手指移动过程中,两点之间距离 44 CGFloat distance = sqrt(pow(pointOne.x - pointTwo.x, 2.0f) + 45 pow(pointOne.y - pointTwo.y, 2.0f)); 46 //换算出缩放比例 47 zoomFactor += (distance - previousDistance) / previousDistance; 48 zoomFactor = fabs(zoomFactor); 49 previousDistance = distance; 50 51 //缩放 52 self.layer.transform = CATransform3DMakeScale(zoomFactor, zoomFactor, 1.0f); 53 } 54 } 55 56 -(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event 57 { 58 if(event.allTouches.count != 2) { 59 pinchZoom = NO; 60 previousDistance = 0.0f; 61 } 62 } 63 64 -(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event 65 { 66 67 } 68 69 @end
上面实现的方式有一点不足之处就是必须两个手指同时触摸按下才能达到缩放的效果,并不能达到相册里面那样一个手指触摸后,另一个手指按下也可以缩放。如果需要达到和相册照片缩放的效果,需要同时控制begin、move、end几个阶段的事件处理。这个不足就留给感兴趣的同学自己去实现了。
参考文章:
http://ryantang.me/blog/2013/12/07/ios-event-dispatch-1/
http://ryantang.me/blog/2013/12/29/ios-event-dispatch-2/