定义手势识别如何交互
1 有限状态机中手势识别的操作
手势识别器使用状态机来进行控制。识别器所在的每一个状态,都会依照它们所符合的特定条件向另一个可能的状态进行跳转。如图1-3所示。所有的状态识别器都会有一个初始的状态(UIGestureRecognizerStatePossible),然后分析其所接收到的所有的多点触摸序列,分析的过程中,对应的手势要么成功识别、要么失败。如果失败,那就意味着将手势识别器的状态转向“失败状态(UIGestureRecognizerStateFailed)”。
对于非持续手势识别,如果是被成功,就会从某个状态转向“已识别(UIGestureRecognizerStateRecognized)”状态 ,也就意味着手势分析的整个过程完成。
对于持续手势识别,一旦识别成功,则会首先从某个状态转向“已开始(UIGestureRecognizerStateBegan)”状态 ,当手势持续操作并运动时就会由“已开始”转到 “已变化(UIGestureRecognizerStateChanged)” 并持续由该状态继续,当用户的最后一根手指离开视图进行操作,就会转入“已结束(UIGestureRecognizerStateEnded)”状态,该手 势识别过程也就结束。需要注意的是,结束状态也是手势识别状态的一个。
如果连续手势识别发现用户的手势不符合预期的模式,其状态也可能从“已改变”状态,转到“已取消(UIGestureRecognizerStateCancelled)”状态。手势识别器的状态每次发生改变,都会向其目标对象发送一条消息,除非其状态转为“失败”或者“取消”。如此,非持续的手势识别器的状态发生转变,就发送一个消息给其目标对象,连续手势识别器,则会连续发送许多消息。
当手势识别器状态转为“已识别”或者“已结束”时,其状态值就会被重置到初始状态, 转向初始状态时不会触发消息发送。
2 与其他手势识别器进行交互
视图对象可以添加多个手势识别器, gestureRecognizers
属性可以查看所有的手势识别器。 addGestureRecognizer
和 removeGestureRecognizer
方法能增加或删除手势识别器。
视图添加多个手势识别器时,默认情况下,手势识别器的相应顺序是随机的,所以每次用户的手势触摸操作都可能由不同的手势识别器接受到并被处理。所以需要有指定手势识别器优先级的能力。开发者就想要修改此“默认”设定,已达到以下目的:
• 指定某个识别器优先于另一个识别器,来接收并处理用户的触摸操作。
• 让两个识别器同时进行操作处理。
• 阻止某个手势识别器对某个触摸操作进行分析。
使用被 UIGestureRecognizer 子类覆盖重写的的类方法、代理方法以及成员函数方法来改 变这些行为操作。
2.1 申明两个识别器的特定顺序
假设开发者想要识别用户的“滑动(swipe)”和“平移(pan)”手势,而这两个手势需要触发两个不同的消息。默认情况下,当用户进“滑动(swipe)”操作时,这个手势会被默认识别为“平移(pan)”。这是因为“滑动”手势操作在被系统识别为“滑动”手势(持久的连续性手势)之前,系统进行判断时,发现其操作行为完全符合“平移”(这是一个瞬发的非持续手势)操作的所有必要的属性条件,所以系统就将其识别为“平移”。
1 | - (void)viewDidLoad { |
调用该方法的手势识别器 A 会给接收消息的手势识别器 B 发送一个消息,指定一定是 B 对手势操作识别失败之后,A 才开始接收并分析用户的手势操作。在 A 进行等待 B 对用户的手势操作进行分析并直到识别失败的过程中,A 的状态一直会处于某个可能初始状态, 除非B到达了分析识别“失败”的状态,A才开始分析并开始转向下一个状态。另一方面,如果 B 识别成功或者识别(成功)开始,A 就会被转向失败状态。
2.2 禁止手势识别器进行触摸分析
通过给识别器添加代理对象,开发者可以修改识别器的行为。协议 UIGestureRecognizerDelegate 提供了几个方法给开发者用来禁止手势识别器的触摸分析功 能。gestureRecognizer:shoulReceiveTouch:
和 gestureRecognizerShouldBegin:
两个方法都可选用。
当用户“触摸”操作开始时,开发者可以使用 gestureRecognizer:shoulReceiveTouch:
方法立即决定手势识别器是否需要处理用户的操作。每当“触摸”发生,该方法就会被调用。 如果不想对用户的触摸操作进行处理直接将该方法返回“NO”即可,默认情况下返回的 是“YES”。该方法并不会修改识别器的状态值。
2.3 允许手势识别同时异步进行
默认情况下,两个不同的手势识别器是不允许同时对手势进行识别处理的,但是假如开发者想要让用户在对某个视图同时进行捏合缩放和旋转操作,开发者就需要改变此默认设 定,通过实现方法 gestureRecognizer:shouldRecognizeSimutaneouslyWithGestureRecognizer:
可以达到此目的,这是由协议 UIGestureRecognizerDelegate 提供的一个可选方法。该方法会在某个识别器进行手势事件识别处理时被调用,从而决定是否需要定事件不让其他识 别器进行识别处理。默认情况下,该方法返回“NO”,如果开发者想要两个不同的手势 识别器同时对手势进行识别处理,让该方法返回“YES”即可。
备注:开发者只需要对其中一个手势的代理对象实现该方法并返回YES即可。这就意味着,两 个手势识别器中只要有一个返回YES,另外一个返回NO就不起作用了。
2.4 指定两个手势识别器的单向关系
如果开发者想让两个识别器进行交互,但是指定为一种单向关系,开发者就可以通过重写方法 canPreventGestureRecognizer:
或者 canBePreventedByGestureRecognizer:
之一并返回
“NO”(默认返回的是“YES”)来达到此目的。举个例子,如果想要在进行旋转操作的
时候屏蔽掉捏合缩放操作、而在捏合缩放操作的时候可以进行旋转,开发者就可以这样设
置 [rotationGestureRecognizer canPreventGestureRecognizer:pinchGestureRecognizer]
然后重写“旋转”手势识别器的子类方法并返回“NO”。关于如何创建继承 UIGestureRecognizer 子类的信息请参阅“创建自定义手势识别器”章节。
如果这两个手势识别器需要同时进行不相互干预,请参阅章节 2.3.默认情况下,这两个不同的手势识别器是不能同时进行手势识别操作的,也就是互相屏蔽的,其中任何一个处于活动状态,另外一个都是被屏蔽的。
与其他用户界面操作进行交互
在 iOS6 以及之后的版本中,默认情况下,所有的控制器都不允许手势识别器重复交叠。 比如,按钮(button)的默认操作行为是“单击”,然后,有一个“按钮”所在的视图 (view)绑定了一个“单击”手势识别器对手势进行处理,当用户“单击”这个按钮的时候,按钮指定的动作处理方法会接收到这个“单击”事件,然而视图的手势识别器却接收不到。
这种策略仅应用于: UIButton,UISwitch,UISlider,UIStepper,UISegementedControl 和 UIPageControl 上的单击或者滑动事件。
如果自定义控件继承自以上的类,又想要改变系统的默认设定,那就应该直接将手势识别器和其子类控件进行绑定,而不是和控件的父视图或者其他上层控件进行绑定。只有这样,手识别器才会优先接收到用户的触摸操作事件。
手势识别器处理原生触摸事件
1 点击事件的数据结构
在 iOS 中,每一个“触摸(touch)”行为对象就代表单根手指在屏幕上的一次运动操作。一个“手势(gesture)”可以有一个或者多个“触摸”行为对象,在 iOS 中以 UITouch 类对象进行抽象表示。例如,一个捏合缩小手势就有两个“触摸”行为对象:两根手指在屏幕上以相反运动方向相互靠拢运动。
一个事件(Event)包含了这次多点触摸行为序列的所有“触摸”对象。一个多点触摸行为序列开始于用户第一根手指触摸到屏幕、终止于用户的最后一根手指抬起并离开屏幕。 当一根手指进行运动时,iOS 系统就会实例化 UITouch 对象并发送给对应的事件对象。一个多点触摸事件会被抽象成一个类型为 UIEventTypeTouches 的 UIEvent 对象。
每一个“触摸”对象都只追踪一根手指的轨迹,其生命周期也仅限于整个触摸序列的起止期间。在此这段时间内,UIKit 跟踪手指的轨迹并及时更行其对象的属性。这些属性包括触摸的行为的方式、在当前视图对象中的位置、之前的位置以及时间戳。
2 App在”触摸处理“方法中接受”触摸“对象
在一次多点触摸序列事件中,当有新的触摸行为或者触摸行为发生变化时,app 会发送以下消息:
- touchesBegan:withEvent: 当一个或者多根手指开始触摸屏幕时调用。
- touchesMoved:withEvent: 当手指开始移动时调用。
- touchesEnded:withEvent: 当有手指离开屏幕时调用。
- touchedCancelled:withEvent: 当触摸序列事件被系统事件取消时调用,比如来了电话。
以上每一个方法都对应着一个“触摸阶段”,比如第一个方法会和 UITouchPhaseBegan 相对应。它的值存储在 UITouch 的 phase 属性中。
调整触摸事件路由
1 默认行为
默认情况下,事件会优先发送给手势识别器进行识别,当手势识别器识别失败后才会发送给同层级的视图。如果识别成功,同层级的视图不会受到触摸事件。
当触摸事件发生时,UITouches 触摸对象会先由 UIApplication 对象传递给 UIWindow 对象,然后,在传递给最底层的视图对象之前,UIWindow 对象会逐层向下, 将“触摸”对象传递给触摸事件发生位置所在的视图对象所绑定的手势识别器进行识别处理。
window 对象会延迟将“触摸”对象发送给视图对象,从而让手势识别器最先对“触摸” 进行分析处理。在延迟期间,如果识别器识别出来触摸手势,window 对象就不会将“触摸对象”传递给视图对象了,并将识别出来的手势序列中其他之前本该发送给视图对象的触摸对象取消掉。
2 修改默认行为
UIGestureRecognizer 的下面这些属性可以调整事件传递的顺序:
delaysTouchesBegan(默认值是 NO)。通常情况下,window 会在 Began 和 Moved 阶 段将触摸事件发送给 view 和手势识别器对象。如果将此属性值设置成 YES,window 就不会在 Began 阶段将“触摸”(UITouch)对象发送给视图对象,这样可以保证当手势识别器识别到某个手势时,就不有任何相关的 UITouch 对象被发送给 绑定的视图。
该属性值的设置类似于 UIScrollView 的 delaysContentTouches 属性;在这种情况 下,UIScrollView 就立即随着用户“触摸”动作的进行滚动,而不会将“触摸” (UITouch)对象发送给 SrollView 的子视图对象,所以也就不会有视觉上的反馈效果。
- delaysTouchesEnded(默认值为 YES)。当该属性被设置成 YES 时,可以保证视图对象的动作处理不会结束,这样一来该手势动作还有机会被取消。当手势识别器对触摸事件进行分析时,在 End 阶段window 不会将 UITouch 对象发送给发绑定的视图。如果识别器成功识别出来手势操作,UITouch 对象会被取消掉;若是识别 失败,window 对象就会将它们通过消息 touchesEnded:wihtEvent:发送给视图对象。如果将该属性值设置成 NO,就会把这些“触摸”(UITouch)对象发送给手势识别器的同时,也发送给视图对象进行分析处理。
自定义手势
自定义手势需要实现下列方法,在实现方法时,注意需要先调用父类方法。另外还需要精确设置state属性,系统是严格根据state属性来进行手势状态识别的。
1 | - (void)reset; |
1 自定义手势的事件处理方法
创建了一个非持续手势单指触摸的对勾手势识别器。通过记录手势操作的中心点、亦即向上勾动的转折位置点,所以就可以让外部捕获该坐标点的值。
1 | #import <UIKit/UIGestureRecognizerSubclass.h> |
连续动作手势的识别和非持续动作手势的识,识别器的状态值的转变是不一样的,在章节 “有限状态机中手势识别的操作”中有介绍。自定义手势识别器时,通过给其状态state 属性赋予相应的值,来指定手势识别对应的连续的还是非持续的手势。对勾手势作为一个非持续手势,就不要给其状态赋予Began或者Changed状态值了。
所以,在进行创建手势识别器的子类进行自定义手势识别时,最重要的一点就是准确为其状态属性state赋予准确的值。iOS系统需根据此状态属性,确保手势识别器能够按照预期对手势进行识别。
欲知更多如何自定义手势识别器知识,请观看《WWDC 2012: Building Advanced Gesture Recognizers 》。
2 重置手势识别器的状态
如果手势识别器的状态值转为 Recognized(识别成功)/Ended(结束识别),Canceled (取消),UIGestureRecognizer 在其状态回滚到 Possible 初始状态前,会调用 reset 方法。
通过实现 reset 方法,将所有的内部状态重置,以便手势识别器可以用于识别用户下一次手势操作,一旦手势识别器从该方法返回之后,就不再对后续的触摸操作进一步更新处理了。
1 | - (void)reset { |
事件响应链
事件需要投递给负责处理该事件的对象,按优先级从高到低的顺序投递,如此形成的链式责任链就是事件响应链。
单例对象 UIApplication 从事件队列取出事件后,会被转发给 app 的window对象,然后由此 window 对象将事件传递给事件发生所在的对象进行处理,这个初始对象是什么,取决于事件的类型:
- Touch Event(触摸事件)。对于触摸事件,window对象首先尝试将事件传递给事件发生所在的 view 。该view 就是所谓的 hit-test 对象。寻找 hit-test 对象的过程被称作 hit-testing。
- Motion and remote control events(运动和远程控制事件)。对于远程控制和运动事件,window对象会将事件发送给第一响应者(the first responder)进行处理。
1 Hit-Testing 过程
用户在使用手机时,会觉得滑动一个 TableView 然后内容随之滚动是理所应当的事情。但是把视角切换到屏幕和视图树两者时,就会发现两者完全是两个系统。用户本质上是做了在屏幕上移动手指的动作,这和屏幕上显示什么东西没有任何关系。
屏幕上的虚拟对象能响应用户的操作,这中间有着巨大的工作量,其中Hit-Testing 过程是非常重要的一环。经过这个过程以后,系统能把用户在一块玻璃上点击的位置转换成该位置上显示的虚拟对象。
Hit-Testing过程大致如下: 先检查点击的位置是否落在了对应的视图对象的范围内。如果在的话,就开始对此视图对象的子视图对象进行同样的检查。视图树中最底层那个包含此触摸点位置的视图对象,就是要查找的 hit-test 视图对象。一旦确定 hit-test 视图对象,系统就会把触摸事件传递给它进行处理。
举个例子,假设用户触摸了视图 E,如图所示。iOS 就会按照以下顺序对子视图进行 检查来查找 hit-test 视图:
- 触摸点在视图 A 的区域范围内,然后开始检查子视图 B 和 C
- 触摸点不在 B 的范围而在 C 的范围,于是就开始检查 D 和 E 视图
- 触摸点不在 D 的范围而在 E 的范围,而 E 视图是视图树最底层的并包含触摸点的视图对象,所以 E 就成为了 hit-test 视图
整个Hit-Testing 过程可以用下面的伪代码来描述:
1 |
|
hitTest:withEvent
方法根据 CGPoint 和 UIEvent 参数查找 hit-test 视图对象。 它会先调用 pointInside:withEvent:
方法。如果传入 hitTest:withEvent:
的 CGPoint 点对象位于视图对象的区域范围内,pointInside:withEvent:
返回值就是 YES,然后就会递归式地在返回 YES 的子视图对象上调用 hitTest:withEvent:
,直到找到底层的视图。
如果传入 hitTest:withEvent:
的点不在视图对象的范围内,第一次调用 pointInside:withEvent:
就会返回 NO,这个点就被忽略掉了,hitTest:withEvent:
就返回 nil。 如果子视图返回 NO,那么整个视图树的分支都会被忽略掉。所以如果父视图不包含某个触摸事件的点,子视图即使包含了这个点,也不会接收到此触摸事件。因为在父视图进行pointInside检查时就中断了事件的投递。
所以如果想要实现子视图超出父视图还能相应事件,需要改写父视图默认的hitTest:withEvent方法。
触摸对象UITouch 在其生命周期内会和hit-test 视图对象一直关联在一起,即使UITouch在后续的时间里移动并离开该视图对象的范围也是一样的。
hit-test 视图对象拥有最先对触摸事件进行处理的机会,如果 hit-test 视图对象无法处理该事件,事件对象就会沿着响应者的视图链向上传递,直到找到最适合处理该事件的对象或者到最顶层的window为止。
2 响应者组成的响应链
许多事件都依赖于响应者链(responder chain)进行事件传递。响应者链就是一系列的相关联的响应者。如果第一个响应者无法处理事件,响应者就会将事件传递给下一个响应者。
UIResponder 类是所有响应者的基类,不仅定义了事件处理的编程接口,同时还定义了通用的响应者行为。UIApplication、UIViewController、UIView 类的实体对象都是响应者。
第一响应者第一个接收事件。通常来讲,第一响应者是一个视图 view 对象。通过做两件事,一个对象就变成了第一响应者:
- 重写 canBecomeFirstResponder 并返回 YES;
- 接收 becomeFirstResponder 消息。如果有必要,对象本身可以自己发送此消息。
将某个对象变成第一响应者之前,一定要确保APP已经建立好了视图树。如通常应该在重写的 viewDidAppear:方法中调用becomeFirstResponder 方法,但是如果写在了 viewWillAppear 里面,此时因为视图树还没有建立起来,becomeFirstResponder 的返回值就 NO 了。
也不仅仅只是事件对象依赖于响应者链,响应者链可以被用于处理以下所有对象:
- Touch Events(触摸事件)。如果 hit-test 视图对象无法处理触摸事件,事件就会从hit-test 视图沿着响应链往上传递,直到找到合适的处理该事件的对象。
- Motion Events(运动事件)。要使用 UIKit 处理“摇动”(shake-motion)事件, 第一响应者就必须实现方法 motionBegan:withEvent:或者 motionEnded:withEvent:之一。
Remote Control Events(远程控制事件)。要对远程控制事件进行处理,第一响应者必须实现基类 UIResponder 的 remoteControlReceivedWithEvent:方法。
Action messages(动作消息)。当用户操作了某个控件,如按钮 button、switch, 对应的动作方法的目标是 nil,该消息会从以控件视图对象为开始的响应者链被发送出去。
Editing-menu messages(编辑菜单消息)。当用户点击了编辑菜单的指令,iOS 系统就会使用响应者链去查找到对应实现了必要处理方法(如 cut:,copy:以及 paste:) 的对象。
Text Editing(文本编辑)。当用户点击某个文本区域(UITextField)或者文本视图 (UITextView)时,对应的视图就会成为第一响应者。默认情况下,虚拟键盘会弹 出来,而且对应的 UITextField 或者 UITextView 就会被选中并变成正在编辑状态。
当用户点击某个 UITextField 或者 UITextView 的时候,UIKit 会自动把将对应的对象设置为第一响应者。对于其他类型,App 必须使用 becomeFirstResponder 方法显示地进行设置。
3 响应链的传递路径
如果初始对象(要么是 hit-test 视图,要么是第一响应者)无法对事件进行处理,UIKit 就会把事件传递给响应者链的下一个响应者。每个响应者都可以决定是自己进行事件处理,还是将事件通过方法 nextResponder 的调用,传递给下一个事件响应者。此过程一直进行下去,直到找到了处理该事件的对象,或者到达了响应者链的最后一个响应者。
响应者链开始于 iOS 检测到事件并将其传递到(事件发生所在的)初始对象,通常来讲这个对象是一个视图对象 view。初始视图对象会最先有机会对事件进行处理。如图所示,就是两个不同的 app 中事件的不同的两条事件传递路径。App 的事件传递路径由其特 定的结构所决定,但所有的事件传递路径都遵循同样的逻辑方法。
左边 APP 的事件传递路径如下:
- 初始视图对象尝试对事件进行处理,如果无法处理,就会将事件传递给其父视图对 象,因为视图树中,初始视图对象也并不是最顶端的对象。
- 父视图也进行同样的尝试,因为同样的原因也只能将事件继续向上传递。
- 视图控制器中最顶层的视图也进行同样的尝试,结果发现也处理不了,于是就传递了视图控制器。
- 视图控制器也一样无法处理,于是继续向上传递给了主窗体对象(window)。
主窗体也无法处理,于是就继续传递给 app 的单例实体对象。 6. 如果最后单例实体对象还无法处理,此事件就被丢弃了。
虽然右边的 APP 传递路径略微不一样,但是事件传递遵循的逻辑方法还是一样的:
- 视图将事件沿着其视图控制器的视图树向上传递,直到最顶端的视图。
- 顶端是图无法处理,就直接传递给视图控制器。
- 视图控制器无法处理,就会将事件传递给其顶端视图所在的父视图。重复 1-3,直到到达最顶端的根视图控制器(root view controller)。
- 跟视图控制器将事件传递给主窗体对象。
- 主窗体对象传递给 app 的单例实体对象。
自定义视图来处理响应链时,不要直接将事件或者消息直接发送给 nextResponder,而是调用父类的事件处理方法,来达到将事件沿着响应器链向上传递的目的,让 UIKit 框架来完成响应器链的事件消息传递。
View的事件交互模型
无论是用户主动触发还是程序主动改变,从事件产生到界面发生变化,系统会周期性地执行一系列程序来达成这一目的。这一系列的程序大致分成如图所示的几个部分:
下面分步骤进一步解释了图中的事件序列,并说明了每个阶段发生的情况以及希望应用程序作出响应的方式。
用户触摸屏幕。
硬件将触摸事件报告给UIKit框架。
UIKit框架将触摸打包到
UIEvent
对象中并将其分派到适当的视图。视图的事件处理代码会响应事件。例如:
- 更改视图或其子视图的属性(frame, bounds, alpha等)。
- 调用
setNeedsLayout
方法将视图(或其子视图)标记为需要布局更新。 - 调用
setNeedsDisplay
或setNeedsDisplayInRect:
方法将视图(或其子视图)标记为需要重绘。 - 通知控制器有关某些数据的更改。
当然,由您来决定视图应该做哪些事情以及应该调用哪些方法。
如果视图的大小被修改,UIKit将根据以下规则更新其子视图:
- 如果设置了autoresizing属性,UIKit会根据这些规则调整每个视图。
- 调用
layoutSubviews
方法,更新子视图。
如果任何视图的任何部分被标记为需要重绘,UIKit会要求视图重绘。对于实现了
drawRect:
方法的自定义视图,UIKit会调用该方法,进行重绘。任何更新的视图都与应用程序的其他可见内容合成,并发送到图形硬件进行显示。
图形硬件将渲染的内容传输到屏幕。