今天我们来聊下iOS编程中常见点击事件从分发传递到响应的完整流程
1.事件类别
- Touch events
UIView
上的常见点击事件 - Press events
AppleTV
遥控器或者游戏控制器或其他带有实体物理键所触发的事件 - Shake-motion events
由加速计、陀螺仪、磁力仪触发的事件 - Remote-control events
额外配件如耳机上的音视频播放按键所触发的事件(视频播放、下一首)
今天我们只讲Touch events
相关事件的传递与响应
2.响应链工作原理
从你手指触到到屏幕中某一控件到其响应相关事件其实是分为两步:事件的传递与事件的响应
事件的传递涉及到了UIView
中的两个方法
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
//判断当前点击事件是否存在最优响应者(First Responder)
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
//判断当前点击是否在控件的Bounds之内
事件的传递其实就是在事件产生与分发之后如何寻找最优响应视图的一个过程
2.1事件的传递流程
1.触碰屏幕产生事件UIEvent
并存入UIApplication
中的事件队列中, 并且在整个视图结构中自上而下的进行分发
2.UIWindow
接受到事件开始进行最优响应视图查询的过程(逆序遍历subviews
)
3.当到UIViewController
这一层时同样对其根视图(self.view
及其上subviews
)开始最优响应视图查询。该查询会调用上述提及到两个于UIView
的方法,之所以采用逆序查询也是为了优化查找速度,毕竟后addSubview
的视图在上易于命中
Note:
如果在hitTest & pointInside
过程中查询到最优响应视图则后续对于其他subviews
遍历查询则会停止
2.1.1视图命中查找流程
1.调用hitTest
方法进行最优响应视图查询
- hidden = YES
- userInteractionEnabled = NO
- alpha < 0.01
以上三种情况会使该方法返回nil
,即当前视图下无最优响应视图
2.hitTest
方法内部会调用pointInside
方法对点击点进行是否在当前视图bounds
内进行判断,如果超出bounds
,hitTest
则返回nil
,未超出范围则进行步骤3
3.对当前视图下的subviews
逆序采取上述1 2
步骤以查询最优响应视图。如果hitTest
返回了对应视图则说明在当前视图层级下有最优响应视图,可能为self
或者其subview
,这个要看具体返回。
下面是最优命响应图查询代码示例
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (self.alpha < 0.01 || !self.userInteractionEnabled || self.hidden) {
return nil;
}
if (![self pointInside:point withEvent:event]) {
return nil;
}
__block UIView *hitView = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) {
CGPoint convertPoint = [self convertPoint:point toView:subview];
hitView = [subview hitTest:convertPoint withEvent:event];
if (hitView) {
*stop = YES;
}
}];
return hitView ? : self;
}
好了, 事件的分发与传递流程我们已经讲完了,那我们该如果进行相关的验证呢?首先我们要明确相关要确认的点:
-
UIApplication
开始自上而下的进行事件分发 -
UIView
内部开始反向遍历查找最优视图
UIApplication 开始自上而下的进行事件分发
这个我们可以打开Instrument
中的TimeProfiler
进行一个整体的函数调用查看
在使用Instrument
之前记得为其配置相对应的dSYM
文件,否则到时候TimeProfiler
中看到的将是调用函数的16进制地址,这不便于我们对问题的定位
然后我们在ViewController
中添加一个Button
和对应按钮事件就可以开始运行TimeProfiler
了(Command + i
)
从图中我们可以看到分别一次调用了[UIApplication endEvent:]
及 [UIWindow sendEvent:]
这里可能会有同学注意到上面所提及到流程图中UIWindow
是进行最优响应视图查询的,为什么TimeProfiler
中显示了其调用了一次事件分发。这里让我们来看下Xcode
文档中对于UIWindow
中sendEvent
方法的注释
called by UIApplication to dispatch events to views inside the window
所以博主认为这里的调用是没问题的
UIView 内部开始反向遍历查找最优视图
首先我们可以利用Method Swizzling
交换下我们需要监测的 hitTest
方法
#import "UIView+WCQHitTest.h"
#import
@implementation UIView (WCQHitTest)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL oriSEL = @selector(hitTest:withEvent:);
SEL swiSEL = @selector(wcq_hitTest:withEvent:);
Method oriMethod = class_getInstanceMethod(class, oriSEL);
Method swiMethod = class_getInstanceMethod(class, swiSEL);
BOOL didAddMethod = class_addMethod(class, oriSEL,
method_getImplementation(swiMethod),
method_getTypeEncoding(swiMethod));
if (didAddMethod) {
class_replaceMethod(class,
swiSEL,
method_getImplementation(oriMethod),
method_getTypeEncoding(oriMethod));
}else {
method_exchangeImplementations(oriMethod, swiMethod);
}
});
}
- (UIView *)wcq_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"%@ %s",[self class], __PRETTY_FUNCTION__);
return [self wcq_hitTest:point withEvent:event];
}
然后我们分别新建三个UIView
的子类: AView
、BView
、CView
并依次按顺序添加到ViewController
上
然后我们依次点击A
、B
视图看下hitTes
调用顺序是否和预期一致
2.2事件的响应流程
这里引用下苹果官方文档中的一张图
响应链 其实是由一个个UIResponder
的子类构成的,UIResponder
是系统一个负责接受和处理事件的类。
- (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;
而以上这几个响应触碰的方法其实也是出自于UIResponder
类,
UIView
作为UIResponder
的子类能够处理点击事件也就无可厚非了
现在讲讲事件的响应流程:
1.首先已确定最优响应视图
2.判断最优响应视图能否响应事件,如果视图能进行响应则事件在响应链中的传递终止。如果视图不能响应则将事件传递给 nextResponder
也就是通常的superview
进行事件响应
3.如果事件继续上报至UIWindow
并且无法响应,它将会把事件继续上报给UIApplication
4.如果事件继续上报至UIApplication
并且也无法响应,它将会将事件上报给其Delegate
,但前提下这个Delegate
不属于 响应链 并且是UIResponder
的子类
5.如果最终事件依旧未被响应则会被系统抛弃
Note:
也并非所有的nextResponder
即是superview
,比如UIViewController
的根视图self.view
的nextResponder
是其所在UIViewController
。而如果UIViewController
如果是UIWindow
的根控制器,那么它的nextResponder
就是UIWindow
,但如果UIViewController
是另外一个UIViewController
present
出来的话,那么它的nextResponder
就是之前所执行present
操作的那个UIViewController
流程讲完了,还是那句话: 设法证实其关键节点
- 事件响应自下而上进行上报
我们这次可以利用该方法进行验证:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
同时我们也看看该方法在文档中的描述,看能否查找到一些有用的信息节点
果然~
UIKit calls this method when a new touch is detected in a view or window. Many UIKit classes override this method and use it to handle the corresponding touch events. The default implementation of this method forwards the message up the responder chain. When creating your own subclasses, call super to forward any events that you do not handle yourself.
根据文档所述,该方法默认实现就是将事件沿 响应链 进行自下而上的上报。现在我们同样可以利用Method Swizzling
再次对touchesBegan
方法进行监测,这里有一个要注意的地方:由于这次置换的方法中调用到super
方法,所以我们置换的时候置换的是UIView
中的touchesBegan
方法而没去置换UIResponder
中的touchesBegan
方法
- (void)wcq_touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
NSLog(@"%@ %s",[self class], __PRETTY_FUNCTION__);
[super touchesBegan:touches withEvent:event];
}
同时这次我们为了验证事件的响应是自下而上,我们调整下UI
的结构:
运行模拟器点击CView
3.总结
- 事件分发与传递:自上而下
- 事件响应:自下而上
当然这仅仅只是 Touch event
在 Responder Chain
中的传递与响应流程。不同类型的 UIEvent
分发与响应原理还不一致。
4.最后
你的点赞与指正都是我继续创作的动力,感谢你长的那么帅(漂亮)还来看我的文章