本文专门讲解 iOS 中事件传递 的「确定目标」阶段:
hitTest(_:with:)与point(inside:with:)的原理、算法、子视图遍历顺序,以及可响应条件、自定义命中区域和常见图示。与「响应者链」的传递阶段配合理解,可参见 03-响应者链与 nextResponder 详解。
一、为什么需要 Hit-Testing
触摸发生时,系统需要确定「触摸点落在哪个视图上」,以便将事件交给该视图并进入响应者链。Hit-testing 即在这一阶段,从窗口根视图开始,沿视图层级向下查找最底层且包含该点的视图 ,该视图将作为该触摸事件的第一响应者 (hit-test view)[1]。
二、核心 API
| 方法 | 所属 | 作用 |
|---|---|---|
hitTest(_:with:) |
UIView | 在视图树中查找包含指定点的最底层子视图;返回 nil 表示当前视图及其子视图均不接收该点 |
point(inside:with:) |
UIView | 判断给定点是否在当前视图的 bounds 内(可被重写以扩展或缩小命中区域) |
系统从 UIWindow 开始,对根视图调用 hitTest(_:with:),传入触摸点(已转换为该视图坐标系)。视图内部会先调用 point(inside:with:) 判断点是否在自己范围内,再递归对子视图调用 hitTest(_:with:)。
三、hitTest 算法与伪代码
3.1 可响应前提
视图要参与 hit-test,通常需同时满足(否则当前分支会被剪掉,返回 nil)[[2]][[3]]:
isUserInteractionEnabled == trueisHidden == falsealpha > 0.01
不满足时,hitTest(_:with:) 直接返回 nil,该视图及其子视图都不会成为命中目标。
3.2 系统 hitTest 逻辑(伪代码)
以下为对系统行为的等价描述,便于理解顺序与剪枝逻辑;实际实现以 Apple 源码为准。
text
函数 hitTest(point, event) -> UIView?:
若 当前视图 不满足可响应条件(userInteractionEnabled / hidden / alpha):
返回 nil
若 pointInside(point, event) 为 false:
返回 nil // 点不在当前视图内,整棵子树不再查找
// 按子视图「从后往前」顺序遍历(逆序:最后加入的、Z 轴更靠前的先测)
对 每个 subview 从 subviews.last 到 subviews.first:
candidate = subview.hitTest( 将 point 转换到 subview 坐标系, event )
若 candidate != nil:
返回 candidate // 找到第一个有返回值的子视图即停止
若没有子视图命中:
返回 self // 点在自己范围内且没有更底层子视图命中,则自己就是 hit-test view
要点:
- 先判 pointInside:点不在当前视图内则直接返回 nil,整棵子树被剪枝。
- 子视图逆序 :按
subviews从后往前遍历,即** Z 轴靠前的子视图优先**,与视觉上的「最上层」一致。 - 第一个非 nil 即返回:找到第一个返回非 nil 的子视图就停止,该子视图即为 hit-test view。
3.3 point(inside:with:) 默认行为
默认实现等价于:判断点是否落在视图的 bounds 内(通常不考虑 subview 的超出部分;且若父视图 clipsToBounds == true,超出父视图 bounds 的子视图区域不会参与父视图的 hit-test,因为点不在父视图 bounds 内会先被剪枝)[[1]]。
text
函数 pointInside(point, event) -> Bool:
返回 CGRectContainsPoint(self.bounds, 将 point 转换到当前视图的 bounds 坐标系)
可重写以扩大或缩小可点击区域(如圆形按钮、不规则形状、透明区域穿透等)。
四、事件传递流程(自上而下)
4.1 流程图
4.2 泳道图:Hit-Test 各角色协作
4.3 Hit-Test 知识结构(思维导图)
五、子视图顺序与 Z 轴
子视图在 subviews 数组中的索引越大,在 hit-test 时越先 被遍历,因此后加入的、索引更大的子视图会优先被命中 ,与它们在屏幕上的「盖在上面」一致。若两个子视图重叠,则上面那一层会先被 hitTest 到并成为 hit-test view。
六、clipsToBounds 与命中
- pointInside 只判断点是否在当前视图的 bounds 内。
- 若父视图设置了
clipsToBounds = true,子视图超出父视图 bounds 的部分会被裁剪掉显示,但 hit-test 仍按 bounds 判断 :若触摸点落在父视图 bounds 外(即使落在子视图的 frame 内),父视图的pointInside会返回 false,整棵子树不会参与命中 [[1]]。 - 因此:子视图若超出父视图 bounds 且父视图 clipsToBounds,超出部分在默认实现下无法被 hit-test 命中 ,除非在父视图层重写
point(inside:with:)或hitTest(_:with:)做特殊处理。
七、自定义 hitTest / pointInside 的常见用法
| 需求 | 做法 |
|---|---|
| 扩大点击区域 | 重写 point(inside:with:),对中心区域做扩展(如上下左右各扩展 44pt) |
| 透明区域不响应 | 重写 point(inside:with:),根据像素透明度返回 false |
| 让触摸「穿透」到下层 | 重写 hitTest(_:with:),在特定条件下返回 nil,使当前视图不参与命中 |
| 指定子视图优先 | 重写 hitTest(_:with:),自定义遍历顺序或强制返回某子视图 |
示例(扩大点击区域):
swift
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let inset: CGFloat = -20
return bounds.insetBy(dx: inset, dy: inset).contains(point)
}
商用场景示例 :商品列表 Cell 内「加购」「收藏」等小图标,视觉约 24pt,为提升点击率将热区扩大到 44pt,重写该图标的容器 view 或子类的 point(inside:with:) 即可。
穿透示例(浮层不拦截、点击落到下层):
swift
/// 用于半透明遮罩:触摸不消费,交给下层视图(如背后的列表、按钮)
class PassThroughView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hit = super.hitTest(point, with: event)
return hit == self ? nil : hit // 若命中自己则返回 nil,让下层接收
}
}
商用场景示例:活动弹窗关闭后残留半透明遮罩,希望点击遮罩空白处能穿透到下层(如关闭按钮、跳过);或直播/视频上的礼物动画层不拦截点击,让下层进度条、点赞可点。
Swift 完整示例:可复用的「扩大热区」UIView 子类(适用于任意按钮/图标):
swift
/// 将子视图的可点击区域向外扩展,不改变视觉 frame
final class ExpandHitAreaView: UIView {
var hitAreaInset: UIEdgeInsets = .zero // 负值表示扩大,如 (-10,-10,-10,-10)
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.inset(by: hitAreaInset).contains(point)
}
}
// 使用:将按钮包在 ExpandHitAreaView 内,设置 hitAreaInset = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
八、与响应者链的衔接
hit-test 得到的是触摸事件的第一响应者 (某个 UIView)。触摸事件会先发给该视图(及其上的手势识别器);若视图未处理或未实现 touchesBegan 等,事件会沿 nextResponder 向上传递。因此:
- 阶段一(本文):hit-test,自顶向下,确定「谁被点中」。
- 阶段二 :响应者链,自底向上,确定「谁处理」。详见 03-响应者链与 nextResponder 详解。
参考文献
1\] [Using responders and the responder chain to handle events - Determine which responder contained a touch event](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fuikit%2Fusing-responders-and-the-responder-chain-to-handle-events "https://developer.apple.com/documentation/uikit/using-responders-and-the-responder-chain-to-handle-events") \[2\] [Event handling for iOS - hitTest:withEvent: and pointInside:withEvent:](https://link.juejin.cn?target=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F4961386%2Fevent-handling-for-ios-how-hittestwithevent-and-pointinsidewithevent-are-r "https://stackoverflow.com/questions/4961386/event-handling-for-ios-how-hittestwithevent-and-pointinsidewithevent-are-r") \[3\] [HitTest and UIResponder in iOS (Medium)](https://link.juejin.cn?target=https%3A%2F%2Fmayurkothawade.medium.com%2Fhittest-and-uiresponder-in-ios-8ecee386d119 "https://mayurkothawade.medium.com/hittest-and-uiresponder-in-ios-8ecee386d119")