ScrollView 与 SwiftUI 中的坐标空间
Source: Dev.to
介绍
- 折叠标题
- 视差效果
- 粘性工具栏
- 区段固定
- 滚动驱动的动画
- 下拉刷新逻辑
- 无限列表
然而,大多数 SwiftUI 开发者把 ScrollView 当作黑盒,这会导致:
- 动画卡顿
- 偏移不正确
- 几何计算错误
- 神秘数字
- 脆弱的 hack
缺失的关键是 坐标空间。
本文解释了 SwiftUI 中滚动的实际工作原理,坐标空间如何交互,以及如何构建 可靠、可投入生产的滚动驱动 UI。
🧠 心智模型:滚动只是几何
SwiftUI 的滚动并不特殊——它只是 视图在坐标空间中随时间移动。
一旦你了解 视图认为自己所在的位置,滚动效果就变得可预测。
📐 1. 什么是坐标空间?
坐标空间定义了 (0,0) 所在的位置。SwiftUI 提供了三种主要类型:
1️⃣ Local
相对于视图本身。
geo.frame(in: .local)
2️⃣ Global
相对于整个屏幕 / 窗口。
geo.frame(in: .global)
3️⃣ Named
你自定义的引用。
// Define the space
.coordinateSpace(name: "scroll")
// Read from it
geo.frame(in: .named("scroll"))
大多数滚动错误都源于使用了错误的坐标空间。
🧱 2. ScrollView 创建移动坐标系
在 ScrollView 中:
- 内容会移动,
- 几何值会变化,
- 坐标原点会移动。
ScrollView {
GeometryReader { geo in
Text("Offset: \(geo.frame(in: .global).minY)")
}
.frame(height: 40)
}
当你滚动时,minY 会持续更新——这就是你的滚动偏移量。
🧭 3. 为什么命名坐标空间很重要
.global 能工作……但有时会失效。.global 的问题包括:
- 在表格中失效,
- 在导航堆栈中失效,
- 在分割视图中失效,
- 在 macOS / iPad 上失效。
正确的写法
ScrollView {
// content
}
.coordinateSpace(name: "scroll")
相对于该空间读取几何信息:
geo.frame(in: .named("scroll")).minY
这可以让你的计算在任何地方都保持稳定。
📦 4. 清晰的滚动偏移模式(生产级)
使用零高度的 GeometryReader 通过偏好键发布偏移量。
ScrollView {
GeometryReader { geo in
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: geo.frame(in: .named("scroll")).minY
)
}
.frame(height: 0) // keep it out of layout
// …your scrollable content…
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
scrollOffset = offset
}
偏好键
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
为什么使用这种模式?
- 在各种容器中表现稳定,
- 可复用,
- 动画友好,
- 适合生产环境。
🧩 5. 构建滚动驱动的效果
一旦你拥有 scrollOffset,一切都变成了数学。
折叠标题
let progress = max(0, min(1, 1 - scrollOffset / 120))
视差
.offset(y: -scrollOffset * 0.3)
渐隐
.opacity(1 - min(1, scrollOffset / 80))
缩放
.scaleEffect(max(0.8, 1 - scrollOffset / 400))
指南
- 限制(clamp)数值,
- 保持单调(monotonic),
- 使其可预测。
📌 6. Sticky Headers Without Hacks
SwiftUI 已经提供了固定标题:
LazyVStack(pinnedViews: [.sectionHeaders]) {
Section(header: HeaderView()) {
rows
}
}
仅在标题需要以下情况时才使用 geometry:
- 动画,
- 形变,
- 淡入或缩放。
不要重新发明固定逻辑。
⚠️ 7. GeometryReader 在 ScrollView 中的陷阱
常见错误
- 将整个滚动内容包裹在
GeometryReader中, - 在每一行内部放置
GeometryReader, - 在滚动时进行繁重的布局工作,
- 嵌套多个
GeometryReader。
规则
- 将
GeometryReader的作用范围保持尽可能小。 - 只读取一次几何信息并向上层传播该值。
- 切勿在每帧执行耗时操作。
📱 8. ScrollView + Lists + Performance
List 已经对其行进行虚拟化。如果需要在列表内部获取几何信息:
- 避免在每一行使用
GeometryReader, - 只读取一次滚动偏移(如上所示),
- 从全局状态派生行的效果。
Performance hinges on:
- 布局的稳定性,
- 最小化视图失效,
- 每帧计算的开销低。
🧠 9. 复杂布局中的坐标空间
使用具名空间来消除歧义:
.coordinateSpace(name: "root")
.coordinateSpace(name: "scroll")
.coordinateSpace(name: "card")
有意进行测量:
geo.frame(in: .named("card"))
这消除了脆弱的假设。
🔁 10. 滚动恢复与状态
滚动位置不会自动保留。如果需要恢复它:
- 将偏移量存储在应用状态中。
- 使用
ScrollViewReader进行恢复。
ScrollViewReader { proxy in
proxy.scrollTo(id, anchor: .top)
}
- 避免在动画进行时恢复。
- 谨慎使用。
🧠 心智模型速查表
自问:
- 我处于哪个坐标空间?
- 相对于该空间,
(0,0)位于何处? - 我是否需要一个具名空间来进行稳定的数学运算?
理解这些问题可以帮助你构建滚动驱动的 UI,使其:
- 可预测的,
- 可复用的,
- 并且可投入生产的。
- What moves during scrolling?
- What stays fixed?
- Am I measuring the right thing?
If geometry feels “random”, one of these is wrong.
🚀 Final Thoughts
Scroll‑driven UI in SwiftUI is not magic.
它是:
- 几何
- 坐标空间
- 稳定的数学
- 有意的测量
一旦你理解了这些,就可以构建:
- 折叠标题
- 视差效果
- 基于滚动的动画
- 自适应布局
- 精致的、Apple 级别的 UI