题外话
苹果忙着发布新产品,我则忙着处理那些讨厌的数据...为了坚持最初的信念,在10月最后一天把自己以前总结的一篇文章拿出来给大家分享一下吧。
概述
用户与设备交互的话题再怎么探讨也不为过,这几天寻了几篇文章,写的都很不错,大致梳理一下,留个念想。
在iOS中,用户与设备的交互事件可以分为3类:
+ 触控事件
+ 传感器事件
+ 远程控制事件
本次将主要探讨触控事件中的事件传递、响应者、响应者链条。
图 1-1 用户与设备交互方式
探讨
1. 基础信息
响应者 (Responsder Object) : 能够响应并且处理事件的对象,且继承UIResponder的对象称之为响应者对象,能够处理touchesBegan等触摸事件。
第一响应者 (First Responder) : 第一个接收事件的View对象。
响应者链条: 有很多响应者链接在一起组合起来的一个链条称之为响应者链条。
2. 事件传递
鄙人愚见,事件传递可以认为是App寻找最佳执行此事件响应者的过程。
图 1-2 用户与设备交互事件传递
用户与 iOS设备产生交互后,当前App的UIApplication管理事件队列会加入此事件来等待处理。待App准备处理此事件时,会从准备队列中移除这个事件,App会寻找最佳执行此事件的Target,故会将事件传递给KeyWindow(UIWindow),如果此时该ViewController没有加入GestureRecognizer 那么紧接着会传递给View(视图层次(父->子)),以寻找最佳执行Target。
图 1-3 触摸事件实例图
触摸事件举例:
点击橘色的View: UIApplication事件队列 -> UIWindow -> 蓝色 -> 橘色
点击绿色的View: UIApplication事件队列 -> UIWindow -> 蓝色 -> 白色 -> 绿色
点击红色的View: UIApplication事件队列 -> UIWindow -> 蓝色 -> 白色 -> 红色
传递详解:
KeyWindow会在它的内容视图上调用 hitTest:withEvent: (该方法返回的就是处理此触摸事件的最合适view)来完成这个找寻过程。
hitTest:withEvent: 在内部首先会判断该视图是否能响应触摸事件,如果不能响应,返回nil,表示该视图不响应此触摸事件。
然后再调用pintInside:withEvent:(该方法用来判断点击事件发生的位置是否处于当前视图范围内)。
如果pointInside:withEvent:返回NO,那么hitTest:withEvent:也直接返回nil。
如果pointInside:withEvent:返回YES,则向当前视图的所有子视图发送hitTest:withEvent:消息,所有子视图的遍历顺序是从最顶层视图一直到最底层视图,即从subviews数组的末尾向前遍历。直到有子视图返回非空对象或者全部子视图遍历完成;若第一次有子视图返回非空对象,则hitTest:withEvent:方法返回此对象,处理结束;若所有子视图都返回nil,则hitTest:withEvent:方法返回该视图自身。
3. 探讨 hitTest:withEvent: 方法的底层实现
不接收触摸事件的三种情况
+ 不接收用户交互 userInterationEnabled = NO
+ 隐藏 hidden = YES
+ 透明 alpha = 0.0~0.01
底层实现
// point是该视图的坐标系上的点
- (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]) return nil;
// 3.从后往前遍历自己的子控件,看是否有子控件更适合响应此事件
NSInteger count = self.subviews.count;
for (NSIntegeri = count - 1; i >= 0; i--) {
UIView *childView = self.subviews[i];
CGPoint childPoint = [self convertPoint:point toView:childView];
UIView *fitView = [childView hitTest:childPoint withEvent:event];
if (fitView) {
return fitView;
}
}
// 没有找到比自己更合适的view
return self;
}
4. 响应者链条
每个能执行hitTest:withEvent:方法的View都属于事件传递的一部分,但是只有pointInside:withEvent:返回为YES的View才属于响应者链条。
处理原则
响应者链条其实还包括 视图控制器(ViewController)、UIWindow、UIApplication。如下图:
图 1-4 响应链
通过事件传递找到最合适的处理触摸事件的View后(就是最后一个pintInside:withEvent:返回YES的View,它是第一响应者),如果该view是控制器view,那么上一个响应者就是控制器。如果它不是控制器View,那么上一个响应者就是前面一个pointInside:withEvent:返回YES的view(父控件)。最后这些所有pointInside:withEvent:返回YES的view加上它们的控制器、UIWindow、UIApplication共同构成响应者链条。
响应者链条是自上而下的(顶层->UIApplication),前面的事件传递是自下而上的(UIApplication->顶层)。
5. 响应者链条应用
可以让一个触摸事件让多个响应者同时处理该事件。如在上图中多个View中打印touchBegan:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"touchBegan---%@", [self class]);
[super touchesBegan:touches withEvent:event];
}
总结
1. 知识扩展
如果将某个view的pointInside:withEvent:方法直接返回NO(无论子控件的pointInside:withEvent:返回什么结果),影响的是子控件区域和自身区域的点击事件处理,这些区域不再响应事件。其余区域响应点击事件不发生变化。
如果将某个view的pointInside:withEvent:方法直接返回YES,自身区域响应点击事件不变。其它改变:
首先,父控件所有区域点击事件交给该view处理。
然后,再看该view处于父控件的子控件数组中的位置。数组前面的兄弟控件的点击事件交给该view处理,数组后面的兄弟控件的点击事件由其兄弟控件处理。
最后,该view的子控件原来能够自己处理点击的区域继续由子控件处理,子控件原先不能够自己处理点击的(超出view范围)区域可以由子控件处理了。
所以,想要屏蔽掉某个view响应点击事件,如果其没有子控件或者子控件响应事件也想屏蔽掉,直接将该View的pointInside:withEvent:返回为NO就行了。而在一般情况下,不建议将view的pointInside:withEvent:返回YES.
2. 什么时候重写hitTest:withEvent:,什么时候重写pointInside:withEvent:,在哪个view内重写它们?
很多情况下hitTest:withEvent:和pointInside:withEvent:方法任选其一都可以实现某个功能,比如在屏蔽中,pointInside:withEvent:返回NO就可以实现的话,都可以用hitTest:withEvent:返回nil代替。
但是,hitTest:withEvent:更强大。一般pointInside:withEvent:在一般情况下起内部顶多只能根据情况判断怎么返回NO,屏蔽掉自己和子控件的事件响应。所以只要是想要保留子控件对触摸事件响应,屏蔽其父控件的响应,单独重写pointInside:withEvent:无法办到,必须重写hitTest:withEvent:方法。
触摸事件原来该由某个view响应,现在你不想让它处理而让别的控件处理,那么就应该在该view内重写hitTest:withEvent:或pointInside:withEvent:方法。
参考文献