I. 一个物理触摸事件的完整流程:
- 用户触摸 iPhone 的触摸屏,硬件感应到会通知操作系统
- 操作系统根据硬件提供的信息打包成UITouch 中的 UIEvent对象
- 将事件传递给当前运行程序的事件队列
- 这个事件会被 runloop 发送给被触摸视图所在的 window
- window 会启动 hit-testing, 找到 hit-test view
- 如果这个 view 不能处理这个触摸事件,事件会沿着响应者链找到处理它的对象,或者被系统丢弃
II. 基本概念:
1. Event Type:
iOS 有三种事件类型:
- 触控事件 (UIeventTypeTouches): 单点/多点触控以及手势操作
- 传感器事件 (UIEventTypeMotion): 各种传感器等
- 远程控制事件 (UIEventTypeRemoteControl):
2. 响应者对象(Responder Object)
响应者对象,是可以相应和处理事件的对象
一个事件可以由多个 responder 接收,第一个接收事件的对象,就叫 first responder
iOS 中,UIResponder 就是代表响应者的类,几乎大部分的类都是它的子类:UIWindow,UIView,UIControl,UIController 等
III. 响应者链:
这段摘自苹果官方文档,前两个不想翻译╮(╯_╰)╭,就原文摘下来了
1. 第一响应者接受事件(the first responder receives some event first)
It receives key events, motion events, and action messages, among others. (Mouse events and multitouch events first go to the view that is under the mouse pointer or finger; that view might or might not be the first responder.)
2. 响应者链启用协作事件处理
- If the first responder cannot handle an event or action message, it forwards it to the “next responder” in a linked series called the responder chain.
- If an object in the responder chain cannot handle the event or action, it passes the message to the next responder in the chain.
- The message travels up the chain, toward higher-level objects, until it is handled. If it isn't handled, the app discards it.
image
3. 事件的路径(The Path of an event)
通常情况下,事件在一条响应链的路径的开端是一个视图-第一响应者或者触摸点或者鼠标点击所在的视图范围.从这里开始,一直沿着视图层级到达 window,直到全局的 APP 对象,然而 iOS 里的响应者链有一些变化:
- 如果一个视图是被一个 VC 管理,并且这个视图不能处理这个事件,那么管理它的 VC 将会成为下一个响应者.
- VC 如果有 Super VC,那么它的下个响应者是其 Super VC 的最表层的 View,如果没有,那么 nextResponder 就会是 UIWindow
- UIApplication 是响应者链的重点,它的 nextResponder 是 nil.
需要说明的是,如果当前 responder 不处理事件,并且想将其传递给 nextResponder,需要手动写代码,才会继续往下传递,否则会被废弃.
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
// 将事件传递给 nextResponder
id theNextResponder = [self nextResponder];
[theNextResponder touchesBegan:touches withEvent:event];
}
IV. Hit-Test View 与 Hit-Testing
1. 是什么?
Hit-Test View 就是事件被触发时和用户交互的对象,寻找它的过程就叫 Hit-Testing
一开始 Window 会调用hitTest:withEvent: 方法,它的所有子视图都会递归调用这个方法,知道找到需要的 View. 调用结束后,这个 view,和 view 上面依附的手势都会和 UITouch 对象关联起来,这个 UITouch 会作为后面的事件传递的参数
一种可能的 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];
// 儿子家里可能还有儿子,回去问清楚再来给爸爸汇报(调用儿子的hitTest:withEvent:方法)
UIView *hitTestView = [subView hitTest:convertedPoint withEvent:event];
// 破案了
if (hitTestView) {
// 大义灭亲交出儿子
return hitTestView;
}
}
// 找儿子问了半天原来发现东西在自己屋里
return self;
}
// 官爷您要找的东西,不在我这里~
return nil;
}
2. 有什么用?
a. 扩大视图点击区域
b. 将事件传递给下层的视图
c. 将事件传递给子视图
a. 扩大视图点击区域
比如你可能碰见过一个场景:
测试/产品/UI 拿着手机过来找你说:
"xxx 你这个按钮怎么这么小,点都点不住啊?"
你无辜的说:
"UI 给我的尺寸就这么大,并且切的图四周没有留白,我也只能给这个 button 做这么大."
身为一个为用户体验着想的开发者,怎么能这么不顾用户的使用体验呢?
所以你可以这么干:
增加某个 view 的点击区域:
// 在自定义视图的 m 文件里实现以下方法
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
CGRect touchRect = CGRectInset(self.bounds, -10, -10);
if (CGRectContainsPoint(touchRect, point)) {
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;
}
b. 将事件传递给下层的兄弟视图(视觉上更下层,实际上是更先被加到父视图上的兄弟视图)
比如两个重叠的视图,不想让在上层的视图处理事件,可以有以下做法:
- B 视图的userInteractionEnabled = NO(B 在系统调用hit testing过程中返回了nil)
- 重写 B 的 hitTest:withEvent: 方法,并返回 nil (如果点击的是交叉区域,hit testing view 会是 A, 如果点击B 的其他区域, hit testing view可能是它们的父视图)
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
UIView *hitTestView = [super hitTest:point withEvent:event];
if (hitTestView == self) {
return nil;
}
return hitTestView;
}
c. 将事件传递给子视图
栗子如下,摘自这篇博客
UIView 有一个子视图: scrollview(蓝色部分),没有占据整个 View 的区域,scrollview 里面是可以滑动的 imageView,实例是做了一个旋转木马的效果,然而你会发现,如果你拖动蓝色区域外,图片是不会滑动的.
想要顺利的让整个视图都能响应滑动手势,只需要在外层的 UIView 中实现 hitTest:withEvent: 方法,并且把 scrollView return 出去当做hit test view.
V. 事件处理
前面说到了,"如果一个 responder 不处理事件...",事件是怎么处理的呢?
必须重写4种方法:
- touchesBegin:withEvent
- touchesMoved:withEvent
- touchesEnded:withEvent
- touchedCancelled:withEvent (例如被来电打断的事件)
但 UIKit 的子类只需要实现与你感兴趣的方法,但必须调用super的实现
参考:
- Events (Cocoa Application Competencies for iOS)
- Responder object (Cocoa Application Competencies for iOS)
- iOS Events and Responder Chain