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

6 阅读6分钟

本文专门讲解 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 源码为准。

函数 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]]。

函数 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)