事件响应与手势识别
# 05.事件响应与手势识别
深度篇 | hitTest命中测试、响应链传递、UIGestureRecognizer状态机、手势冲突解决三板斧。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 事件源头:从硬件到UIEvent
- 4. hitTest命中测试
- 5. 响应链传递机制
- 6. UIGestureRecognizer状态机
- 7. 手势冲突三板斧
- 8. UIScrollView中的手势暗战
- 9. 性能与调试
- 10. 综合案例串讲
# 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 为什么这么分层
疑惑:为什么要把"找到目标视图"和"处理手势"拆成两套机制?
论证:
职责分离——hitTest 只回答一个问题:「当前触摸点在哪个 View 上?」手势识别器只回答另一个问题:「这一组触摸符合什么手势模式?」两者正交。如果把它们耦合在一起,就无法实现"同一个 View 上挂多个手势各自独立识别"的设计。
手势的延迟特性——手势识别是一个时序过程(等够长按时间、等够滑动距离),而 hitTest 是一个瞬时快照(手指按下那一刻确定目标)。如果 hitTest 也卷入手势延迟,整个系统的触摸响应将不可预测。
响应链的降级机制——手势识别器有机会"吞掉"部分 touch 事件,但绝对不会吞掉所有——那些没命中手势的 touch 仍然沿响应链传递。这种设计让「ScrollView 吞掉滑动,但把点击留给 Button」成为可能。
系统手势的优先级——SpringBoard 层必须在 UIKit 层之前过滤(否则用户可能在 App 上方划出控制中心),这要求分层拦截架构。
反向验证:如果不分层会怎样?参考早期 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
}
}
自定义手势的核心规则:
- 必须手动设置
state——不设state = .began,target-action 永远不触发。 state一旦设为.failed/.cancelled/.ended,不可再变。reset()在每次状态回到.possible时自动调用——在这里清空临时数据。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 的性能本质。