Highlight
SwiftUI 的 LazyVStack 和 LazyHStack 通过按需加载子视图和预取机制实现流畅滚动,但高度估算、视图解析规则和状态管理存在特定约束,开发者需要在数据层过滤、初始化器配置和避免动态视图数量等方面遵循最佳实践。
核心内容
想象你在做一个折纸教程 App,里面有几十步图文说明。如果用普通的 VStack,所有步骤会在页面打开时一次性加载,内存和渲染开销随步骤数量线性增长。LazyVStack 解决了这个问题:它只加载当前屏幕可见区域的视图,滚动时才逐步追加新内容,划出屏幕的视图会被移除。
(01:37)
这种按需加载带来了一个代价:屏幕外视图的高度是估算的。LazyVStack 根据已放置视图的平均高度和剩余视图数量来推算总高度,因此滚动过程中总高度可能动态调整。同样,LazyVStack 的理想宽度等于第一个子视图的宽度,因为它没有加载全部视图去计算最大宽度。
(02:42)
一个典型的场景是横竖屏切换。iPhone 从竖屏切到横屏后,StepView 的高度会变矮(副标题文字占用行数减少)。LazyVStack 会保持当前最上方可见视图锚定,但滚动回顶部时,它必须逐步修正上方区域的估算高度,同时同步更新 ScrollView 的 contentOffset,确保最终回到顶部时 offset 为零。
(04:02)
Lazy stack 支持嵌套。在折纸 App 底部加一个横向滚动的照片展示栏,用 LazyHStack 嵌套在 LazyVStack 里,用户不滚动照片区就不会触发内部视图的加载。但要注意:LazyHStack 的理想高度等于第一个子视图的高度,如果照片配有可变行数的描述文字,长文字会被截断。解决办法是固定高度,比如给文字设 lineLimit。
(05:12)
如果想让照片区更突出,可以把它放进 Section 并用 pinnedViews: [.sectionHeaders] 固定区头。滚动时区头会吸附在顶部。
(06:30)
滚动效果与可见性检测
给照片加 scrollTransition 可以实现进出屏幕的动画效果。但要小心:Lazy stack 根据视图的原始位置判断是否在屏幕内,如果 transform 把视图推出了原始 frame,Lazy stack 会认为它已经离屏而提前卸载,导致视图异常消失。
(07:04)
检测滚动位置时,不要用 onScrollGeometryChange 读取绝对 contentOffset。因为 Lazy stack 的 offset 是估算值,估算更新时按钮的显隐边界会漂移。应该用 onScrollTargetVisibilityChange,根据子视图的相对可见性来判断。
(08:20)
详细内容
视图解析:一个 View struct 不等于一个子视图
LazyVStack 加载的”子视图”不一定对应你代码里的 View struct。ForEach 会展开成多个 StepView,但如果 StepView 的 body 顶层返回两个视图且没有嵌套在 VStack 里,LazyVStack 会把这两个视图当作独立的子视图分别加载。
(09:29)
struct StepView: View {
let step: Step
var body: some View {
StepDiagram(/* ... */)
StepInstructions(/* ... */)
}
}
关键点:
StepView的 body 顶层有两个视图,没有VStack包裹- LazyVStack 会把
StepDiagram和StepInstructions当作两个独立子视图加载 StepView本身仍然需要被求值,但 LazyVStack 管理的是它展开后的子视图
更隐蔽的问题是动态视图数量。如果 StepView 根据环境值决定返回一个视图还是零个视图,LazyVStack 为了维护索引正确性,会把所有 StepView 保留在内存中,即使它们当前不可见。
(10:52)
struct StepView: View {
let step: Step
@Environment(\.detailLevel) var detailLevel
var body: some View {
if step.isVisible(in: detailLevel) {
VStack { /* ... */ }
}
}
}
关键点:
if条件导致 StepView 有时返回一个子视图,有时返回零个- LazyVStack 按索引寻址可见子视图,必须保留所有 StepView 实例以防
detailLevel变化后索引偏移 - 这些离屏视图还会因无关环境值(如
writingStyle)的变化触发不必要的 body 重算
正确的做法是在数据层过滤:
(12:15)
struct ContentView: View {
@Query var steps: [Step]
init(detailLevel: DetailLevel) {
_steps = Query(filter: #Predicate<Step> { step in
step.detailLevel >= detailLevel
})
}
var body: some View { /* ... */ }
}
关键点:
- 用 SwiftData 的
Query+Predicate在数据层过滤 - LazyVStack 直接拿到确定数量的子视图,不需要构造 View 去计算数量
- 无关环境值变化不会影响离屏视图
if let 解包 Optional 也有同样的问题。如果 apiToken 环境值从 nil 变成有值,所有 StepView 的索引都会变化。更好的做法是把认证状态交给上层视图处理,未登录时直接显示 ContentUnavailableView,不进入 Lazy stack。
(12:35)
预取机制与状态管理
Lazy stack 在滚动方向上会提前预取(prefetch)即将进入屏幕的视图。它会在帧空闲时间逐步完成视图的 body 求值和布局,把原本一帧内的大块工作拆到多帧,避免掉帧。
(13:19)
预取意味着视图的 body 可能在 onAppear 之前就被调用。如果用户反向滚动,预取过的视图甚至可能 body 被求值了但 onAppear 永远不会触发。因此不要把关键初始化逻辑放在 onAppear 里。
(15:53)
错误做法:
struct StepView: View {
let id: Step.ID
@State var viewModel = StepViewModel()
var body: some View {
VStack {
if let content = viewModel.content { /* ... */ }
}
.onAppear {
viewModel.configure(with: id)
}
}
}
关键点:
onAppear中配置 viewModel,视图在出现前处于未配置状态- 预取时做的布局工作会在
onAppear触发后被丢弃重算 - 滚动性能受影响,且可能加载比实际需要更多的视图
正确做法是在初始化器中配置:
(16:14)
struct StepView: View {
@State var viewModel: StepViewModel
init(id: Step.ID) {
_viewModel = State(initialValue: StepViewModel(id: id))
}
var body: some View { /* ... */ }
}
关键点:
- viewModel 在初始化时就配置好,预取时就能拿到正确数据
- 视图在出现前已经处于合理状态,预取的工作不会被浪费
异步加载也可以利用预取提前开始。比如从网络加载折纸图解:
(16:23)
struct StepView: View {
let step: Step
@State var diagram: Diagram?
var body: some View {
VStack { /* ... */ }
.task {
diagram = await DiagramLoader.loadDiagram(id: step.id)
}
}
}
如果想让加载更早开始,可以用 @Observable 的 loader 在初始化时触发:
(16:40)
struct StepView: View {
let step: Step
@State var diagramLoader: DiagramLoader
init(step: Step) {
self.step = step
_diagramLoader = State(initialValue: DiagramLoader(id: step.id))
}
var body: some View { /* ... */ }
}
@Observable
class DiagramLoader {
var diagram: Diagram?
init(id: Step.ID) {
Task {
diagram = await loadFromNetwork(id: id)
}
}
}
关键点:
DiagramLoader在初始化时就启动网络请求- 预取过程中 loader 已经开始加载,视图滑入屏幕时数据大概率已就绪
- 配合缓存可以进一步提升体验
视图滑出屏幕后不会立即从内存删除,Lazy stack 会保留一段时间以防快速滑回。但状态变量(@State)最终会被释放,不要把需要持久化的数据存在 View 的 state 里。
(17:16)
// 错误:高亮状态会在视图滑出后丢失
struct StepView: View {
let step: Step
@State var isHighlighted = false
// ...
}
// 正确:用 Binding 把状态提升到外层
struct ContentView: View {
@State var highlighted: Set<Step.ID> = []
// ...
}
struct StepView: View {
let step: Step
@Binding var highlighted: Set<Step.ID>
// ...
}
关键点:
@State随视图生命周期存在,视图被释放后状态丢失- 需要持久化的状态应放到 Model 对象或通过
@Binding提升到父视图
程序化滚动
SwiftUI 提供了 ScrollPosition 来程序化控制滚动位置,支持滚动到屏幕外的视图。
(17:58)
struct ContentView: View {
@State var scrollPosition = ScrollPosition()
var body: some View {
ScrollView { /* ... */ }
.scrollPosition($scrollPosition)
.overlay(alignment: .bottom) {
Button {
scrollToShowcase()
} label: { /* ... */ }
}
}
func scrollToShowcase() {
withAnimation {
scrollPosition.scrollTo(id: "showcase-header")
}
}
}
关键点:
ScrollPosition绑定到 ScrollView 上scrollTo(id:)可以直接跳转到指定 ID 的视图,即使它当前不在屏幕内- 配合
withAnimation实现平滑滚动动画
程序化滚动的性能取决于 Lazy stack 能否快速定位目标视图。如果每个 ForEach 元素总是解析为单个子视图,LazyVStack 可以直接查询 ForEach 找到目标 ID,不需要构造任何 View。如果存在动态视图数量,定位效率会下降。
避免滚动后改变布局
一个常见的反模式是用 onGeometryChange 读取子视图高度,再用这个值去调整其他视图的布局。这会导致视图出现后才改变自身高度,把下方内容往下推,影响滚动位置的稳定性。
(19:16)
// 不推荐:滚动后改变布局
struct StepView: View {
let step: Step
@State var subtitleHeight: CGFloat?
var body: some View {
VStack {
StepDiagram(diagram: step.diagram)
.frame(height: diagramHeight(subtitleHeight: subtitleHeight))
Title(step.title)
Subtitle(step.subtitle)
.onGeometryChange(for: CGFloat.self, of: \.size.height) { _, value in
subtitleHeight = value
}
}
}
}
如果 SwiftUI 内置布局无法满足图文环绕、固定比例等复杂需求,用自定义 Layout 替代 onGeometryChange 的 hack:
(19:17)
struct StepView: View {
let step: Step
var body: some View {
StepLayout {
StepDiagram(diagram: step.diagram)
Title(step.title)
Subtitle(step.subtitle)
}
}
}
struct StepLayout: Layout {
// 在 sizeThatFits 中一次性计算所有子视图尺寸和位置
// ...
}
关键点:
- 自定义 Layout 在
sizeThatFits和placeSubviews中一次性完成测量和放置 - 不会出现在视图显示后才二次调整布局的问题
- 滚动位置保持稳定
核心启发
1. 长列表照片浏览器
做什么:用 LazyVStack + Section + pinnedViews 做按日期分组的照片流,区头显示日期,滚动时吸附在顶部。横向嵌套 LazyHStack 做同一天的缩略图预览。
为什么值得做:照片数量动辄上千,普通 VStack 会一次性加载所有视图,内存和启动时间不可接受。Lazy stack 的按需加载让大相册也能流畅滚动,pinnedViews 让日期导航始终可见。
怎么开始:数据层用 SwiftData Query 按日期分组,避免在 View body 里做条件过滤。入口:LazyVStack(pinnedViews: [.sectionHeaders]) + @Query
2. 无限滚动内容流
做什么:在列表底部放一个 ProgressView,配合 onAppear 触发下一页加载。数据加载逻辑放在初始化器或 @Observable loader 中启动。
为什么值得做:Lazy stack 的预取机制会在用户接近底部时提前加载内容,但如果加载逻辑放在 onAppear 里,预取时 viewModel 还没配置,等用户真正滑到底时才开始加载,体验变成”滑到底 -> 等加载”。把初始化提前到构造器,预取和加载同步进行。
怎么开始:用 DiagramLoader 模式或初始化器配置 viewModel,配合 .task 启动异步加载。入口:init(step: Step) { _diagramLoader = State(initialValue: DiagramLoader(id: step.id)) }
3. 带滚动动画的卡片列表
做什么:给卡片加 scrollTransition 实现滑入时的缩放/旋转效果。
为什么值得做:滚动动画能提升列表的视觉质感,让内容切换更有节奏感。但 Lazy stack 根据视图的原始 frame 判断是否在屏幕内,如果 transform 把视图推出了原始边界,Lazy stack 会认为它已经离屏而提前卸载,导致视图异常消失。
怎么开始:用 scaleEffect(1 - abs(phase.value) * 0.1) 这类保持视图在原始边界内的效果。入口:.scrollTransition { effect, phase in effect.scaleEffect(1 - abs(phase.value) * 0.1) }
4. 智能阅读进度保存
做什么:做长文阅读 App 时,把每章节的阅读进度存到父视图或 Model 中,配合 ScrollPosition 实现”回到上次阅读位置”。
为什么值得做:@State 随视图生命周期存在,视图滑出屏幕被 Lazy stack 回收后状态就丢失了。如果用户读到第 10 章,滑回目录再点进来,进度归零。把状态提升到父视图或存到 SwiftData,才能持久化。
怎么开始:用 @Binding var readingProgress: Set<Chapter.ID> 把状态提升到父视图,或用 SwiftData Model 存储。配合 ScrollPosition 的 scrollTo(id:) 实现跳转。入口:@Binding var readingProgress: Set<Chapter.ID> + scrollPosition.scrollTo(id:)
5. 复杂图文混排布局
做什么:当内置 Stack 无法满足图文环绕、固定比例等复杂布局时,用自定义 Layout 替代 onGeometryChange 的 hack。
为什么值得做:onGeometryChange 读取子视图高度后再调整其他视图布局,会导致视图显示后才二次改变自身高度,把下方内容往下推,影响滚动位置的稳定性。自定义 Layout 在 sizeThatFits 阶段就确定所有子视图位置,和 Lazy stack 配合更稳定。
怎么开始:实现 Layout 协议的 sizeThatFits 和 placeSubviews 方法,一次性完成测量和放置。入口:struct StepLayout: Layout { func sizeThatFits(...) -> CGSize { ... } }
关联 Session
- Code-along: Build powerful drag and drop in SwiftUI — SwiftUI 拖拽重排与 Lazy stack 的联动
- Compose custom layouts with SwiftUI — 自定义 Layout 替代几何变化回调
- Stacks, Grids, and Outlines in SwiftUI — SwiftUI 布局基础,本 session 的前置知识
- SwiftData — 在数据层用 Query 和 Predicate 过滤列表数据
评论
GitHub Issues · utterances