- 响应者对象介绍
- 什么是响应者链
- 事件响应流程(事件的产生和传递)
- 怎么寻找最合适的 view
- 应用
在runLoop
的介绍中, 说到了,runLoop
在事件响应的应用,
苹果注册了一个Source1
(基于mach port
的) 用来接收系统事件, 其回调函数为 _IOHIDEventSystenClientQueueCallback();
当一个硬件事件 (触摸/锁屏/摇晃等) 发生后, 首先由IOKit.framework
生成一个IOHIDEvent
事件并由SpringBoard
接收, 这个过程的详细情况可以参考这里; SpringBoard
只接收按键(锁屏/静音等), 触摸,加速,接近传感器等几种 event, 随后通过 mach port 转发给需要的 App 进程; 随后触发 App 注册 苹果注册的那个 Source1 就会触发回调, 并调用_UIApplicationHandleEventQueu()
进行应用内部的分发;
_UIApplicationHandleEventQueue()
会把IOHIDEvent
处理并包装成 UIEvent
进行处理或者分发, 其中包括识别UIGesture / 处理屏幕旋转 / 发送给 UIWindow
等; 通常事件比如 UIButton
点击, touchsBegin / Move / End / Cancel
事件都是在这个回调中完成的;
所以当我们 触摸手机屏幕时, 系统会将这一操作封装成一个UIEvent
对象, 放到 runLoop
的事件队列里面, UIApplication
从事件队列取出事件, 然后找到该事件的第一响应者处理该事件, 这里主要介绍事件的产生和传递 — 响应者链;
一、响应者对象介绍
响应者对象是什么?
响应者对象是一个能够响应和处理事件的对象; UIResponder 是所有响应者对象的基类, 继承自 UIResponder 的对象称为响应者对象; UIApplication, UIWindow, UIViewController 和所有继承自 UIView 的 UIKit 类都直接或间接继承自 UIResponder;
UIResponder 一般响应一下几种事件: 触摸事件(touch handling), 点按事件(press handling), 加速事件 和 远程事件;
//触摸事件(touch handling)
- (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);
//点按事件(press handling) NS_AVAILABLE_IOS(9_0)
- (void)pressesBegan:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
//加速事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
//远程控制事件
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);
二、什么是响应者链
响应者链: 由多个响应者组合起来的链条, 就叫做响应者链; 他表示了每个响应者之间的联系, 并且可以使得一个事件可选择多个对象处理;
当触摸了 initial view 时:
- 第一响应者就是 initial view , 即 initial view 首先响应 touchesBegan:withEvent: 方法, 接着传递给 橘黄色的 view;
- 橘黄色 view 开始响应 touchesBegan:withEvent: 方法, 接着传递给蓝绿色 view;
- 蓝绿色 view 响应 touchesBegan:withEvent: 方法, 接着传递给控制器的 view;
- 控制器 view 响应 touchesBegan:withEvent: 方法, 控制器传递给窗口 window;
- 窗口 window 再传递给 UIApplication 处理该事件;
如果上述响应者都不处理该事件, 那么事件被丢弃;
三、事件响应流程(事件的产生和传递)
当一个触摸事件产生的时候, 程序是如何找到第一响应者呢
当点击屏幕时会产生一个触摸事件, 消息循环(runLoop) 会接收到触摸事件, 将事件包装成 UIEvent 对象, 放到主循环的消息队列里, UIApplication 会从消息队列里取出事件, 分发下去;
首先传给 UIWindow, UIWindow 通过 hitTest:withEvent:
方法找到此次触摸事件初始点所在的视图,找到这个视图之后,就会调用该视图的 touchesBegan:withEvent:
方法来处理此事件;
iOS系统检测到手指触摸(Touch)操作时会将其放入当前活动Application的事件队列,UIApplication会从事件队列中取出触摸事件并传递给key window(当前接收用户事件的窗口)处理,window对象首先会使用hitTest:withEvent:方法寻找此次Touch操作初始点所在的视图(View),即需要将触摸事件传递给其处理的视图,称之为hit-test view
hitTest:withEvent: 查找响应者过程
图片中view等级
[ViewA addSubview:ViewB];
[ViewA addSubview:ViewC];
[ViewB addSubview:ViewD];
[ViewB addSubview:ViewE];
点击 ViewE:
- A 是 UIWindow 的根视图, 首先对 A 进行
hitTest:withEvent:
pointInside:withEvent:
方法判断用户点击是否在 A 的范围;- 遍历 A 的子视图 B 和 C, 从后向前遍历;
- 因此, 先查看 C , 调用 C 的
hitTest:withEvent:
方法,pointInside:withEvent:
判断用户点击是否在 C 的范围内, 不在返回 NO, C 对应的hitTest:withEvent:
返回 nil;- 再查看 B, 调用 B 的
hitTest:withEvent:
方法,pointInside:withEvent:
判断用户点击是否在 B 的范围内, 在返回 YES;- 再遍历 B 的子视图 D 和 E, 从后向前遍历;
- 先查看 E, 调用 E 的
hitTest:withEvent:
方法,pointInside:withEvent:
判断用户点击是否在 E 的范围内, 在返回 YES; E 没有子视图, 因此 E 对应的hitTest:withEvent:
方法返回 E, 再往前回溯, 就是 B 的hitTest:withEvent:
返回 E, A 的 hitTest:withEvent: 返回 E;
至此, 点击事件的第一响应者找到了;
如果 hitTest:withEvent:
找到了第一响应者, 但 view 没有处理该事件, 那么事件会沿着响应者链向上传递 -> 父视图 -> 视图控制器 -> UIWindow -> UIApplication , 如果传递到响应链最顶级还没有处理事件, 就丢弃该事件;
注意: 控件不能响应的情况,
- userInteractionEnabled = NO;
- hidden = YES;
- 视图透明度 alpha <= 0.01;
- 子视图超出父视图区域;
子视图超出父视图, 不响应的原因:
因为父视图的pointInside:withEvent:
方法返回 NO, 就不会遍历子视图了, 可以重写pointInside:withEvent:
方法解决此问题;
四、怎么寻找最合适的 view
hitTest:withEvent:
// 此方法返回的View是本次点击事件需要的最佳View
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
事件传递给 window 窗口或者控件后, 就调用hitTest:withEvent:
方法寻找更合适的 view, 如果控件是合适的 view, 则在子控件再调用 hitTest:withEvent:
查看子控件是不是合适的 view, 一直遍历,直到找到合适的 view, 或者废弃事件;
// 因为所有的视图类都是继承BaseView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// 1.判断当前控件能否接收事件
if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
// 2. 判断点在不在当前控件
if ([self pointInside:point withEvent:event] == NO) return nil;
// 3.从后往前遍历自己的子控件
NSInteger count = self.subviews.count;
for (NSInteger i = count - 1; i >= 0; i--) {
UIView *childView = self.subviews[I];
// 把当前控件上的坐标系转换成子控件上的坐标系
CGPoint childP = [self convertPoint:point toView:childView];
UIView *fitView = [childView hitTest:childP withEvent:event];
if (fitView) { // 寻找到最合适的view
return fitView;
}
}
// 循环结束,表示没有比自己更合适的view
return self;
}
pointInside:withEvent:
// 判断一个点是否落在范围内
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
这个函数的用处是判断当前的点击或者触摸事件的点是否在当前的view中。
它被hitTest:withEvent:调用,通过对每个子视图调用pointInside:withEvent:决定最终哪个视图来响应此事件。如果pointInside:withEvent:
返回YES,然后子视图的继承树就会被遍历(遍历顺序中最先响应的为:与用户最接近的那个视图。 it starts from the top-level subview),即子视图的子视图继续调用递归这个函数,直到找到可以响应的子视图(这个子视图的 hitTest:withEvent:
会返回self,而不是nil);否则,视图的继承树就会被忽略。
当我们需要重写某个UIView的继承类UIViewInherit的时候,如果需要重写 hitTest:withEvent:
方法,就会出现是否调用[super hitTest:withEvent:]
方法的疑问?究竟是否需要都是看具体需求,这里只是说明调与不调的效果。
如果不调用,那么重写的方法 hitTest:withEvent:
只会调用重写后的代码,根据所重写的代码返回self或nil,如果返回self那么你的这个UIViewInherit类会接受你的按键,然后调用touches系列方法;否则返回nil那么传递给 UIViewInherit 类的按键到此为止,它不接受它的父view给它的按键,即不会调用touches系列方法。这时,pointInside:withEvent:
几乎没有作用。
如果调用,那么[super hitTest:withEvent:]
方法首先是根据pointInside:withEvent:
的返回值决定是否递归调用所有子View的hitTest:withEvent:
方法。对于子 View 的 hitTest:withEvent:
方法调用也是一样的过程,这样一直递归下去,直到最先找到的某个递归层次上的子 View 的 hitTest:withEvent:
方法返回非 nil,这时候,调用即结束,最终会调用这个子 View 的 touches 系列方法。
如果我们不想让某个视图响应事件,只需要重载 pointInside:withEvent:
方法,让此方法返回NO就行了。不过从这里,还是不能了解到hitTest:WithEvent
的方法的用途
五、应用
在实际开发中, 可能会遇到自定义 tabBar, 中间有凸起按钮的情况, 如图:
如何做到点击按钮而不会触发页面?
这里就需要用到 hitTest:withEvent:
来处理;
一般我们在子类化的 UITabBar
中, 重写 hitTest:withEvent:
方法,
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (self.clipsToBounds || self.hidden || (self.alpha == 0.f)) {
return nil;
}
UIView *result = [super hitTest:point withEvent:event];
//如果发生在 tabBar 里面直接返回
if (result) {
return result;
}
//这里遍历哪些超出部分, 通用写法
for (UIView *subview in self.subviews) {
//把这个坐标从 tabBar 的坐标系转为 subView 的坐标系
CGPoint subPoint = [subview convertPoint:point fromView:self];
result = [subview hitTest:subPoint withEvent:event];
//如果事件发生在 subview 里, 就返回
if (result) {
return result;
}
}
return nil;
}
或者
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//判断当前手指是否点击到中间按钮上,如果是,则响应按钮点击,其他则系统处理
//首先判断当前View是否被隐藏了,隐藏了就不需要处理了
if (self.isHidden == NO) {
//将当前tabbar的触摸点转换坐标系,转换到中间按钮的身上,生成一个新的点
CGPoint newP = [self convertPoint:point toView:self.centerBtn];
//判断如果这个新的点是在中间按钮身上,那么处理点击事件最合适的view就是中间按钮
//self.centerBtn 为大按钮
if ([self.centerBtn pointInside:newP withEvent:event]) {
return self.centerBtn;
}
}
return [super hitTest:point withEvent:event];
}
以上 hitTest:withEvent:
的两种实现都可以达到点击 tabBar
外部触发按钮事件的目的;