乔帮主在发布会上提到,用户的手才是最好的输入设备,的确,iPhone之后,非触屏手机再已难觅。触摸是最基本的用户输入事件,理解iOS特有的触摸事件响应机制,能够良好管理程序中触摸响应方法,避免冲突的发生。
iOS中的事件
iOS中的事件主要分为三类:
- UIControl Actions: 使用target/action注册的SEL。
- User Events: 用户与应用之间的交互:触摸,输入文字,摇晃,远程控制等。
- System Events: 应用启动,切前后台,低内存等。
cocoa和cocoa touch的程序启动后,,会首先初始化一些基本资源:在主线程创建一个main event loop;初始化主UIWindow
。
main event loop本质上是一个NSRunLoop
,与其他辅助线程的run loop不同,其是自创建后自动开始运行的。主消息循环最大的特点是:它在创建时就与负责捕获用户事件的系统底层建立了连接,所以它的input source可以收到系统传递过来的用户事件。UIApplication
对象会将当前要处理的用户事件封装成UIEvent
,发送给UIWindow
,在由UIWindow
转发给对应的响应者。
UIEvent
表示用户与iOS产生交互的事件,UIWindow
将触摸事件发送给hitTest View,其他事件发送给first responder,若它们不能处理该事件,事件在响应链向上传递,找到最终的响应者或丢弃。
本文主要介绍触摸事件的响应机制。
iOS中能够捕获触摸事件的类
iOS程序中,有三种类可以接受用户的触摸事件并响应,分别是:UIControl
, UIReponder
, UIGestureRecognizer
,这三个类在参与触摸响应机制的时机不同,在实际使用时要加以注意。
iOS中的触摸事件
iOS中使用UItouch
来表示用户的一根手指在屏幕上的触摸行为。当用户触摸屏幕时,硬件会捕捉到触摸行为,将触摸点的半径、力度和坐标等发送给iOS,经过UIKit
封装后,得到UITouch
对象。通过UITouch
对象,我们可以获得其关联的视图(hitTest View),在视图中的坐标,生命周期的当前阶段,点击数等信息。。一次用户点击多次的事件,其只包含一个UITouch
触摸类型的UIEvent
包含至少一个UITouch
,也就是用户在屏幕上的一次手势操作的手指运动,其会持有此次事件相关联的UITouches
序列。,即在一次手势操作中,其中一个手指中途离开屏幕,它所对应的UITouch
依然存在于该事件中。响应者会在touchesBegan:withEvent:
等方法中获取UITouch
对应的UIEvent
。
UITouches
序列在用户第一根手指触摸屏幕时开始,最后一根手指离开时结束,当手指状态变化时,iOS会将序列中的UITouch
对象发送给UIEvent
对象。
iOS的触摸事件响应机制
当用户触摸屏幕时,对应的触摸事件会加入到UIApplication
事件队列中,当下一个RunLoop来临时,UIApplication
会将出列最前端的事件,发送给当前的UIWindow
(key window
)。
UIWindow
会调用hitTest:withEvent:
方法,开始hit-testing流程寻找包含触摸点的视图。该流程会返回包含触摸点的层级最低的视图。
每当用户触摸屏幕时,UIKit
都会执行hit-testing,之后再从hitTest视图开始寻找事件的响应者。当hitTest视图决定后,它就关联了对应的触摸事件,会持续收到触摸事件生命周期的方法,(touchBegan, touchMove, touchCancel/touchEnd),即使是触摸点已经在touchMove阶段移出了hitTest视图,它依然能够收到后续的消息。
Note: A touch object is associated with its hit-test view for its lifetime, even if the touch later moves outside the view.
hit-testing流程
iOS中hit-testing使用逆前序的深度遍历算法来确定用户点按的最低层级(最靠近用户)的视图,该hitTest视图是触摸事件的响应链头结点。
逆前序的深度遍历算法:根节点-->右子树-->左子树。
当收到触摸事件后,UIApplication
在当前视图层级中,从key window
开始(最顶级),从上往下遍历子视图调用hitTest:withEvent:
,若找到hitTest视图则停止遍历并返回。
当视图收到hitTest:withEvent:
方法后,通过下列条件判断是否在该视图执行hit-testing。
-
pointInside:withEvent:
方法返回YES。pointInside:withEvent:
方法用来判断触摸点是否在当前视图内。 - hidden == NO。
- userInteractionEnabled == YES。
- alpha >= 0.01。若view的content绘制为透明的,则不受影响。
需要注意的是,当clipsToBounds == NO时,视图的子视图可能会超出其bounds,这种情况如果触摸点在子视图超出父视图的范围,那么hit-tesing不会再此视图树上执行。
如图,当用户触摸
viewB.1
时,UIApplication
对象收到触摸事件,从key window
开始执行hit-testing,首先访问viewC
,由于pointInside:withEvent:
方法返回NO,取消执行并访问viewB
,满足执行,则从右往左开始访问其子视图(视图层级从下往上),找到viewB.1
,它没有子视图,则返回自己。最终UIWindow
对象将viewB.1
作为hitTest视图返回给UIApplication
对象。可以看到,当某一视图收到
hitTest:withEvent:
方法后,它会向所有子视图发送hitTest:withEvent:
方法,若它的没有子视图或所有子视图返回nil,那么就返回自己,所有hit-testing流程最终一定会找到一个对象UIView/UIWindow
去接收触摸事件。以下是
hitTest:withEvent:
可能的实现。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
return nil;
}
responder chain
responder chain是UIResponder
对象组成的链形结构,它以first responder为头结点,UIApplication
对象为尾节点,事件从头开始在响应链中向上传递。
UIResponder
用来设计处理事件,UIApplication
, UIViewController
, UIView
都是其子类,只要它们实现了UIResponder
中的钩子方法,就可以响应对应的事件。
其中
first responder
用来第一个接触事件,可以使用becomeFirstResponder
来设置它,主要要在视图层级已经完全建立之后再设置。
If you try to assign the first responder in viewWillAppear:, your object graph is not yet established, so the becomeFirstResponder method returns NO
默认情况下,fist responder
是当前UIWindow
中最有可能响应事件的UIView
,这由UIkit
决定。
iOS中大部分的事件都依赖响应链来找到最终的响应者,在UIResponder
的头文件中可以看到,Touch events,Motion events,Remote events,UIControl Action,Text editing,press events等事件都可以在响应链中传递。
寻找响应对象
当UIApplication
在处理的事件时,触摸事件会交给hitTest view
开始的响应链处理,其他的动作事件,远程事件,系统事件等,会交给first responder
开始的响应链处理。
UIKit会将用户事件发送给理论上最合适的对象。所以当程序中的响应者要经过很长的查找路径时,这时就要考虑是否实现是否设计合理了。
UIKit first sends the event to the object that is best suited to handle the event. For touch events, that object is the hit-test view, and for other events, that object is the first responder
对于触摸事件,hit-test视图获得了最先接受触摸对象的机会,但如果它不能处理对应的触摸事件,那么UIKit会沿着以hit-test开头的响应链寻找能够最终的响应者。
当找到响应者或已经到链尾(UIApplication)仍不能处理,UIKit会停止查找,对于后者,对应的事件会被丢弃。
除了UIResponder
对象,UIGestureRecognizer
与UIControl
也可以响应触摸事件,但它们参与触摸事件响应的方式不同。
-
UIGestureRecognizer
在响应链中的位置取决于依附的视图。 -
UIControl
参与响应的方式决定于其关联的target。
UIGestureRecognizer
要先于视图收到触摸事件,但需要注意的是,若该视图也可以响应触摸事件(实现了UITouch
生命周期函数),那么手势对象并不会阻碍视图的响应,双方是同时响应的,只不过存在先后顺序。
响应触摸事件
当确定了响应链后,UIWindow
会向hitTest View
发送以下方法:
- (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;
这是UIResponder
用于响应触摸事件的方法,这些钩子方法的默认实现是向nextResponder
转发方法。
当触摸事件在响应链上传递时,判断当前UIResponder
能否响应的条件是:其是否实现了touchesBegan
方法。
在这些UITouches
序列的生命周期方法中,我们可以获取对应UIEvent
与UITouch
,利用它们所提供的信息,进一步决定如何响应用户的触摸事件。