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 | 事件传递及响应链