深入理解 SwiftUI 容器
Demystify SwiftUI containers
2024年6月10日
一句话判断
SwiftUI 今年开放了构建自定义容器视图的底层 API——ForEach(subviewOf:)、Group(subviewsOf:)、Section 支持,你的容器终于可以像 List 一样灵活地接受任意组合的内容。
这场 Session 讲了什么
SwiftUI 的 List、Table 等内置容器支持静态内容、动态数据(ForEach)、分组(Section)、以及容器级修饰符。但你自定义的容器之前做不到这些——只能接受固定类型的数据集合。
今年新增的 API 让你构建的容器拥有和 List 同等级别的能力。ForEach(subviewOf:) 遍历内容的所有 resolved subview,Group(subviewsOf:) 把子视图聚合为集合以获取数量等信息,Section 类型让你的容器支持分组。还有 ContainerValuePreferenceKey 让子视图向容器传递配置信息,实现类似 listRowSeparator(.hidden) 的容器级修饰符。
Session 用一个”展示板”(DisplayBoard)作为贯穿案例,从一个简单的数据驱动容器逐步进化为支持任意内容组合、分组、自定义修饰符的完整容器。
值得深挖的点
Declared Subview vs Resolved Subview:理解 SwiftUI 的内容解析
这是整场 Session 最核心的概念区分。你在代码中写出的视图叫做”declared subview”——比如三个 Text 加一个 ForEach,declared subview 数量是 4。但运行时实际出现在屏幕上的视图叫做”resolved subview”——如果 ForEach 里有 9 个元素,resolved subview 数量就是 12。
ForEach(subviewOf:) 遍历的是 resolved subview。这意味着无论内容是硬编码的 Text、动态生成的 ForEach、有条件的 if-else,还是嵌套的 Group,你的容器都能正确遍历所有最终会出现在屏幕上的视图。SwiftUI 帮你处理了所有解析逻辑。
理解这个区别对实现容器至关重要。如果你需要计算子视图数量来决定布局策略,必须用 Group(subviewsOf:) 对 resolved subview 计数,而不是对 declared subview 计数。Session 中的例子是:展示板在卡片超过 15 个时自动缩小尺寸,这个判断必须基于 resolved 数量。
ContainerValuePreferenceKey:子视图向容器”汇报”的通道
SwiftUI 的 preference key 机制一直存在,但 ContainerValuePreferenceKey 是专门为容器场景设计的。子视图通过 containerValue() 修饰符向容器传递值,容器在遍历 subview 时读取这些值。
这个机制让容器级修饰符成为可能。比如你可以定义一个 boardCardStyle 修饰符,子视图用它声明”我需要高亮样式”,容器在渲染时读取这个值决定如何绘制卡片。这和 listRowSeparator(.hidden) 的实现原理完全一样——子视图声明偏好,容器负责执行。
设计上的 trade-off:ContainerValue 是在视图解析阶段传递的,如果你需要在运行时动态改变容器行为,还需要配合 SwiftUI 的响应式更新机制。它不是万能的通信通道,而是专门解决”子视图向容器传递静态配置”这个特定问题。
代码片段
用 ForEach(subviewOf:) 支持任意内容组合
struct DisplayBoard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
CustomLayout {
// 遍历所有 resolved subview,转为卡片
ForEach(subviewOf: content) { subview in
CardView {
subview
}
}
}
}
}
// 使用时支持静态和动态内容混排
DisplayBoard {
Text("我自己的歌") // 静态
Text("另一首歌") // 静态
ForEach(samSongs) { song in // 动态
Text(song.title)
}
}
场景:自定义容器需要像 List 一样接受任意内容。坑:ForEach(subviewOf:) 的闭包参数类型是 Subview,不是 some View,需要通过 Subview.content 访问实际内容。
统计 resolved subview 数量
var body: some View {
CustomLayout {
// 用 Group 收集所有 resolved subview
Group(subviewsOf: content) { subviews in
let cardScale = subviews.count > 15 ? 0.7 : 1.0
ForEach(subviews) { subview in
CardView {
subview
}
.scaleEffect(cardScale)
}
}
}
}
场景:根据子视图数量动态调整布局策略。坑:Group(subviewsOf:) 中的 subviews 是 SubviewCollection,不能直接用下标访问,需要通过 ForEach 遍历。
定义容器级修饰符
// 1. 定义 ContainerValue key
struct HighlightKey: ContainerValuePreferenceKey {
static let defaultValue = false
}
// 2. 子视图使用修饰符
Text("重点歌曲")
.containerValue(\.highlight, true)
// 3. 容器读取值
ForEach(subviewOf: content) { subview in
let isHighlighted = subview.containerValue(\.highlight) ?? false
CardView(isHighlighted: isHighlighted) {
subview
}
}
场景:子视图向容器声明渲染偏好,类似 listRowSeparator。坑:ContainerValue 在 subview 解析时确定,运行时频繁变化的值不适合用这个机制。
最佳实践
新项目: 如果你需要构建可复用的容器组件(自定义列表、卡片网格、看板布局),直接使用新 API。从 ForEach(subviewOf:) 开始支持灵活内容,需要分组时加 Section 支持,需要子视图配置时用 ContainerValue。这套 API 是 SwiftUI 官方的容器构建方案,比自己解析 ViewBuilder 输出靠谱得多。
已有项目: 如果你之前用了 ViewBuilder + 手动遍历或其他 hack 方案构建容器,今年可以迁移到官方 API。迁移的优先级取决于你的容器复杂度——如果只是简单的数据驱动列表,当前的 ForEach 方案不需要改。如果你的容器已经支持 Section 或自定义修饰符(通过其他方式实现的),迁移到官方 API 可以减少维护负担。
还有什么值得关注
Subview类型是 SwiftUI 新引入的,代表 resolved 后的单个子视图,包含其内容和容器值。Section现在可以在自定义容器中识别,通过ForEach(sectionedSubgraphsOf:)遍历分组内容。SubviewCollection支持count、isEmpty等集合操作,以及直接传给ForEach遍历。