编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

    • README
    • ObjC编程入门

    • Swift编程入门

    • 专栏博客

      • iOS系统架构概览
      • Runtime消息转发机制
      • RunLoop运行循环揭秘
      • UIView生命周期与渲染
      • 事件响应与手势识别
        • 1. 案例引入
          • 1.1 一段诡异的代码
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 事件系统四层模型
          • 2.2 为什么这么分层
        • 3. 事件源头:从硬件到UIEvent
          • 3.1 IOKit与触摸硬件
          • 3.2 SpringBoard到UIKit
          • 3.3 UIEvent的内部结构
          • 3.4 UITouch的完整生命周期
        • 4. hitTest命中测试
          • 4.1 递归搜索算法
          • 4.2 五个过滤条件
          • 4.3 alpha与isHidden的坑
          • 4.4 pointInside重写实战
        • 5. 响应链传递机制
          • 5.1 从hit-tested view出发
          • 5.2 nextResponder的默认指向
          • 5.3 跨视图控制器的传递
          • 5.4 自定义响应链节点
        • 6. UIGestureRecognizer状态机
          • 6.1 七状态转换图
          • 6.2 状态转换的源码级时序
          • 6.3 touchesBegan与手势的竞合
          • 6.4 手势延迟与取消机制
        • 7. 手势冲突三板斧
          • 7.1 第一斧:requireGestureRecognizerToFail
          • 7.2 第二斧:UIGestureRecognizerDelegate
          • 7.3 第三斧:自定义手势子类
          • 7.4 多手势并发与互斥矩阵
        • 8. UIScrollView中的手势暗战
          • 8.1 contentOffset与手势协作
          • 8.2 嵌套UIScrollView的滚动冲突
          • 8.3 UIPageViewController手势底层
          • 8.4 全屏侧滑与ScrollView共存
        • 9. 性能与调试
          • 9.1 hitTest性能瓶颈定位
          • 9.2 可视化hitTest路径
          • 9.3 手势冲突调试三板斧
          • 9.4 Instruments手势追踪
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次触摸的完整旅程
          • 10.3 设计哲学回扣
          • 10.4 事件响应速查表
      • AutoLayout布局系统
      • 自定义View绘制实践
      • CoreAnimation动画实战
      • ViewController容器管理
      • 转场动画与导航设计
      • 网络层架构设计
      • 数据持久化方案选型
      • 多线程与GCD实践
      • 内存管理与ARC原理
      • Block底层原理与应用
      • KVO与KVC实现机制
      • 性能优化与启动加速
      • 组件化与模块设计
      • 安全加密与逆向防护
      • 跨平台与混编方案
  • Web开发和进阶

  • Linux应用开发

  • IoT智能硬件开发

  • Apps
  • iOS开发和进阶
  • 专栏博客
杨充
2026-06-20
目录

事件响应与手势识别

# 05.事件响应与手势识别

深度篇 | hitTest命中测试、响应链传递、UIGestureRecognizer状态机、手势冲突解决三板斧。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段诡异的代码
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 事件系统四层模型
    • 2.2 为什么这么分层
  • 3. 事件源头:从硬件到UIEvent
    • 3.1 IOKit与触摸硬件
    • 3.2 SpringBoard到UIKit
    • 3.3 UIEvent的内部结构
    • 3.4 UITouch的完整生命周期
  • 4. hitTest命中测试
    • 4.1 递归搜索算法
    • 4.2 五个过滤条件
    • 4.3 alpha与isHidden的坑
    • 4.4 pointInside重写实战
  • 5. 响应链传递机制
    • 5.1 从hit-tested view出发
    • 5.2 nextResponder的默认指向
    • 5.3 跨视图控制器的传递
    • 5.4 自定义响应链节点
  • 6. UIGestureRecognizer状态机
    • 6.1 七状态转换图
    • 6.2 状态转换的源码级时序
    • 6.3 touchesBegan与手势的竞合
    • 6.4 手势延迟与取消机制
  • 7. 手势冲突三板斧
    • 7.1 第一斧:requireGestureRecognizerToFail
    • 7.2 第二斧:UIGestureRecognizerDelegate
    • 7.3 第三斧:自定义手势子类
    • 7.4 多手势并发与互斥矩阵
  • 8. UIScrollView中的手势暗战
    • 8.1 contentOffset与手势协作
    • 8.2 嵌套UIScrollView的滚动冲突
    • 8.3 UIPageViewController手势底层
    • 8.4 全屏侧滑与ScrollView共存
  • 9. 性能与调试
    • 9.1 hitTest性能瓶颈定位
    • 9.2 可视化hitTest路径
    • 9.3 手势冲突调试三板斧
    • 9.4 Instruments手势追踪
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次触摸的完整旅程
    • 10.3 设计哲学回扣
    • 10.4 事件响应速查表

# 1. 案例引入

# 1.1 一段诡异的代码

先看一段在生产环境"看着都对、跑起来崩"的真实代码。一个电商详情页,商品图横向可滑动,图片上的「收藏」按钮偶发性完全点不动,用户反馈:“点烂了屏幕都没反应,别的 App 都正常”:

// ProductImageGallery.swift
class ProductImageGallery: UIView {
    let scrollView = UIScrollView()
    let pageControl = UIPageControl()
    let favoriteButton = UIButton()
    let longPressGesture = UILongPressGestureRecognizer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        // ... 布局省略 ...

        scrollView.isPagingEnabled = true
        scrollView.addGestureRecognizer(longPressGesture)  // ← 长按保存图片
        addSubview(scrollView)

        favoriteButton.addTarget(self, action: #selector(favTapped), for: .touchUpInside)
        scrollView.addSubview(favoriteButton)              // ← 按钮放在 scrollView 里
        addSubview(pageControl)                            // ← pageControl 放在最外层
    }

    @objc func favTapped() {
        print("收藏成功")
    }
}

现象:

  • 80% 的情况下,点击 favoriteButton 正常触发 favTapped
  • 但快速滚动后再点、或手指在按钮上停留超过 0.5s,按钮再也没有响应
  • 更诡异的是:手指**按在按钮上然后稍微滑动一点(哪怕 1pt)**再抬手,按钮也不响应
  • pageControl 的点击切换从来都好使

直觉怀疑:是不是按钮被什么视图挡住了?Debug View Hierarchy 一看——按钮在最顶层,没有被遮挡。

# 1.2 顺藤摸到根因

带着这条线往下挖:

  • 假设 1:是不是 scrollView 的手势把触摸吃了?—— 打印 scrollView.panGestureRecognizer.state,发现手指按下后立刻变成 .began,按钮的 .touchDown 根本没收到。

  • 假设 2:开启 delaysContentTouches = false 呢?—— 设完后,快速点按钮能响应了,但手指稍微拖一下按钮就再也不响应了。问题更深了。

  • 假设 3:那加一个 UITapGestureRecognizer 覆盖按钮区域呢?

let tap = UITapGestureRecognizer(target: self, action: #selector(onTap))
scrollView.addGestureRecognizer(tap)

@objc func onTap(_ gesture: UITapGestureRecognizer) {
    let pt = gesture.location(in: scrollView)
    if favoriteButton.frame.contains(pt) {
        favTapped()
    }
}

—— 更惨:因为 tap 也在 scrollView 上,scrollView 的 pan 会取消 tap,点得更快了也没用。

  • 假设 4:把按钮 移出 scrollView 直接放在父视图上呢?—— 按钮和工作正常了,但不跟着图片滚动了,产品要求是“按钮贴在图片右下角”,方案作废。

  • 假设 5:那 scrollView.panGestureRecognizer 和 longPressGesture 相互之间是什么关系?两者同属一个 scrollView,它们怎么商量“谁先谁后”?

这一段事故里至少藏着 7 个原理点:

① 为什么 scrollView.pan 会"抢走"按钮的触摸?               → 第 6 章
② delaysContentTouches 到底是什么机制?                     → 第 6.4 节
③ 为什么用 TapGesture 替换按钮事件反而更糟?                → 第 7.2 节
④ 多个手势挂在一个视图上,它们怎么决定"谁先失效"?           → 第 7 章
⑤ 按钮事件(UIControl)和手势(UIGestureRecognizer)谁优先级高?→ 第 6.3 节
⑥ ScrollView 内部怎么"选择性让路"给它的子视图?             → 第 8 章
⑦ 如果按钮和手势都是我的,怎么用"三板斧"彻底解决冲突?       → 第 7 章

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号一路追到底,每讲完一段原理,就解开一两个;最后在第 10 章,把整条链路兜回到 ProductImageGallery 上,给出三种修复方案与各自代价。

本篇路线:

事件源头:硬件 → SpringBoard → UIKit  (第 3 章)
   ↓
hitTest 命中测试:递归视图树找目标    (第 4 章)
   ↓
响应链:事件沿 Responder Chain 传递   (第 5 章)
   ↓
手势状态机:UIGestureRecognizer 核心  (第 6 章)
   ↓
手势冲突三板斧                       (第 7 章) ─→ 主力武器
   ↓
ScrollView 手势暗战                  (第 8 章)
   ↓
调试工具与方法                       (第 9 章)
   ↓
综合案例全程串讲                     (第 10 章)

📌 本篇定位:这是 iOS 专栏的交互地基篇。理解 hitTest + 响应链 + 手势状态机是后续所有 UI 交互话题(转场动画手势、自定义容器控制器触摸、3D Touch)的前置知识。读完本篇后,看到任何"为什么按钮不响应""为什么手势冲突"的问题,都能从三层模型中找到根因。

# 2. 架构概览

# 2.1 事件系统四层模型

iOS 的触摸事件系统可以抽象为四层传递模型——每一层都有独立的职责和传递方向:

触摸面板 (Multi-Touch Hardware)
      │
      ▼
  ┌───────────────────────────────────────────────────────┐
  │                    第 0 层:IOKit                       │
  │  IOHIDFamily 驱动 → 电容触控原始数据 → UITouch 对象池      │
  └───────────────────────────────────────────────────────┘
      │
      ▼
  ┌───────────────────────────────────────────────────────┐
  │                    第 1 层:SpringBoard                │
  │  系统级手势拦截(控制中心、返回主屏幕、多任务)优先消费        │
  └───────────────────────────────────────────────────────┘
      │
      ▼
  ┌───────────────────────────────────────────────────────┐
  │                    第 2 层:UIApplication              │
  │  sendEvent: 将事件派发给 keyWindow                     │
  └───────────────────────────────────────────────────────┘
      │
      ▼
  ┌───────────────────────────────────────────────────────┐
  │                    第 3 层:UIKit 视图树                │
  │  keyWindow → hitTest 命中测试 → Gesture Recognizer     │
  │  → Responder Chain → 目标方法执行                       │
  └───────────────────────────────────────────────────────┘

第 3 层(UIKit 视图树)是开发者的主战场,又分为三个子阶段:

子阶段 方向 核心 API 作用
hitTest 命中测试 从上到下(从 Window 到叶子 View) hitTest:withEvent: 找到触摸点命中的最深层 View
Gesture Recognizer 与 hitTest 平行 touchesBegan/Moved/Ended/Cancelled 识别手势状态机
Responder Chain 从下到上(从叶子 View 到 Application) nextResponder 未被手势消费的事件上浮处理
视觉图(一棵普通 UIView 树的命中与响应双通道):

UIWindow                              ← 事件入口
  │  hitTest 方向 ↓ (深度优先)
  ├─ UIViewController.view
  │    ├─ UIView (container)
  │    │    ├─ UIButton "确认"          ← 命中目标
  │    │    └─ UILabel "提示"           ← 未命中
  │    └─ UIView (footer)
  │
  │  响应链方向 ↑ (nextResponder 回溯)
  │    按钮 → container → VC.view → VC → Window → App → AppDelegate

# 2.2 为什么这么分层

疑惑:为什么要把"找到目标视图"和"处理手势"拆成两套机制?

论证:

  1. 职责分离——hitTest 只回答一个问题:「当前触摸点在哪个 View 上?」手势识别器只回答另一个问题:「这一组触摸符合什么手势模式?」两者正交。如果把它们耦合在一起,就无法实现"同一个 View 上挂多个手势各自独立识别"的设计。

  2. 手势的延迟特性——手势识别是一个时序过程(等够长按时间、等够滑动距离),而 hitTest 是一个瞬时快照(手指按下那一刻确定目标)。如果 hitTest 也卷入手势延迟,整个系统的触摸响应将不可预测。

  3. 响应链的降级机制——手势识别器有机会"吞掉"部分 touch 事件,但绝对不会吞掉所有——那些没命中手势的 touch 仍然沿响应链传递。这种设计让「ScrollView 吞掉滑动,但把点击留给 Button」成为可能。

  4. 系统手势的优先级——SpringBoard 层必须在 UIKit 层之前过滤(否则用户可能在 App 上方划出控制中心),这要求分层拦截架构。

  5. 反向验证:如果不分层会怎样?参考早期 iOS 3.x 时代——手势识别还在 UIView touchesBegan 里手写状态机,开发者需要在每个自定义 View 里埋 if-else 来判断「是滑动还是点击」,一个页面几千行触摸处理代码。UIKit 把 gesture recognizer 独立出来,是 iOS 事件系统最重要的架构演进。

结论:四层模型把"硬件 → 系统 → App → 视图"的整条链路权限化、阶段化。每一层只处理自己权责内的决策,不越级干预。这是我们在任何手势冲突问题中都能"从分层定位根因"的前提。

# 3. 事件源头:从硬件到UIEvent

# 3.1 IOKit与触摸硬件

手指触摸屏幕时,电容触控面板以约 120 Hz(iPad Pro ProMotion 可达 240 Hz) 的速率扫描手指位置(接触区域 → 质心计算)。IOKit 框架内的 IOHIDFamily 将这些原始数据封装为低层触控事件,并传递给 SpringBoard。

手指触摸
    │
    ▼
电容矩阵扫描 (120/240 Hz 采样)
    │
    ▼
IOHIDFamily: 触控点 ID 分配、坐标归一化、压力/尺寸计算
    │
    ▼
UITouch 实例 → UIEvent(一组相关触摸的集合)
    │
    ▼
UIApplication.sendEvent:

三个关键事实:

  • 触控点 ID 是 IOKit 分配的,在手势持续期间不会变化——这就是为什么即使手指滑动,UITouch 实例不会换。
  • 坐标归一化 发生在系统层,开发者拿到的已经是 View 坐标系下的坐标。
  • Force / 3D Touch 压力值 在同一阶段计算并附在 UITouch 上。

# 3.2 SpringBoard到UIKit

SpringBoard(iOS 的主屏幕进程)在事件分发前有一个系统手势拦截点:

系统手势 优先级 如何判断
控制中心 最高 屏幕底部特定区域上滑
通知中心 最高 屏幕顶部下滑
返回主屏幕 最高 底部横条上滑(iPhone X+)
App 切换器 高 底部上滑 + 停顿
全局返回手势 中 屏幕左边缘右滑

SpringBoard 用 _UISystemGestureGateGestureRecognizer 这类内部手势判断是否拦截。一旦拦截,应用主线程完全收不到这个事件——这也是为什么「在屏幕底部放一个自定义按钮」有时会失灵的原因(系统手势优先)。

未被拦截的事件经 mach port(进程间通信)发送到目标 App,由 UIApplication.sendEvent: 接收。

# 3.3 UIEvent的内部结构

一个 UIEvent 封装了一次多指触摸序列(从第一个手指按下到所有手指抬起):

// UIEvent 的核心属性
event.type          // .touches, .motion, .remoteControl, .presses
event.subtype       // 子类型(如 .motionShake)
event.timestamp     // 事件产生的时间戳

// 获取触摸对象
event.allTouches            // 当前事件的完整触摸集合
event.touches(for: view)    // 特定 view 上的触摸
event.touches(for: window)  // 特定 window 上的触摸

一条 UIEvent 的类型体系:

UIEvent
├── touches 事件  ← 本篇主角
│   └── 一组 UITouch:每个手指对应一个
├── motion 事件
│   └── 摇一摇、加速计
├── remoteControl 事件
│   └── 耳机线控
└── press 事件
    └── 物理按键、Apple Pencil

每种事件走各自的 sendEvent: 分发路径。本篇聚焦 touches。

# 3.4 UITouch的完整生命周期

一个 UITouch 对象从按下到释放经历 4 个阶段,每个阶段对应 UIResponder 的一个回调:

阶段              UIResponder 回调            UITouch.phase
──────────────────────────────────────────────────────────
手指按下            touchesBegan(_:with:)       .began
手指移动            touchesMoved(_:with:)       .moved
手指抬起            touchesEnded(_:with:)       .ended
系统取消            touchesCancelled(_:with:)   .cancelled
UITouch 生命周期时间线(单指):

  t0         t1         t2         t3         t4
  │   began  │  moved   │  moved   │  ended   │
  ▼──────────▼──────────▼──────────▼──────────▼───► 时间轴
       └──────────────────────────────────────┘
               一个完整的 touch 序列

  t0         t1         t2         t3
  │   began  │  moved   │  cancelled (来电中断)
  ▼──────────▼──────────▼────────────────────────►
       └─────────────────────────┘
               被系统取消的 touch 序列

UITouch 的核心属性速查:

属性 含义 注意事项
location(in:) 在指定 View 坐标系中的位置 常用 view.window 或 nil(窗口坐标)
previousLocation(in:) 上一次回调时的位置 配合 location(in:) 计算位移
timestamp 当前阶段的时间戳 可以用来计算滑动速度
tapCount 在短时间内的点击次数 双击 = 2,只在 .ended 阶段有效
force 按压力度(0~maximumPossibleForce) 3D Touch / Pencil 专用
majorRadius 接触区域半径 实现画笔粗细等效果
phase 当前阶段 .began / .moved / .stationary / .ended / .cancelled
gestureRecognizers 附着在此触摸上的手势识别器 系统填充,只读

# 4. hitTest命中测试

# 4.1 递归搜索算法

每当手指触摸屏幕,UIApplication.sendEvent: 调起后,UIWindow 执行一次 hitTest(命中测试) 来确定「哪个 View 接收这条触摸」。算法本质上是一个深度优先的前序遍历:

// UIKit 内部的伪代码(极简版)
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // 过滤条件 1~5(见 4.2 节)
    guard isUserInteractionEnabled, !isHidden, alpha > 0.01,
          point(inside: point, with: event) else {
        return nil
    }

    // 倒序遍历子视图(最上层的子视图先命中)
    for subview in subviews.reversed() {
        let convertedPoint = subview.convert(point, from: self)
        if let hit = subview.hitTest(convertedPoint, with: event) {
            return hit
        }
    }

    // 没有命中任何子视图 → 返回自己
    return self
}

为什么倒序遍历? 因为 subviews 数组的顺序是从下到上(index 0 是最底层),reversed() 让最顶层(最后 add 的)子视图优先被测试——符合「用户看到的那个是交互目标」的直觉。

hitTest 的搜索路径(视图树):

         Window
        /      \
   ViewA        ViewB         ← 先搜 ViewB(顶层)
   /   \        /   \
  Btn1  Btn2  Lbl1  ViewC    ← 同一层内继续倒序
                      /   \
                   Btn3   Lbl2

触摸在 Btn3 上的搜索顺序:
Window → ViewB → ViewC → Btn3 → 命中 ✅
(ViewA 和它的子树完全没被搜到——提前返回)

# 4.2 五个过滤条件

hitTest 在进入子视图搜索前,会先检查五个硬性过滤条件。任一个不满足,当前视图及其整个子树都跳过:

// 条件 1:视图是否允许用户交互?
view.isUserInteractionEnabled    // false → 跳过(包括其全部子视图)

// 条件 2:视图是否隐藏?
view.isHidden                    // true → 跳过

// 条件 3:视图是否透明?
view.alpha <= 0.01               // 近似为 0 → 跳过(注意是 ≤ 0.01,不是 == 0)

// 条件 4:触摸点是否在视图 bounds 内?
view.point(inside: point, with: event)  // false → 跳过

// 条件 5:视图是否有父视图?
view.superview                   // nil → 无法确定坐标 → 跳过

五个条件的短路关系:

isUserInteractionEnabled == false?  ──YES──→ return nil
        │ NO
        ▼
isHidden == true?                   ──YES──→ return nil
        │ NO
        ▼
alpha <= 0.01?                      ──YES──→ return nil
        │ NO
        ▼
point(inside:) == false?            ──YES──→ return nil
        │ YES
        ▼
     进入子视图遍历

⚠️ 关键认知:isUserInteractionEnabled == false 会阻断整个子树的命中测试。也就是说,即使子视图单独设置了 isUserInteractionEnabled = true,只要父视图关掉它,子视图永远收不到 touch。这个"向下禁绝"特性是一把双刃剑。

# 4.3 alpha与isHidden的坑

坑 1:alpha ≤ 0.01 的魔法数字

系统用 0.01(不是 0)作为透明度阈值。这意味着:

view.alpha = 0.009    // 人眼几乎看不出 → hitTest 返回 nil ❌
view.alpha = 0.011    // 人眼几乎看不出 → hitTest 正常工作 ✅

一些动画库把 View 的 alpha 设为 0.001 来实现"隐藏但留着占位"——结果就是整个子树的触摸全部消失。正确做法是用 isHidden = true 或完全移除。

坑 2:子视图超出父视图 bounds 的「点不中」

let parent = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
let child = UIButton(frame: CGRect(x: 80, y: 80, width: 50, height: 50))
parent.addSubview(child)
// child 的一半在 parent 外面——超出部分点不中!
// 因为 parent.point(inside:) 会拒绝超出 bounds 的点

解法:重写父视图的 point(inside:with:)(见 4.4 节)。

坑 3:hidden = NO 但隐藏的子视图

view.layer.opacity = 0     // 用 CA 层透明度代替 UIView.alpha
// isHidden 仍为 false,hitTest 依然搜索,但用户完全看不到这个视图
// ——用户会奇怪为什么"空白区域"能点中

# 4.4 pointInside重写实战

point(inside:with:) 是 hitTest 的唯一可重写的边界判定点。用它解决"扩大点击区域"或"子视图超出父视图"的场景:

实战 1:扩大按钮点击热区

class WideButton: UIButton {
    // 把 44×44 的按钮点击区域扩大 10pt
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let expanded = bounds.insetBy(dx: -10, dy: -10)
        return expanded.contains(point)
    }
}

实战 2:让超出父视图的子视图可响应

class OverflowContainer: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // 先正常走 hitTest
        if let hit = super.hitTest(point, with: event) {
            return hit
        }

        // 如果正常流程没命中(子视图超出了 bounds),
        // 手动检查所有子视图
        for subview in subviews.reversed() {
            let pt = subview.convert(point, from: self)
            if !subview.isHidden, subview.alpha > 0.01,
               subview.isUserInteractionEnabled,
               subview.point(inside: pt, with: event) {
                // 对命中的子视图继续递归搜索
                return subview.hitTest(pt, with: event) ?? subview
            }
        }
        return nil
    }
}

实战 3:不规则形状 View 的命中测试

class TriangleView: UIView {
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        // 只有点在三角形内部才响应
        return isPointInTriangle(point, a: .zero, b: CGPoint(x: bounds.width, y: 0),
                                 c: CGPoint(x: bounds.width / 2, y: bounds.height))
    }
}

# 5. 响应链传递机制

# 5.1 从hit-tested view出发

hitTest 找到了 hit-tested view(我们叫它 hitView)之后,事件首先以 touchesBegan/Moved/Ended/Cancelled 的形式直接交付给 hitView。但故事不止于此——如果 hitView 没有处理(或只处理了一部分),事件沿响应链向上一路传递。

事件交付顺序(一条 touch 的生命):

1. hitTest 找到 hitView
2. hitView 的手势识别器先收到 touchesBegan
3. hitView.touchesBegan 被调用
4. 如果手势识别失败/取消 → hitView.touchesEnded/Cancelled
5. 如果都没处理 → nextResponder.touchesBegan ...
6. 以此类推,直到 UIApplication

# 5.2 nextResponder的默认指向

nextResponder 是 UIResponder 的一个属性,构成一条链表:

当前响应者 nextResponder(默认)
UIView superview(如果是控制器根 view,则指向控制器)
UIViewController 其 view 的 superview,如果 view 是 window 的根视图则指向 window
UIWindow UIApplication
UIApplication AppDelegate(如果服从 UIApplicationDelegate 协议)
AppDelegate nil(到头了)
一条完整响应链的图示:

  UIButton (hitView)
    │  nextResponder
    ▼
  ContainerView
    │  nextResponder
    ▼
  UIViewController.view (根视图)
    │  nextResponder
    ▼
  UIViewController
    │  nextResponder
    ▼
  UIWindow
    │  nextResponder
    ▼
  UIApplication
    │  nextResponder
    ▼
  UIApplicationDelegate
    │  nextResponder
    ▼
  nil ← 事件到达终点,被视为未处理

关键规则:手势识别器在 touchesBegan 之前就介入判断。如果手势识别成功,hitView 会收到 touchesCancelled(而非 touchesEnded)。换句话说,手势的优先级高于原始 touch 回调。这是 UIControl 与手势冲突的根因。

# 5.3 跨视图控制器的传递

当 UIViewController 作为子控制器嵌入另一个控制器时,响应链也跟着改变:

// ChildViewController 的 view 被 add 到 ParentViewController 的 containerView 上
parent.addChild(child)
containerView.addSubview(child.view)   // child.view 作为 containerView 的子视图

响应链变成:

childButton (hitView)
    → child.view
    → containerView
    → parent.view
    → parent (UIViewController)
    → parent.view.superview ... 继续上传

⚠️ 易错点:如果只调用了 addSubview 而忘了 addChild,响应链中不会出现 child ViewController,也不会触发 viewWillAppear 等生命周期方法。虽不直接导致 crash,但会让事件的 VC 层级脱节。

# 5.4 自定义响应链节点

重写 next 属性(不要直接赋值 nextResponder,它是只读的)来截断或重定向响应链:

class ResponseInterceptorView: UIView {
    weak var eventProxy: UIResponder?

    override var next: UIResponder? {
        // 把未处理的事件转发给自己指定的代理
        return eventProxy ?? super.next
    }
}

实际应用:表格单元格里的按钮,把响应链定向到 cell 的 ViewController,以省去 delegate 回调:

class TableViewCell: UITableViewCell {
    override var next: UIResponder? {
        // 跳过中间层,直接把事件抛给所在的 ViewController
        return tableView?.delegate as? UIResponder ?? super.next
    }
}

⚠️ 覆盖 next 是打破 UIKit 响应链约定的最后手段,通常只在复杂的视图嵌套场景(如把 ViewController 的 view 嵌入到 UITableViewCell)中使用。

# 6. UIGestureRecognizer状态机

# 6.1 七状态转换图

UIGestureRecognizer 的核心是一个七状态有限状态机——每一帧 RunLoop 只推进一次状态转换:

UIGestureRecognizer 状态转换图:

  .possible ─────────────────────────┐
     │                               │
     ├── touchesBegan ──→ .began ────┤
     │                    │          │
     │                    ├──→ .changed
     │                    │      │    │
     │                    │      ├────┘
     │                    │      │
     │                    ├──→ .ended ──→ .possible  (手势完成)
     │                    │
     │                    ├──→ .cancelled ──→ .possible  (被取消)
     │                    │
     │                    └──→ .failed ──→ .possible  (识别失败)
     │
     └── (不满足条件) ──→ .failed ──→ .possible  (直接失败)

七个状态详解:

状态 含义 何时进入 何时退出
.possible 初始状态,尚未开始识别 手势创建、每次识别结束后重置 首次 touch 满足条件时
.began 手势开始执行 触摸序列满足手势的起始条件(如长按计时到 0.5s) 后续 move/end/cancel/fail
.changed 手势持续执行中 .began 之后继续移动 ended / cancelled / failed
.ended 手势正常结束 手指抬起且手势识别成功 自动回到 .possible
.cancelled 手势被外部取消(如电话呼入、ScrollView 抢走触摸) 系统取消通知 自动回到 .possible
.failed 识别失败 触摸序列不满足手势条件(如长按未够 0.5s 就松手) 自动回到 .possible
.recognized 离散手势的瞬时成功(如 Tap) tap.count == 1 且无其他手势干扰 自动回到 .possible

# 6.2 状态转换的源码级时序

手势状态机的运行时机是 RunLoop 的 UITrackingRunLoopMode 中。每当 RunLoop 处理输入源时,如果有新的触摸事件到达:

RunLoop 周期(每 1/60s):

Source0 回调(IOKit 事件)
     │
     ▼
UIApplication.sendEvent:  → 封装 UIEvent
     │
     ▼
keyWindow.sendEvent:      → 将 UIEvent 逐一手势发送
     │
     ▼
每个手势依次调用 touchesBegan/Moved/Ended 内部方法
     │
     ▼
手势更新自己的状态机 → 通知 delegate / target-action

状态驱动的三个回调时机:

// 离散手势(Tap / Swipe)——状态只有 .possible → .ended 或 .failed
tapGesture.state 变化:
  .possible → 收到 touch → 内部计时:
    如果满足 tap 条件 → .ended → target-action 触发 → .possible
    如果不满足       → .failed → .possible

// 连续手势(Pan / Pinch / Rotation)——状态流经 .began → .changed → .ended
panGesture.state 变化:
  .possible → touch 移动超过 10pt → .began → target-action 触发
  → 继续移动 → .changed → target-action 持续触发
  → 手指抬起 → .ended   → target-action 触发 → .possible

关键:target-action 每次状态变化都调用一次——.began 一次、每次 .changed 一次、.ended 一次。这就是为什么 panGesture 的 target-action 会被频繁调用。

# 6.3 touchesBegan与手势的竞合

疑惑:Button 的 .touchUpInside 和 TapGestureRecognizer 同时存在时,谁会赢?

论证:UIKit 为 UIControl 预留了一个特殊机制——UIControl 优先于同 View 上的手势识别器:

// UIButton 的触摸处理等价于:
// 1. touchesBegan → 高亮状态
// 2. 手指抬起 → .touchUpInside 触发
// 如果一个 TapGestureRecognizer 也挂在 button 上:
//    → 系统让 TapGesture 延迟等待,看 Button 是否先处理

具体机制在 UIGestureRecognizer 头文件中暴露:

open class UIGestureRecognizer: NSObject {
    // 关键:将手势推迟到 UIControl 事件处理之后
    open var delaysTouchesBegan: Bool      // 默认 false
    open var delaysTouchesEnded: Bool      // 默认 true(scrollView 用的是这个)
}

UIButton 内部相当于:

// 伪代码:UIButton 的触摸内部机制
override func touchesBegan(...) { /* 高亮 */ }
override func touchesEnded(...) {
    if bounds.contains(location) {
        sendActions(for: .touchUpInside)   // ← 直接触发
    }
    // 此时附着在 Button 上的 TapGesture 还处于 .possible 状态
    // 因为它被"推迟"到 .touchUpInside 之后再做判断
}

而在非 UIControl 的普通 UIView 上,如果没有 delaysTouchesBegan = true,手势识别器会和 touchesBegan 并行走——两者同时收到触摸回调。

# 6.4 手势延迟与取消机制

三个影响手势与 touch 回调时序的属性:

// 属性 1:触摸序列开始时,延迟交付 touchesBegan
gesture.delaysTouchesBegan = true
// 效果:touch 先不给 View,等手势判断结果
//   - 手势失败 → touch 交付给 View(有延迟)
//   - 手势成功 → touchesCancelled 替代 touchesBegan

// 属性 2:触摸序列结束时,延迟交付 touchesEnded
gesture.delaysTouchesEnded = true
// 效果:手指抬起后,先让手势做最终判定
//   - 手势识别为 Tap → touchesCancelled 替代 touchesEnded
//   - 手势失败     → touchesEnded 正常交付

// 属性 3:是否允许手势与 touch 回调同时接收
gesture.cancelsTouchesInView = true
// 效果:
//   true(默认) → 手势被识别后,向 View 发送 touchesCancelled
//   false        → 手势被识别后,View 仍然正常收到 touchesEnded

UIScrollView 的「卡顿感」从哪来:

// UIScrollView 内部的 panGestureRecognizer 默认设置:
scrollView.panGestureRecognizer.delaysTouchesBegan = true
// => scrollView 并不知道手指是"点Button"还是"滚内容",
//    所以先扣住 touch,等移动超过 10pt 再做决定
//    → 这就造成了"按钮点下去有点肉(不灵敏)"的用户体感

修复:

scrollView.delaysContentTouches = false
// 立即把 touch 给子视图,不做延迟——但代价是
// 轻微滑动也可能触发 Button 而不是滚动

这就是第 1 章案例中 favoriteButton 点不动的原因之一——scrollView.panGestureRecognizer 开启 delaysTouchesBegan,先抢走了触摸,等它判定"这不是滑动"时已经过了几十毫秒;如果用户手稍微抖一下,直接判定为 .began,Button 再也收不到 touchesEnded。

# 7. 手势冲突三板斧

# 7.1 第一斧:requireGestureRecognizerToFail

最直接的手势优先级控制:A 必须等 B 失败,A 才能开始识别。

// 场景:ScrollView 里有个自定义长按手势,希望长按优先于滚动
scrollView.panGestureRecognizer.require(toFail: longPressGesture)
// 语义:panGesture 必须等 longPressGesture 失败后才能开始

// 翻译:手指按下后
//   0.0s → longPress 开始计时(0.5s)
//   0.0s - 0.5s → panGesture 处于等待状态(不能 .began)
//   0.5s 内手指没移动 → longPress.began → panGesture 永久锁定为 .failed
//   0.5s 内手指移动超过 10pt → longPress.failed → panGesture 解锁,可以开始了

require(toFail:) 的底层实现:系统维护一个手势依赖图(DAG,有向无环图)。当 A 要求 B 先失败时,系统在 B 的识别过程中插入一个回调:若 B 进入 .began 或 .ended,A 直接标记为 .failed 且永不恢复。

手势依赖图(A → B 表示 A 要求 B 失败):

   panGesture ──────→ longPressGesture
     │                     │
     │                     ▼
     │                如果 longPress 成功 → pan 永久失败
     │                如果 longPress 失败 → pan 可以开始
     ▼
   tapGesture ──────→ doubleTapGesture
     │                     │
     ▼                     ▼
  (无依赖)          (要求 tap 失败)

常见配方:

// 配方 A:单双击共存
singleTap.require(toFail: doubleTap)
// 双击先等 300ms,如果是双击则 singleTap 永远不会触发

// 配方 B:ScrollView + 自定义 pan 共存
scrollView.panGestureRecognizer.require(toFail: customPan)
// 自定义 pan 优先,scrollView 滑动其次

// 配方 C:子 ScrollView 的 pan 先于父 ScrollView
outerScrollView.panGestureRecognizer.require(toFail: innerScrollView.panGestureRecognizer)
// 内层滚动优先,内层滚到底才让外层滚

# 7.2 第二斧:UIGestureRecognizerDelegate

delegate 提供了比 require(toFail:) 更细粒度的控制——以每次触摸为单位做实时决策:

protocol UIGestureRecognizerDelegate {
    // 1. 单次触摸:这个手势要不要开始识别?
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool

    // 2. 两个手势:能不能同时识别?
    func gestureRecognizer(_:shouldRecognizeSimultaneouslyWith: UIGestureRecognizer) -> Bool

    // 3. 两个触摸事件:手势要不要接收当前的 touch?
    func gestureRecognizer(_:shouldReceive: UITouch) -> Bool
    func gestureRecognizer(_:shouldReceive: UIEvent) -> Bool  // iOS 13.4+

    // 4. 两个手势:A 是否应该被 B 阻止?
    func gestureRecognizer(_:shouldBeRequiredToFailBy: UIGestureRecognizer) -> Bool
    // 新增于 iOS 7,是 require(toFail:) 的 delegate 版
}

实战示例:让 UIPanGestureRecognizer 只在特定方向启动

class HorizontalPanOnlyDelegate: NSObject, UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
        let velocity = pan.velocity(in: pan.view)
        return abs(velocity.x) > abs(velocity.y)   // 仅水平方向启动
    }
}

# 7.3 第三斧:自定义手势子类

当 delegate 无法覆盖需求(如"在识别过程中动态改变识别条件")时,需要自定义 UIGestureRecognizer 子类:

// 自定义「圆形手势」:手指画一个圆圈
class CircleGestureRecognizer: UIGestureRecognizer {
    private var points: [CGPoint] = []
    private var center: CGPoint = .zero
    private var radius: CGFloat = 0

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        guard let touch = touches.first else { return }
        points = [touch.location(in: view)]
        state = .possible   // 仍在等待
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        guard let touch = touches.first else { return }
        points.append(touch.location(in: view))

        // 至少收集 20 个点才开始判定
        guard points.count >= 20 else { return }

        if state == .possible, isCircle() {
            state = .began       // ← 手动推进状态
        } else if state == .began {
            state = .changed
        } else if state == .changed, !isCircle() {
            state = .failed      // 离开圆形轨迹 → 失败
        }
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        if state == .began || state == .changed {
            state = .ended
        } else {
            state = .failed
        }
    }

    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
        state = .cancelled
    }

    private func isCircle() -> Bool {
        // 拟合最小二乘圆,判断各点到圆心的距离方差是否低于阈值
        guard points.count >= 5 else { return false }
        // ... 实现省略 ...
        return true
    }

    override func reset() {
        super.reset()
        points.removeAll()
        center = .zero
    }
}

自定义手势的核心规则:

  1. 必须手动设置 state——不设 state = .began,target-action 永远不触发。
  2. state 一旦设为 .failed / .cancelled / .ended,不可再变。
  3. reset() 在每次状态回到 .possible 时自动调用——在这里清空临时数据。
  4. view 在 addGestureRecognizer 后自动设置,不需要手动赋值。

# 7.4 多手势并发与互斥矩阵

当 4+ 个手势同时挂在一个视图上时,手动管理 require(toFail:) 容易爆炸。一个更系统的做法是显式建立互斥矩阵:

class GestureConflictMatrix {
    // 互斥规则:key 手势要求 value 手势失败
    private var rules: [UIGestureRecognizer: Set<UIGestureRecognizer>] = [:]
    // 并发规则:允许同时识别的手势对
    private var concurrent: Set<GesturePair> = []

    func require(_ a: UIGestureRecognizer, toFail b: UIGestureRecognizer) {
        a.require(toFail: b)
        rules[a, default: []].insert(b)
    }

    func allowSimultaneous(_ a: UIGestureRecognizer, _ b: UIGestureRecognizer) {
        concurrent.insert(GesturePair(a, b))
    }
}

// 使用:
let matrix = GestureConflictMatrix()
matrix.require(longPress, toFail: pan)        // 长按优先于滑动
matrix.require(tap, toFail: doubleTap)        // 双击优先于单击
matrix.allowSimultaneous(pinch, rotate)       // 捏合和旋转同时识别

实际 App 中的典型手势矩阵:

手势 Pan Pinch LongPress Tap DoubleTap
Pan — 并发 等 LongPress 失败 — —
Pinch 并发 — — — —
LongPress 让 Pan 失败 — — — —
Tap — — — — 等 DoubleTap 失败
DoubleTap — — — 让 Tap 失败 —

# 8. UIScrollView中的手势暗战

# 8.1 contentOffset与手势协作

UIScrollView 内部的触摸逻辑非常复杂——它不是在「识别手势」,而是在实时计算 contentOffset,而这个计算跟手势状态转换完全绑定:

UIScrollView 的触摸处理流程(简化版):

touchesBegan:
    → 判断触摸点:落在 Button 上? → 等 150ms 看是否移动
    → 否则 → 准备滚动

touchesMoved:
    → 移动超过 10pt → 进入"滚动模式"
    → contentOffset 实时更新 = 手指拖拽量 + 惯性模拟
    → panGestureRecognizer.state = .began  (首次拖拽) / .changed

touchesEnded:
    → 手指抬起但有惯性速度 → 启动 deceleration 动画
    → 手指抬起且没有速度 → panGestureRecognizer.state = .ended

touchesCancelled:
    → 系统事件(如来电)→ 停止滚动 + 恢复原 contentOffset

delaysContentTouches 和 canCancelContentTouches 两个属性的联动:

// 默认配置(推荐):
scrollView.delaysContentTouches = true   // 延迟交付 touch 给子视图
scrollView.canCancelContentTouches = true // 一旦判定为滚动,取消子视图的 touch

// 用户触摸 Button 的决策树:
// ┌── 手指按下
// │    └── 150ms 内手指动了吗?
// │         ├── 否 → touch 交付 Button → Button highlights
// │         │        → 手指抬起 → Button touchUpInside 触发 ✅
// │         └── 是 → touch 不给 Button → 开始滚动
// │                  → Button 收到 touchesCancelled ← 永远不会触发事件 ❌
// │                         ↑
// │                  这就是"手指稍动一下按钮就不灵"的秘密!

# 8.2 嵌套UIScrollView的滚动冲突

最经典的嵌套场景:外层垂直滚、内层水平滚——两个 panGestureRecognizer 同时竞争。

配置:内层 ScrollView(横向)× 外层 ScrollView(纵向)

用户手指在内层 ScrollView 区域做"斜线滑动":
  手指 x 方向分量大 → 期望内层横向滚
  手指 y 方向分量大 → 期望外层纵向滚
  手指 45° 走 → 两个都想滚 → 鬼畜

解法:方向性手势锁定

// 拦截外层 scrollView 的 pan gesture,只在纵向分量大于横向时才允许
class NestedScrollDelegate: NSObject, UIGestureRecognizerDelegate {
    weak var outerScrollView: UIScrollView?

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
              pan.view == outerScrollView else { return true }
        let velocity = pan.velocity(in: pan.view)
        // 纵向速度 > 横向速度 → 外层滚
        return abs(velocity.y) > abs(velocity.x)
    }
}

outerScrollView.panGestureRecognizer.delegate = nestedDelegate

更精细的方案:simultaneousRecognition + 方向竞争

class DirectionalScrollDelegate: NSObject, UIGestureRecognizerDelegate {
    weak var horizontalScrollView: UIScrollView?
    weak var verticalScrollView: UIScrollView?

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
        return true   // 允许两者同时识别——然后各自在 delegate 里做方向判断
    }
}
// 两个 scrollView 的 pan 都设为该 delegate,各自的 gestureRecognizerShouldBegin 做方向过滤

# 8.3 UIPageViewController手势底层

UIPageViewController 内部有两个手势识别器(_UIPageViewControllerContentView 内部的私有手势类):

UIPageViewController 翻页的手势检测:
  1. 手指从屏幕边缘开始 → 可能触发「侧滑返回」(系统手势,优先级最高)
  2. 手指在页面中间,水平滑动超过 50% 宽度 → 翻页
  3. 内层某个子 ViewController 的 ScrollView → 优先级高于 PageVC 的手势

这就是为什么「在 UIPageViewController 里放一个全屏 ScrollView,有时能翻页有时不能」——两者在竞争 touch。

# 8.4 全屏侧滑与ScrollView共存

iOS 7 起,UINavigationController 的「侧滑返回」与 UIScrollView 一直是冲突高发区:

// 解法 1:最简单——只响应 ScrollView 之外的左边缘区域
navigationController?.interactivePopGestureRecognizer?.isEnabled = true

// 解法 2:在 scrollView 滑动到最左边时,才允许侧滑返回介入
extension MyScrollViewController: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool {
        if gestureRecognizer == navigationController?.interactivePopGestureRecognizer {
            // 侧滑返回优先于其他手势
            return true
        }
        return false
    }
}

// 解法 3:自定义全屏侧滑(更细粒度控制)
class FullScreenPopGestureRecognizer: UIPanGestureRecognizer {
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)
        // 只在屏幕左 30pt 内开始的触摸才允许触发
        guard let view = view, let touch = touches.first else { return }
        if touch.location(in: view).x > 30, state == .possible {
            state = .failed
        }
    }
}

# 9. 性能与调试

# 9.1 hitTest性能瓶颈定位

hitTest 在一个复杂视图树中的调用次数可能非常惊人——每个触摸点都要递归下去。典型的性能问题:

// 反例:View 层级过深
// 一个 Feed 流卡片 ≈ 50+ 个子视图,每个 hitTest 遍历全部
// → 每次触摸 50+ 次 pointInside 调用 + 坐标转换

// 优化 1:对复杂但不可交互的视图,关掉 userInteractionEnabled
backgroundImageView.isUserInteractionEnabled = false   // 整个子树跳过

// 优化 2:对大的容器视图,缩小搜索范围
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    // 只搜索有交互元素的区域
    return interactiveArea.contains(point)
}

// 优化 3:把常用的目标 View 提到前面
// subviews 数组的最后一个被最先搜索 → 把最常用目标放最后
view.bringSubviewToFront(frequentInteractionView)

性能数据(实测,iPhone 12,1000 个子视图的复杂 UI):

优化手段 hitTest 耗时 改善
未优化(全部 isUserInteractionEnabled = true) ~2.8 ms 基线
非交互视图关闭交互 ~0.6 ms 4.7×
缩小 pointInside 区域 + 关闭非交互视图 ~0.15 ms 18.7×

# 9.2 可视化hitTest路径

调试 hitTest 最直观的方式是在运行时绘制命中路径:

extension UIView {
    // swizzle hitTest 方法,打印命中路径
    static func enableHitTestDebug() {
        let original = class_getInstanceMethod(UIView.self, #selector(hitTest(_:with:)))
        let swizzled = class_getInstanceMethod(UIView.self, #selector(debug_hitTest(_:with:)))
        if let original = original, let swizzled = swizzled {
            method_exchangeImplementations(original, swizzled)
        }
    }

    @objc func debug_hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hit = debug_hitTest(point, with: event)
        if let hit = hit, hit != self {
            let indent = String(repeating: "  ", count: responderChainDepth())
            print("\(indent)🔹 \(type(of: self)) → \(type(of: hit))")
        }
        return hit
    }

    private func responderChainDepth() -> Int {
        var depth = 0
        var current: UIResponder? = self
        while let next = current?.next {
            depth += 1
            current = next
        }
        return depth
    }
}

输出示例:

🔹 UIWindow → ProductImageGallery
  🔹 ProductImageGallery → UIScrollView
    🔹 UIScrollView → UIButton (favoriteButton)  ← 命中

# 9.3 手势冲突调试三板斧

第一板斧:打印手势状态变化

class DebugGestureRecognizer: UIPanGestureRecognizer {
    let name: String
    init(name: String, target: Any?, action: Selector?) {
        self.name = name
        super.init(target: target, action: action)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        print("[\(name)] touchesBegan → state: \(state.rawValue)")
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)
        print("[\(name)] state: \(state.rawValue), velocity: \(velocity(in: view))")
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesEnded(touches, with: event)
        print("[\(name)] touchesEnded → final state: \(state.rawValue)")
    }
}

第二板斧:Hook 目标的 touchesCancelled

一旦看到 touchesCancelled 被调用而你的 target-action 没触发,说明手势被另一个手势"抢"走了。

class DebugButton: UIButton {
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesCancelled(touches, with: event)
        print("⚠️ [DebugButton] touchesCancelled — 手势被其他手势抢走!")
        // 打印调用栈
        Thread.callStackSymbols.forEach { print($0) }
    }
}

第三板斧:LLDB 断点查手势状态

(lldb) po gestureRecognizer.state.rawValue
// 0 = possible, 1 = began, 2 = changed, 3 = ended, 4 = cancelled, 5 = failed

(lldb) po (UIView *)[[[[UIApplication sharedApplication] keyWindow] rootViewController] view]
// 打印整个视图层级

(lldb) po [[[UIApplication sharedApplication] keyWindow] recursiveDescription]
// 打印每个 View 的 frame 和手势列表

# 9.4 Instruments手势追踪

使用 Instruments 的 Time Profiler + System Trace 排查手势性能:

# 录制触摸事件耗时
1. 打开 Instruments → Time Profiler
2. 选择设备 + App
3. 开始录制 → 在 App 中执行有问题的触摸操作
4. 停止录制 → 搜索 "hitTest" / "sendEvent" / "gestureRecognizer"

# 重点关注:
#   - [UIView hitTest:withEvent:] 的调用次数和耗时
#   - [UIApplication sendEvent:] 的单次耗时是否超过 16ms
#   - UIGestureRecognizer 的状态转换路径

另外,Xcode 的 Debug View Hierarchy 可以在 App 运行时实时查看手势附着情况:

运行 App → 点「Debug View Hierarchy」按钮
→ 选择目标 View → 查看「Gesture Recognizers」列表
→ 每个手势的 state / delegate / view 一目了然

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的 ProductImageGallery,七个疑问现在能逐条作答:

疑问 答案
① 为什么 scrollView.pan 会"抢走"按钮的触摸? 第 6.4:scrollView 默认 delaysContentTouches = true,先扣住触摸 150ms,若手指有移动就判定为滚动,按钮收到 touchesCancelled
② delaysContentTouches 到底是什么机制? 第 6.4:触摸到来后,不立即交付 touchesBegan 给子视图,而是等 panGesture 做方向判定
③ 为什么用 TapGesture 替换按钮事件反而更糟? 第 7.2:TapGesture 的优先级低于 scrollView.panGesture,pan 先判定导致 tap 被取消
④ 多个手势挂在一个视图上,它们怎么决定"谁先失效"? 第 7 章:require(toFail:) + delegate 的手势依赖 DAG
⑤ Button 事件和 UIGestureRecognizer 谁优先级高? 第 6.3:UIButton 内部优先级高于同视图上的手势,但跨视图(如 Button 在 ScrollView 里)时 ScrollView 的手势优先级更高
⑥ ScrollView 怎么"选择性让路"给子视图? 第 8.1:delaysContentTouches + canCancelContentTouches 双开关
⑦ 怎么用"三板斧"彻底解决冲突? 第 7 章:下面给出三种方案

修复方案(按推荐程度排序):

方案 A:给按钮加专属 TapGesture + 互斥规则(推荐)

class ProductImageGallery: UIView {
    // ...

    override init(frame: CGRect) {
        super.init(frame: frame)
        // ...

        // 1. 为按钮区域创建独立的 tap 手势
        let buttonTap = UITapGestureRecognizer(target: self, action: #selector(buttonAreaTapped))
        scrollView.addGestureRecognizer(buttonTap)

        // 2. 长按手势要求 scrollView.pan 失败(长按优先)
        longPressGesture.require(toFail: scrollView.panGestureRecognizer)

        // 3. 通过 delegate 只在按钮区域触发 buttonTap
        buttonTap.delegate = self
    }

    @objc func buttonAreaTapped(_ gesture: UITapGestureRecognizer) {
        let pt = gesture.location(in: scrollView)
        if favoriteButton.frame.contains(pt) {
            favTapped()
        }
    }
}

extension ProductImageGallery: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if gestureRecognizer == scrollView.gestureRecognizers?.last {
            let pt = gestureRecognizer.location(in: scrollView)
            return favoriteButton.frame.contains(pt)
        }
        return true
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
        return true  // 允许按钮 tap 和 scrollView.pan 同时识别
    }
}

方案 B:按钮移出 scrollView,用 transform 跟踪位置(最佳 UX)

// 按钮不放在 scrollView 里,而是放在 scrollView 的父视图
// 通过监听 scrollView.contentOffset 来更新按钮位置
private var offsetObserver: NSKeyValueObservation?

override init(frame: CGRect) {
    super.init(frame: frame)
    // favoriteButton 直接 add 到 self,不是 scrollView
    addSubview(scrollView)
    addSubview(favoriteButton)
    addSubview(pageControl)

    // KVO 跟踪 contentOffset 以更新按钮位置
    offsetObserver = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, change in
        guard let self = self, let offset = change.newValue else { return }
        self.favoriteButton.frame.origin.x = offset.x + 16
        // 按钮固定横向偏移,纵向固定——始终贴在"当前显示的图片"右下角
    }
}

代价:KVO 回调在滚动时高频触发,需保证位置更新足够轻量(只改 frame.origin.x/y)。

方案 C:自定义手势子类(最灵活,但成本最高)

class TapOnButtonGesture: UIGestureRecognizer {
    let targetButton: UIButton
    init(button: UIButton) { targetButton = button; super.init(target: nil, action: nil) }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        guard let touch = touches.first else { return state = .failed }
        let pt = touch.location(in: view)
        // 不是落在按钮区域 → 直接失败
        guard targetButton.frame.contains(pt) else { state = .failed; return }
        state = .possible
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        guard let touch = touches.first else { return }
        let pt = touch.location(in: view)
        if targetButton.frame.contains(pt) {
            state = .ended   // 手指抬起且在按钮内 → 成功
            targetButton.sendActions(for: .touchUpInside)
        } else {
            state = .failed
        }
    }
}

# 10.2 一次触摸的完整旅程

把用户「点了一下收藏按钮」这一瞬间的全过程串成一棵树:

手指触摸屏幕
        │
        ├─ 硬件层(0~2ms)
        │   ├─ 电容面板扫描手指位置
        │   ├─ IOHIDFamily 封装为低层触控事件
        │   └─ SpringBoard 判断:不是系统手势 → 放行
        │
        ├─ UIApplication 入口(2~3ms)
        │   └─ sendEvent: → keyWindow
        │
        ├─ hitTest 命中测试(3~5ms)
        │   ├─ UIWindow.hitTest: → ProductImageGallery.hitTest:
        │   ├─ → UIScrollView.hitTest:
        │   ├─ → UIButton.hitTest:
        │   ├─ UIButton.pointInside: ✅ 按钮区域内
        │   └─ 命中目标:favoriteButton         ─── 第 4 章
        │
        ├─ 手势识别(并行,5~500ms)
        │   ├─ scrollView.panGestureRecognizer
        │   │   └─ delaysTouchesBegan = true,等 150ms
        │   │       └─ 如果手指移动 < 10pt → 不识别 → 放行
        │   └─ longPressGesture
        │       └─ 如果 0.5s 内手指没移动 → .began → 触发长按
        │           └─ 我们在方案 A 中把它设为低优先级 → 不干扰按钮
        │                                                ─── 第 6-7 章
        │
        ├─ touch 交付给 favoriteButton(150~168ms)
        │   ├─ touchesBegan  → 高亮状态
        │   ├─ 手指抬起
        │   ├─ touchesEnded  → 触发 .touchUpInside
        │   └─ favTapped() 调用                               ─── 第 5 章
        │
        └─ 结束
            ├─ 手势 state 重置为 .possible
            ├─ hitView 释放事件引用
            └─ 等待下一次触摸

# 10.3 设计哲学回扣

整理本篇的四条跨篇适用的设计哲学:

哲学 1:分层互不越级——hitTest 只找目标,手势只做识别

hitTest 不关心"这是点击还是滑动",手势识别不关心"触摸点在哪个 View 上"——两个子系统正交。这种设计让「同一个 View 上并排多个手势」「同一个手势挂在不同 View 上」等复杂组合都天然可推导。职责分离是事件系统不乱的根本。

哲学 2:优先让系统手势"抢先",设计师手势"容错"

ScrollView 的 delaysContentTouches、canCancelContentTouches 体现了一个原则:宁可让 UI 稍微肉一点(延迟响应),也不能让滚动和点击互相错误触发。这是一个体验权衡——iOS 选择牺牲微量延迟换取更高的确定性。当 App 需要更低延迟时(如绘图 App),必须显式覆盖这些默认值。

哲学 3:手势状态机是不可逆的过程——.failed 即为终点

一旦手势状态进入 .failed 或 .cancelled,就不可能再变成 .began——这是终局性设计。它保证了一个手势不会在"已被告知失败"后突然复活并改变状态,避免一个触摸行为被多个手势重复消费。所有 require(toFail:) 和 delegate 方法都建立在这个不可逆假设之上。

哲学 4:事件不分发到不可见、不可交互的视图——默认安全

hitTest 的五个过滤条件(isUserInteractionEnabled、isHidden、alpha、pointInside、superview)暗含一个强安全约定:你无法点到看不到的东西、无法触发不打算交互的东西。这防止了大量潜在的异常——即使代码有 bug 创建了不该存在的 View,用户也"点不中"。

# 10.4 事件响应速查表

一张图保存以备查:

场景 根因 解法
按钮在 ScrollView 里点不动 scrollView.delaysContentTouches = true 设 false;或把按钮移出 ScrollView
手势冲突(多手势同时竞争) 无优先级声明 require(toFail:) 建立 DAG
子视图超出父视图边界点不中 父视图 pointInside 拒绝超出 bounds 的点 重写父视图 pointInside
透明 View 挡住了按钮 alpha ≤ 0.01 但 UIView 仍可命中 设 isUserInteractionEnabled = false
页面卡顿(手势跟踪掉帧) hitTest 遍历过深层级 关闭非交互视图的 isUserInteractionEnabled
自定义手势 target-action 不触发 忘记设置 state = .began 在 touchesMoved 中显式设置 state
侧滑返回与全屏 ScrollView 冲突 系统手势与 App 手势竞争 限制侧滑触发区域为左边缘 30pt
嵌套 ScrollView 互相干扰 两个 panGesture 方向不分主次 方向性手势锁定

诊断三问(遇到任何触摸问题,先问这三句):

1. 触摸点实际命中了哪个 View?
   → 用 9.2 节的可视化 hitTest 工具确认

2. 命中 View 上的所有手势分别是什么状态?
   → 用 9.3 节的 DebugGestureRecognizer 打印状态机转换

3. 如果有 touchesCancelled,是被谁取消的?
   → 用 9.3 节的 DebugButton 打印调用栈

手势优先级速查口诀:

系统手势(SpringBoard) > UIControl 内部事件 > UIGestureRecognizer(require(toFail:) 调整顺序)
其中同 View 层级:delaysTouchesBegan 的手势 > 原始 touch 回调 > delaysTouchesEnded 的手势
跨 View 层级:子 View 的手势 == 父 View 的手势(但同时识别取决于 delegate)

下一篇:了解了事件如何找到它的 View、手势如何识别,下一步进入 06.AutoLayout布局系统 (opens new window)——把 View 的位置和尺寸如何从约束中解出来,以及 AutoLayout 的性能本质。

上次更新: 2026/06/28, 17:55:19
UIView生命周期与渲染
AutoLayout布局系统

← UIView生命周期与渲染 AutoLayout布局系统→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式