02-主题|事件响应者链@iOS-hitTest与事件传递详解

本文专门讲解 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 == true
  • isHidden == false
  • alpha > 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

要点:

  1. 先判 pointInside:点不在当前视图内则直接返回 nil,整棵子树被剪枝。
  2. 子视图逆序 :按 subviews 从后往前遍历,即** Z 轴靠前的子视图优先**,与视觉上的「最上层」一致。
  3. 第一个非 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 流程图

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:),自定义遍历顺序或强制返回某子视图

示例(扩大点击区域):

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")

相关推荐
没有故事的Zhang同学5 小时前
03-超级App软件平台@路由规则设计-【Universal-Links】与【App-Links详解】
程序员
没有故事的Zhang同学5 小时前
01-超级App软件平台@路由规则设计-【总纲】
程序员
没有故事的Zhang同学5 小时前
04-超级App软件平台@路由规则设计-【组件化路由框架详解】
程序员
没有故事的Zhang同学5 小时前
07-超级App软件平台@路由规则设计-【通用路由管理组件设计】
程序员
没有故事的Zhang同学9 小时前
02-Debug调试@网络-Wireshark网络抓包工具:从原理到实践
程序员
SimonKing10 小时前
GitHub 10万星的OpenCode,正在悄悄改变我们的工作流
java·后端·程序员
xiezhr10 小时前
36岁程序员被曝复工当晚猝死出租屋内
程序员·996·程序员日常·猝死·加班
xiezhr10 小时前
米哈游36岁程序员被曝复工当晚猝死出租屋内
游戏·程序员·游戏开发