应用程序使用响应者对象来接收和处理事件,属于UIResponder类的实例对象都是响应者,常见的子类包括UIView、UIViewController和UIApplication。响应者接收到原始事件后,必须处理该事件或者将此事件转发给另一个响应者。当应用程序接收到一个事件时,UIKit会自动将该事件指向最合适的响应者对象,此响应者称为第一响应者,第一响应者会将未处理的事件传递给处于激活状态的响应者链中的下一个响应者对象。应用程序中不存在单一的响应者链,UIkit定义了如何将事件从一个响应者传递到下一个响应者的默认规则,我们可以随时通过覆盖响应者对象中的nextResponder属性来更改这些规则。
UIResponder是所有可以响应事件的类的基类。
NS_CLASS_AVAILABLE_IOS(2_0) @interface UIResponder : NSObject
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
#else
- (nullable UIResponder*)nextResponder;
#endif
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canBecomeFirstResponder; // default is NO
#else
- (BOOL)canBecomeFirstResponder; // default is NO
#endif
- (BOOL)becomeFirstResponder;
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canResignFirstResponder; // default is YES
#else
- (BOOL)canResignFirstResponder; // default is YES
#endif
- (BOOL)resignFirstResponder;
#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL isFirstResponder;
#else
- (BOOL)isFirstResponder;
#endif
// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet *)touches NS_AVAILABLE_IOS(9_1);
下图显示了应用程序中的默认响应者链,其界面包含一个label,一个text field,一个button和两个background view。如果text field没有处理触摸事件,UIKit会将事件发送到text field的父视图对象,如果事件还是未被处理,UIKit会继续发送该事件到此视图的父视图,直到发送到window的根视图,然后响应者链从根视图转移到持有此根视图的视图控制器,再从视图控制器转移到window。如果window不处理这个事件,UIKit会将事件传递给UIApplication对象。如果应用程序的委托对象是UIResponder类的实例并且响应者链中还不包含该对象,那么UiKit可能将该事件传递给应用程序的委托对象。
事件传递中UIWindow会根据不同的event,用不同的方式寻找initial object,initial object决定于当前的事件类型。比如Touch Event,UIWindow会首先试着把事件传递给事件发生的那个view,就是下文要说的hit-testview。对于Motion和Remote Event,UIWindow会把例如震动或者远程控制的事件传递给当前的firstResponder,有关firstResponder的相关信息请看这里。下面主要讲Touch Event的hit-testview。
有了事件响应链,接下来的事情就是寻找响应事件的具体响应者了,我们称着为:Hit-Testing View,寻找这个View的过程我们称着为Hit-Test。
那么什么是Hit-Test呢,我们可以把它理解为一个探测器,通过这个探测器我们可以找到并判断手指是否点击在某个视图上面,换句话说就是通过Hit-Test可以找到手指点击到的处于屏幕最前面的那个UIView。
在解释Hit-Test是怎么工作之前,先来看看它是什么时候被调用的。前面说Hit-Test是一个探测器,那么在代码里面其实就是一个函数,UIView有如下两个方法:
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // default returns YES if point is in bounds
每当手指接触屏幕,UIApplication接收到手指的事件之后,就会去调用UIWindow的hitTest:withEvent:,看看当前点击的点是不是在window内,如果是则继续依次调用subView的hitTest:withEvent:方法,直到找到最后需要的view。调用结束并且hit-test view确定之后,这个view和view上面依附的手势,都会和一个UITouch的对象关联起来,这个UITouch会作为事件传递的参数之一,我们可以看到UITouch头文件里面有一个view和gestureRecognizers的属性,就是hitTest view和它的手势。
现在知道Hit-Test是什么时候调用了,那么接下来看看它是怎么工作的。Hit-Test是采用递归的方法从view层级的根节点开始遍历,看看下面这张图:
UIWindow有一个MianVIew,MainView里面有三个subView:view A、view B、view C,他们各自有两个subView,他们层级关系是:view A在最下面,view B中间,view C最上(也就是addSubview的顺序,越晚add进去越在上面),其中view A和view B有一部分重叠。如果手指在view B.1和view A.2重叠的上面点击,按照上面说的递归方式,顺序如下图所示:
递归是向界面的根节点UIWindow发送hitTest:withEvent:消息开始的,从这个消息返回的是一个UIView,也就是手指当前位置最前面的那个 hittest view。 当向UIWindow发送hitTest:withEvent:消息时,hitTest:withEvent:里面所做的事,就是判断当前的点击位置是否在window里面,如果在则遍历window的subview然后依次对subview发送hitTest:withEvent:消息(注意这里给subview发送消息是根据当前subview的index顺序,index越大就越先被访问)。如果当前的point没有在view上面,那么这个view的subview也就不会被遍历了。当事件遍历到了view B.1,发现point在view B.1里面,并且view B.1没有subview,那么他就是我们要找的hittest view了,找到之后就会一路返回直到根节点,而view B之后的view A也不会被遍历了。
一图胜千言:
注意hitTest里面是有判断当前的view是否支持点击事件,比如userInteractionEnabled、hidden、alpha等属性,都会影响一个view是否可以相应事件,如果不响应则直接返回nil。
不接收触摸事件的三种情况
(1)不接收用户交互 userInteractionEnabled = NO
(2)隐藏 hidden = YES
(3)透明 alpha = 0.0 ~ 0.01
我们留意到还有一个pointInside:withEvent:方法,这个方法跟hittest:withEvent:一样都是UIView的一个方法,通过他开判断point是否在view的frame范围内。如果这些条件都满足了,那么遍历就可以继续往下走了,代码表现大概如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (!self.isUserInteractionEnabled ||
self.isHidden ||
self.alpha <= 0.01 ||
![self pointInside:point withEvent:event]
) {
return nil;
}
for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
CGPoint convertPoint = [subView convertPoint:point toView:self];
UIView *hitTestView = [subView hitTest:convertPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
UIKit会自动查找first responder,那么它是如何实现这个查找过程呢?通过文档
我们可以了解到它底层是通过view-based hit-testing来确定哪里发生了event,具体是通过hitTest:withEvent:方法来查找view,将会成为first responder。
event分发与传递:自上而下。(UIApplication-window-…)
event响应:自下而上。(view-superView-…)
一个事件响应者的完成主要分为2个过程: hitTest方法命中视图和响应者链确定响应者; hitTest的调用顺序是从UIWindow开始,对视图的每个子视图依次调用,也可以说是从显示最上面到最下面,直到找命中者; 然后命中者视图沿着响应者链往上传递寻找真正的响应者。
https://developer.apple.com/documentation/uikit/touches_presses_and_gestures/using_responders_and_the_responder_chain_to_handle_events?language=objc
https://developer.apple.com/documentation/uikit/uiresponder?language=objc
https://www.jianshu.com/p/84c0ca05abc3
http://zhoon.github.io/ios/2015/04/12/ios-event.html
https://www.jianshu.com/p/2f664e71c527
http://smnh.me/hit-testing-in-ios/
https://juejin.im/post/5bdfe2e451882516fb2b8e17