05-主题|事件响应者链@iOS-应用场景与进阶实践

9 阅读5分钟

本文在 01 总纲02 hitTest03 响应者链04 UIResponder 基础上,总结工程中的应用场景进阶用法:UIControl 的 target=nil 与响应者链、手势识别器与响应者的优先级、扩大点击区域与事件穿透、以及 SwiftUI 与 UIKit 的对比。文末附参考文献。


一、UIControl 与 target=nil 的响应者链

1.1 机制

UIControl(如 UIButton、UISlider)使用 addTarget(_:action:for:) 时,若将 target 设为 nil,系统不会在添加时绑定具体对象,而是在事件触发时第一响应者开始,沿 next 查找第一个能响应该 action 的响应者并调用,即 action 沿响应者链寻找 target [1]。编辑菜单(复制/粘贴/剪切)也使用同一机制在链上查找实现 copy(_:)paste(_:)cut(_:) 等的对象。

1.2 Action 方法签名

Action 方法通常为以下形式之一 [2]

  • @IBAction func doSomething()
  • @IBAction func doSomething(sender: UIButton)
  • @IBAction func doSomething(sender: UIButton, forEvent event: UIEvent)

1.3 使用注意

  • Cell 内按钮:按钮在 UITableViewCell/UICollectionViewCell 内时,链的路径是 Cell → 其他 view,不一定会经过 TableView 的 ViewController。若希望由 VC 处理,用 nil target 可能找不到 VC,此时更稳妥的做法是显式指定 target(如 VC)或通过 delegate/callback 把事件交给 VC [3]。Delegate、Block、闭包、函数封装、遍历传递等「传递方式」的对比与选型见 06-响应者链传递方式与编程模式详解
  • 非相邻 VC:通过 present 的 VC 与当前 VC 不一定在一条「相邻」的 next 链上,nil target 不一定能跨 present 边界找到目标,建议用显式 target 或业务层路由。

二、UIGestureRecognizer 与响应者链

2.1 优先级关系

手势识别器在触摸到达视图的 touchesBegan 等之前参与识别。若手势识别成功,可消费触摸,视图的 touches 方法可能不再被调用;若手势识别失败,触摸会交给视图并沿响应者链继续传递 [4]

控件(如 UIButton)可通过 gestureRecognizerShouldBegin(_:) 等让父视图的手势不干扰自己的点击,从而保证按钮的 target-action 优先。

2.2 泳道图:手势、响应者与控件的优先级

flowchart TB
    subgraph 触摸发生
        T1[手指按下]
    end
    subgraph 系统
        S1[hit-test 得到 view]
        S2[手势识别器优先]
    end
    subgraph 手势层
        G1[识别成功?]
        G2[消费事件]
        G3[识别失败]
    end
    subgraph 响应者层
        R1[视图 touches / UIControl]
        R2[沿 next 传递]
    end
    T1 --> S1
    S1 --> S2
    S2 --> G1
    G1 -->|是| G2
    G1 -->|否| G3
    G3 --> R1
    R1 --> R2

2.3 小结

层级说明
手势识别先于视图的 touches 参与识别,成功则可消费事件
视图 touches手势未消费时,由 hit-test view 及其 next 链处理
UIControl通过内部逻辑与 gesture 的配合,保证点击等行为优先

三、扩大点击区域与事件穿透

3.1 扩大点击区域

视觉较小的按钮或图标,可通过重写 point(inside:with:) 扩大可点击范围(如四周各扩展 20pt),提升可点性 [5]

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    let margin: CGFloat = 20
    return bounds.insetBy(dx: -margin, dy: -margin).contains(point)
}

3.2 事件「穿透」到下层

若希望某视图不响应触摸、让触摸落到下层视图,可重写 hitTest(_:with:),在满足条件时返回 nil,则当前视图及其子视图不参与命中,系统会继续用其兄弟或父视图参与 hit-test [6]

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let hit = super.hitTest(point, with: event)
    // 若希望本视图不拦截,可返回 nil;否则返回 hit
    return shouldPassThrough ? nil : hit
}

商用场景示例:视频播放页上的礼物动画、点赞动效浮层使用 PassThroughView 或重写 hitTest 返回 nil,使点击落到下层进度条、暂停按钮;或活动弹窗关闭后遮罩不拦截,点击空白处关闭。

3.3 手势与按钮共存的完整代码(商用:列表 Cell 内按钮由 VC 处理)

// Cell 内「加购」按钮希望由 ListViewController 处理,用 delegate 传递,避免 nil target 链不到 VC
protocol ProductCellDelegate: AnyObject {
    func productCell(_ cell: ProductCell, didTapAddCart productId: String)
}

class ProductCell: UITableViewCell {
    weak var delegate: ProductCellDelegate?
    private var productId: String = ""
    @objc private func addCartTapped() {
        delegate?.productCell(self, didTapAddCart: productId)
    }
}

class ListViewController: UIViewController, ProductCellDelegate {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ProductCell", for: indexPath) as! ProductCell
        cell.delegate = self
        cell.configure(productId: items[indexPath.row].id)
        return cell
    }
    func productCell(_ cell: ProductCell, didTapAddCart productId: String) {
        // 加购、埋点、弹 toast 等
        cartService.add(productId: productId)
    }
}

3.4 应用场景分类(思维导图)

mindmap
  root((应用场景))
    列表与 Cell
      Cell 内按钮 delegate
      扩大热区 小图标
    浮层与遮罩
      穿透 hitTest 返回 nil
      手势与按钮共存
    编辑与输入
      编辑菜单 copy paste
      自定义 inputView
    手势与链
      手势优先 再响应者链
      gestureRecognizerShouldBegin

四、SwiftUI 与 UIKit 的对比(简要)

SwiftUI 没有 UIKit 式的「响应者链」[7][8]

  • 使用 Gesture 修饰符在视图上声明手势,由系统做 hit-test 与手势竞争,不会把事件沿「next」链向上冒泡。
  • 可点击区域由 framecontentShape 决定;.allowsHitTesting(false) 相当于从 hit-test 中排除。
  • 多手势的优先级通过 highPriorityGesturesimultaneousGesture 等显式组合,而非依赖「链」传递。

UIKit 与 SwiftUI 混用时,需注意:SwiftUI 宿主视图内的交互由 SwiftUI 管理;嵌入的 UIKit 视图仍走 UIKit 的 hit-test 与响应者链。


五、应用场景小结

场景涉及机制建议
按钮/控件由上层 VC 统一处理target-action + nil target → 响应者链Cell 内或复杂层级下优先显式 target 或 delegate
编辑菜单(复制/粘贴)链上查找 canPerformAction / target在合适响应者上实现 copy/paste/cut 等方法
扩大按钮可点区域point(inside:with:)重写并扩大 bounds 的「有效」区域
浮层不拦截触摸hitTest(_:with:) 返回 nil指定条件下返回 nil 实现穿透
手势与按钮共存手势识别 vs 响应者用 gestureRecognizerShouldBegin 等保护控件
自定义键盘/输入条First Responder + inputView成为第一响应者并设置 inputView / inputAccessoryView
事件/回调传递方式选型Delegate / Block / 闭包 / 函数封装 / 遍历06-响应者链传递方式与编程模式详解

5.1 商用场景速查

场景做法
电商列表加购/收藏Cell 内小按钮用 delegate 交给 VC,或扩大热区 + target-action
视频/直播浮层不挡点击浮层 view 重写 hitTest 在命中自己时返回 nil
活动弹窗遮罩点击关闭遮罩用 PassThroughView 或 hitTest 返回 nil,按钮在遮罩上方单独处理
设置页开关/列表点击系统 UITableView 的 didSelect + 响应者链;Cell 内控件用 delegate 更稳
安全输入/自定义键盘自定义 UITextField 的 inputView,canBecomeFirstResponder = true

参考文献

[1] Using responders and the responder chain to handle events - Controls and the responder chain
[2] UIControl | Apple Developer Documentation
[3] UIControl Target Action event not flowing up the responder chain (Stack Overflow)
[4] Using responders and the responder chain - Gesture recognizers
[5] How to implement point(inside:with:) (Stack Overflow)
[6] Hacking Hit Tests (Khanlou)
[7] SwiftUI Gesture System Internals (DEV)
[8] SwiftUI Hit-Testing & Event Propagation Internals (DEV)