本文专门讲解 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 源码为准。
函数 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]]。
函数 pointInside(point, event) -> Bool:
返回 CGRectContainsPoint(self.bounds, 将 point 转换到当前视图的 bounds 坐标系)
可重写以扩大或缩小可点击区域(如圆形按钮、不规则形状、透明区域穿透等)。
四、事件传递流程(自上而下)
4.1 流程图
flowchart TB
A[触摸发生] --> B[UIWindow 收到事件]
B --> C[对根 view 调用 hitTest:withEvent:]
C --> D{pointInside 为 true?}
D -->|否| E[返回 nil,该分支结束]
D -->|是| F[按逆序遍历子视图]
F --> G[对子视图递归 hitTest]
G --> H{有子视图返回非 nil?}
H -->|是| I[返回该子视图 作为 hit-test view]
H -->|否| J[返回 self]
I --> K[该 view 成为触摸的 first responder]
J --> K
4.2 泳道图:Hit-Test 各角色协作
flowchart TB
subgraph 用户
U1[手指触摸屏幕]
end
subgraph 系统_UIApplication
S1[事件入队]
S2[派发至 keyWindow]
end
subgraph 系统_UIWindow
W1[hitTest 根 view]
W2[得到 hit-test view]
end
subgraph 视图层级
V1[pointInside 判断]
V2[逆序遍历子视图]
V3[递归 hitTest]
V4[返回最终 view]
end
U1 --> S1
S1 --> S2
S2 --> W1
W1 --> V1
V1 --> V2
V2 --> V3
V3 --> V4
V4 --> W2
4.3 Hit-Test 知识结构(思维导图)
mindmap
root((Hit-Test))
入口
UIWindow 根视图
hitTest:withEvent:
条件
userInteractionEnabled
hidden / alpha
pointInside
遍历
子视图逆序
Z 轴优先
结果
hit-test view
first responder
自定义
扩大热区
穿透
不规则区域
五、子视图顺序与 Z 轴
子视图在 subviews 数组中的索引越大,在 hit-test 时越先被遍历,因此后加入的、索引更大的子视图会优先被命中,与它们在屏幕上的「盖在上面」一致。若两个子视图重叠,则上面那一层会先被 hitTest 到并成为 hit-test view。
flowchart LR
subgraph 视图层级
V[父视图]
V --> A[子视图 A index 0]
V --> B[子视图 B index 1]
V --> C[子视图 C index 2]
end
subgraph hitTest 顺序
C --> B
B --> A
end
六、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:),自定义遍历顺序或强制返回某子视图 |
示例(扩大点击区域):
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:) 即可。
穿透示例(浮层不拦截、点击落到下层):
/// 用于半透明遮罩:触摸不消费,交给下层视图(如背后的列表、按钮)
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 子类(适用于任意按钮/图标):
/// 将子视图的可点击区域向外扩展,不改变视觉 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
[2] Event handling for iOS - hitTest:withEvent: and pointInside:withEvent:
[3] HitTest and UIResponder in iOS (Medium)