乔帮主在发布会上提到,用户的手才是最好的输入设备,的确,iPhone之后,非触屏手机再已难觅。触摸是最基本的用户输入事件,理解iOS特有的触摸事件响应机制,能够良好管理程序中触摸响应方法,避免冲突的发生。
iOS中的事件
iOS中的事件主要分为三类:
- UIControl Actions: 使用target/action注册的SEL。
- User Events: 用户与应用之间的交互:触摸,输入文字,摇晃,远程控制等。
- System Events: 应用启动,切前后台,低内存等。
cocoa和cocoa touch的程序启动后,,会首先初始化一些基本资源:在主线程创建一个main event loop;初始化主UIWindow。
应用启动过程-w500
main event loop本质上是一个NSRunLoop,与其他辅助线程的run loop不同,其是自创建后自动开始运行的。主消息循环最大的特点是:它在创建时就与负责捕获用户事件的系统底层建立了连接,所以它的input source可以收到系统传递过来的用户事件。UIApplication对象会将当前要处理的用户事件封装成UIEvent,发送给UIWindow,在由UIWindow转发给对应的响应者。
iOS响应用户事件
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生命周期函数),那么手势对象并不会阻碍视图的响应,双方是同时响应的,只不过存在先后顺序。
UIGestureRecognizer与UIView的接触事件的次序
响应触摸事件
当确定了响应链后,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,利用它们所提供的信息,进一步决定如何响应用户的触摸事件。
