触摸、事件和响应者那些事
这篇文章是我在阅读相关苹果官方文档后总结整理出来的一些平常可能不太注意到,但是又比较有用的知识点。如有错误,欢迎指出。
事件传递
事件本质
什么是事件?官方文档的解释是:
Events in iOS represent fingers touching views of an application or the user shaking the device. One or more fingers touch down on one or more views, perhaps move around, and then lift from the view or views. As this is happening, iPhone’s Multi-Touch system registers these touches as events and sends them to the currently active application for processing
当触摸事件发生时,系统会把触摸注册为一个事件(Event),传递给系统处理。一个完整的手势过程,是从第一根手指触碰到屏幕开始,到最后一根手指离开屏幕为止。
当然,手机的摇晃也要算是Event(它属于UIEventType里的motion),不过那是另一回事了,我们后边会再说。
屏幕上的每一个触摸点用UITouch来表示。在整个手势过程中,每一个UITouch对象会被系统持有,但是它的状态是可变的,分别要经历touchesBegan
, touchesMoved
和 touchesEnded
三个状态。
当然,个别的时候,一个UITouch会经历第四个状态:touchesCanceled。一个事件被取消通常是由于一个外部事件(例如来电)的产生,让系统终止了本次touch事件。
UITouch
每一个UITouch对象表示一根手指对屏幕的触摸,包含位置、大小、移动状况以及触摸的力度(力度仅在支持3Dtouch或者Apple Pencil的设备上管用)。
UITouch类有以下我觉得比较重要的属性和方法:
- locationInView: 表示触摸点在给定视图对应坐标系下的位置。如果view参数传nil,那么给出的是touch在window对于坐标系中的位置。
- previousLocation(in:) 表示前一次该touch在给定视图中的方位。
- view 表示touch对象被“传送”到的view。注意,这个view并不一定是touch对象本身所在的view(即,不一定是用户手指点中的view)。例如,当一个gestureRecognizer接收到一个触摸事件时,view为nil,因为没有view在接收这个触摸。
- preciseLocation(in:) 这个表示一个touch在给定视图中的精确方位。注意,不要把返回的CGPoint用于hitTest。有的时候hitTest返回值显示touch在给定view中,但是preciseLocation方法返回的值却表明touch不在view中。
- phase:表示UITouch对象的几个阶段。按照顺序依次变化:began, moved, stationary, ended/canceled
响应者链
UIResponder
UIResponder是一个抽象类,被苹果称为事件处理的“主心骨”。具体到事件发生时,继承自UIResponder的对象主要有两个方面的职责:
- 通过覆写四个关于touches的方法,拦截并处理事件(如果该对象需要响应事件的话)。
- 将事件顺着响应者链向上传递(如果该对象不需要响应该事件的话)。
另外,inputView也可以作为事件的响应(在这里我把它理解为“输入响应”)。例如,当我们点击一个textView,这个view会变成First Responder,并显示它的 inputView。关于inputView和firstResponder我们会另起一篇文章来详细描述它。
接下来我们看几个UIResponder当中重要的属性和方法:
nextResponder
顾名思义,它表示响应者链中的下一个响应者。值得注意的是,UIResponder本身并不存储或者预先设置任何值给nextResponder,该属性默认设置为nil。到底谁是nextResponder还需要继承自它的类自己来覆写。例如,一个View的nextResponder可能是它的superView(如果有的话),也可能是viewController(如果该view就是根视图)。一个ViewController的nextResponder可能是UIWindow(如果其根视图是这个window的root view的话),也可能是另一个viewControllerB(如果viewController嵌套在viewControllerB中显示的话)。UIWindow的nextResponder就是UIApplication。UIAPPlication的nextResponder就是appDelegate(当且仅当这个delegate是UIResponder的实例而非一个view,viewController,或者app object本身)。
-
isFirstResponder
字面意思,“是否是第一响应者”。关于这个第一响应者,目前我暂时无法获得一个准确的定义,但基本可以肯定的是,此处的第一响应者和事件传递过程中寻找的“最合适的响应者”并非同一回事。因此,这部分暂时略过,等找到准确定义之后再发文说明。 -
canBecomeFirstResponder
字面意思,表示一个对象是否能够成为第一响应者。UIKit会把某些事件,例如motion event,分发给“第一响应者”。默认返回No。 -
becomeFirstResponder
让消息接收者成为第一响应者。这个方法相信咱们在开发过程中都快要写烂了——遇到textField,调用此方法,让系统弹出键盘。文档指出,对某个对象调用该方法后,并不能保证该对象一定能够成为firstResponder,因为,UIKit会首先对当前的firstResponder发送resignFirstResponder消息,然而后者可能会失败(例如自定义的对象重写了resignFirstResponder,通过return NO
拒绝退出第一响应者状态)。
如果当前firstResponder成功地resign了,UIKit还要调用当前对象的canBecomeFirstResponder
方法,而如上文所言,后者默认返回NO。
再如果,canBecomeFirstResponder
返回了YES——那么该对象将成为第一响应者。至此,所有发送给第一响应者的事件都被指派给这个对象,且系统将会试图展示该对象的inputView。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *) event
默认情况下,不管你用了几根手指去点这个view,touches集合中仅包含一个UITouch 对象。如果你希望接收到多个手指的触控,记得调用view.isMultipleTouchEnabled=true。
有两个注意点:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *) event
- 该方法的默认实现是将事件沿响应者链向上传递。因此,如果你想要覆写该方法,要确保调用了super的touchesBegan方法以传递任何你自身不处理的事件!
- 如果你覆写该方法的时候没有调用super,那么你需要在你的自定义类中同时调用其他touches相关的方法,哪怕在这些方法中什么都不做。
事件拦截
UIEvent
- 它用于表示用户和APP的一个交互的对象。
- 永远不要retain一个UIEvent对象,或者是其内部的属性。如果的确需要retain某个UIEvent自带的属性,应对后者使用copy操作。
- 包含四个type: touches, motion, remote-Control, presses。motion是由UIKit触发的(要和Core Motion Framework的motion event区分开来。)remote-Control指的是用户通过外部配件(比如耳机、遥控器)对设备发出的操作指令。Press事件指的是用户通过游戏控制器、遥控器等的实体按键来和设备进行的交互行为。所有的这些可以通过UIEvent的type和subtype属性来加以判断。
接下来介绍一些重要的属性和方法:
func touches(for view: UIView) -> Set<UITouch>?
返回该事件中,属于指定view上的所有touch。
func touches(for window: UIWindow) -> Set<UITouch>?
和上面类似。
func touches(for gesture: UIGestureRecognizer) -> Set<UITouch>?
返回该手势识别器所接收到的所有UITouch对象。
func coalescedTouches(for touch: UITouch) -> [UITouch]?
这个方法是在iOS9之后提出的,它利用了一种叫做“触摸合并”的技术。由于系统对touch的采样在touchesMoved方法中进行,而后者的调用频率最高也才60次/秒(如果主线程有其他高耗时的操作,该方法的调用频率甚至更低),这样,就不可避免地会出现“漏点”的情况。而在新的iPad Pro2代上,界面刷新率达到了120Hz(使用Apple pencil时刷新率一度飙升至200Hz),因此,使用传统的touchesMoved必然会造成一个奇观:用户的手指在前面划线,画出来的线在后边追赶用户的手指……抑或是用户命名画了一条弧线,得到的却是一条“折线”……
基于此,苹果提出了触摸拟合技术,它可以让你获取到所有在两次touchesMoved调用之间的UITouch对象。
使用方法如下:
''override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)"
''{
'' if let coalescedTouches = event.coalescedTouchesForTouch(touch) {
'' print("coalescedTouches:", coalescedTouches.count)
''
'' for coalescedTouch in coalescedTouches
'' {
'' //Additional operations
'' }
''
'' }
'' }
`func predictedTouches(for touch: UITouch) -> [UITouch]?
同样是在iOS9以后,同样是为了减少延迟,苹果还推出了触摸预测技术,它根据先前触摸的点,使用一套非常精密的算法,来大致预测下一个被触摸的点所在的坐标。因此,开发者可以使用预测出来的点来提前做好UI更新的准备。
使用方法和func coalescedTouches(for touch: UITouch) -> [UITouch]?
基本一样,在此不多赘述。
UITouch,UIEvent,UIResponder,UIGestureRecognizer的区别与联系
这一部分就算作是本文的小结了。我们来梳理一下四者的区别和联系:
- UITouch:表示一根手指在屏幕上的触摸、移动,其生命周期从手指触摸屏幕时开始,到手指离开屏幕(或者被cancel)为止。
- UIGestureRecognizer: 手势识别器。一个手势可能要一根or多根手指来完成,因此一个手势包含多个UITouch对象。
- UIEvent:表示“事件”,有三个大类:触摸事件、动作事件、远程事件。一个触摸事件(touch event)包含了一个或多个与该事件有关的触摸对象,后者用UITouch对象来表示。
- UIResponder:响应事件的一个“抽象类”,需要响应事件的类必须继承自它。多个响应者组成响应者链。
另外,一个完整的触摸序列,是从第一根手指按下开始,到最后一根手指一开屏幕为止。