WWDC Quick Look 💓 By SwiftGGTeam
Dive into lazy stacks and scrolling with SwiftUI

Dive into lazy stacks and scrolling with SwiftUI

观看原视频

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 会把 StepDiagramStepInstructions 当作两个独立子视图加载
  • 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 在 sizeThatFitsplaceSubviews 中一次性完成测量和放置
  • 不会出现在视图显示后才二次调整布局的问题
  • 滚动位置保持稳定

核心启发

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 存储。配合 ScrollPositionscrollTo(id:) 实现跳转。入口:@Binding var readingProgress: Set<Chapter.ID> + scrollPosition.scrollTo(id:)

5. 复杂图文混排布局

做什么:当内置 Stack 无法满足图文环绕、固定比例等复杂布局时,用自定义 Layout 替代 onGeometryChange 的 hack。

为什么值得做:onGeometryChange 读取子视图高度后再调整其他视图布局,会导致视图显示后才二次改变自身高度,把下方内容往下推,影响滚动位置的稳定性。自定义 Layout 在 sizeThatFits 阶段就确定所有子视图位置,和 Lazy stack 配合更稳定。

怎么开始:实现 Layout 协议的 sizeThatFitsplaceSubviews 方法,一次性完成测量和放置。入口:struct StepLayout: Layout { func sizeThatFits(...) -> CGSize { ... } }

关联 Session

评论

GitHub Issues · utterances