在我们点击屏幕的时候,系统捕获到触摸事件,系统把包含这些触摸事件的信息包装成 UITouch 和 UIEvent 实例,然后找到当前运行的应用,逐级寻找能够响应这个事件的对象,直到没有响应者响应。这一系列响应者组成了响应链。
首先,系统捕获到点击行为后,将点击事件封装成 UIEvent 对象。接下来,就需要确定具体触摸到哪个 view,也即是找到手指触摸到的处于屏幕最前端的 view,这一步叫 hit-testing。
hit-testing
UIView 中有两个方法,hitTest 和 pointInside:
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; |
pointInside 方法是判断 point 是否在当前 view 内。
hitTest 方法是调用 pointInside 函数判断触点是否在当前 view 内,以及递归调用子 view 的 hitTest 方法,找到实际触摸的 view。
把这两个函数自己实现了一遍,如下:
1 | - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { |
这里要注意,hitTest 方法中的 point 是已经转化到当前 view 的坐标系的,也即是当前 view 的左上角为 (0, 0)。所以当递归调用子 view 的 hitTest 方法时,也要注意转化到子 view 的坐标系。
系统也正是从 UIWindow 开始,层层递归调用 hitTest 方法,直到找到实际触摸到处于屏幕最前端的 view,姑且称之为 target-view 吧。
⚠️ 注意,UIEvent 会和 target-view 绑定,target-view 一旦确定,整个触摸过程中 target-view 都不会再改变,即使手指滑动离开了 target-view 的范围。
那么问题来了,如果 view.clipToBounds = NO,而且 subview 有超出 view 的部分。那么点击 subview 超过 view 的部分,view 的 hitTest 会返回什么呢?
答案是返回 nil 哦。
理解了 hitTest 和 pointInside 的逻辑之后,重写 hitTest 和 pointInside 可以只有圆形区域响应事件的效果,这里就不再细讲了。
响应链
所有需要响应触摸事件的类,都继承自 UIResponder,UIResponder 的子类有:UIView、UIApplication、UIViewController。响应链中的每个元素都是 UIResponder 的子类实例。
UIResponder 中有 4 个触摸事件处理方法:
1 | - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event; |
同时,UIResponser 中有一个 nextResponder 的属性,nextResponder 就是下一个响应者。
我们写一个 demo 来看下 nextResponder 都返回什么值。新建一个 ViewController 类,其中部分代码如下:
1 | - (void)viewDidLoad { |
运行之后的 Log 如下:
1 | UINamedView (RedView) |
我们可以看到,响应链通过 nextResponder 属性将 UIResponder 都连接起来了。
响应链的结构大致是:view -> superview -> ViewController -> UIWindow -> UIWindowScene -> UIApplication -> AppDelegate,ViewController、UIApplication、AppDelegate 都在响应链中。
如果存在子 controller,响应链的结构大致是:view -> ChildViewController -> ViewController.view -> ViewController -> …
借用苹果官方文档的图来说明下,更直观。
系统会调用 UIApplication 的 sendEvent: 方法,继而调用 UIWindow 的 sendEvent: 方法。在 UIWindow 的 sendEvent: 方法中会调用 target-view 的触摸事件处理方法,事件也就开始在响应链中传递。如果 target-view 不处理这些事件,则通过 nextResponder 传递给下一个 responder 处理,这也是默认逻辑。如果 target-view 已经处理了这些事件了,则不需要往 nextResponder 传递,比如 UIButton。
⚠️ 在重写子类的事件处理方法时,不要手动传递事件给 nextResponder。应该通过调用 super 的事件处理方法,来把事件传递给 nextResponder。
手势 UIGestureRecognizer
给 view 添加手势后,touchesBegan 和 touchesMoved 仍旧会被调用,与没有添加手势时的逻辑一致。
如果 gesture 识别成功,则 touchesCancelled 被调用,否则 touchesEnded 被调用。
正确构造响应链
大部分的情况下,系统都会自动正确的构造响应链。例如,通过 addSubview 添加子 view 时,子 view 的 nextResponder 会自动的指向 superview,不需要写额外的逻辑。
但是,需要特别注意 UIViewController。有好些朋友创建子 controller 只是单纯的 addSubview,例如:
1 | - (void)displayContentController: (UIViewController*) content { |
这样会导致子 controller 没有在响应链中,子 controller 的 viewDidAppear 等回调缺失。
正确的写法应该是:
1 | - (void) displayContentController: (UIViewController*) content { |
同样,移除 ViewController 也不仅仅是 removeFromSuperview 方法调用。
1 | - (void) hideContentController: (UIViewController*) content { |
firstResponder
firstResponder 听起来像是通过 hit-testing 找到的 target-view。实际上,优先接收某些事件(例如键盘 KeyEvent、MotionEvent等)的响应者,称之为 firstResponder。而触摸事件中的 target-view,可能是 firstResponder,也可能不是。
最简单的例子就是,调用 UITextField 的 becomeFirstResponder 方法,可以激活键盘并输入文字。
参考
iOS开发 - 事件传递响应链 - CocoaChina_一站式开发者成长社区
Using Responders and the Responder Chain to Handle Events | Apple Developer Documentation
iOS - Difference between addChildViewController and addSubview? - Stack Overflow
View Controller Programming Guide for iOS: Implementing a Container View Controller
深入浅出iOS事件机制
Hit-Testing in iOS
UIKit: UIResponder | 南峰子的技术博客
Responder object