iOS 事件的传递链和响应链
为了搞明白从用户点击屏幕开始,到UI根据点击出现反馈。我们可以分成两个部分,1.如何找到哪个View应该响应(或者说 第一响应者)2.沿响应链传递事件。
模拟场景
我们要模拟一个场景,把整个过程走一遍,就可以理解了:
UIWindow -> UIViewController(root) -> UIView -> UIButton,用户点击了UIButton
1.如何找到哪个View来响应
这里我们要说一下事件的传递机制
过程如下:
UIApplication接收事件:UIApplication接收到系统传递的触摸事件。UIApplication将事件传递给应用的主窗口(UIWindow)(func sendEvent(_ event: UIEvent))。
UIWindow处理事件:UIWindow的hitTest(_:with:)方法会被调用来找到触摸点所在的视图。hitTest(:with:)方法会递归遍历视图层次结构,从根视图开始,调用每个视图的pointInside(:with:)方法。pointInside(_:with:)方法检查触摸点是否在视图的边界内。如果返回true,继续递归查找子视图;如果返回false,继续查找兄弟视图。- 最终找到包含触摸点的最深层次的视图,这个视图被称为“事件的第一响应者”(
First Responder)。
我们可以看一下具体实现:
open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01 {
return nil
}
if self.point(inside: point, with: event) {
for subview in self.subviews.reversed() {
let convertedPoint = self.convert(point, to: subview)
if let hitView = subview.hitTest(convertedPoint, with: event) {
return hitView
}
}
return self
}
return nil
}
这里有几点要注意
- 这是一个递归函数,如果仔细观察,它和数据结构图的遍历算法很像,都是递归中嵌套
for循环,但图会发生死循环(不过多赘述),所以需要判断isVisited,这里的View因为不会有反向的指针,所以不需要。 hitTest(:with:)和pointInside(_:with:)都是UIView的函数,UIWindow也是继承自UIView,所以可以递归调用,找到最终应该响应的Viewfor subview in self.subviews.reversed()这行代码中的reversed很关键,因为假设我们同一个位置,add了多个view,一定的上层的view优先级最高。
所以当用户点击了Button的时候,事件先是由 UIApplication,sendEvent,然后 UIWindow 递归调用,hittest等函数,最终确定了UIButton。
- 传递事件:
这里我们要说到响应链最重要的类,UIResponder,UIView,UIViewController,UIWindow,UIApplication都继承了它。- 找到第一响应者视图后,触摸事件会被传递到该视图。
- 该视图会调用
touchesBegan(_:with:)方法来响应触摸事件。 - 在触摸过程中,系统会调用
touchesMoved(:with:),touchesEnded(:with:)和touchesCancelled(_:with:)方法来处理触摸的移动、结束和取消。
不同的控件对UIResponder的实现是不同的,比如 UIView
extension UIResponder {
@objc func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 1. 更新内部状态
self.updateInternalState(for: touches, with: event)
// 2. 通知手势识别器
self.notifyGestureRecognizers(for: touches, with: event)
// 3. 将事件传递给下一个响应者
if let nextResponder = self.next {
nextResponder.touchesBegan(touches, with: event)
}
}
}
这里的关键是通过调用 next 将事件传递给上层。如果没有响应者,那会一直向上传递到UIApplication。
UIButton 就不同了,因为它继承自 UIControl。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
// 1. 更新按钮的外观状态,例如变为高亮状态
self.isHighlighted = true
// 2. 检查是否需要触发手势识别器
self.checkGestureRecognizers(for: touches, with: event)
// 3. 触发目标-动作机制
if shouldTriggerActions(for: touches, with: event) {
self.sendActions(for: .touchDown)
}
}
因为事件到这里就需要处理了,所以不需要在调用next去传递了
UIResponder 如何获取上一层UIResponder
extension UIView {
override open var next: UIResponder? {
return self.superview ?? self.viewController
}
// 视图控制器的计算属性
var viewController: UIViewController? {
var nextResponder: UIResponder? = self
while let responder = nextResponder {
if let viewController = responder as? UIViewController {
return viewController
}
nextResponder = responder.next
}
return nil
}
}
这里感兴趣的同学可以去试试,如果你重写了 UIView的touch函数,在里面print一些数据,你会发现,当你点击一个view的时候,它的父View的touch也会调用,因为UIView的函数实现中有调用UIResponder的next函数。但如果你重写UIButton的touch函数做同样的事,会发现父View的touch函数并不会被调用。因为UIButton这个控件就是用来处理事件的,不需要向上传递了。
在看看我们最初模拟的场景,UIButton会根据自身实现的UIResponder的touch一系列函数,实现UI上的反馈,点击事件的响应等等。
在日常开发中,我们有时候会遇到一些特殊需求,比如扩大UIButton的点击响应范围,或子View的区域比父View大,这都需要我们去重写UIView中的pointInside(_:with:)去改变它的响应逻辑。
参考资料:
iOS | 事件传递及响应链