Highlight
这场 Session 围绕 SwiftUI 布局能力的四个新工具展开。先是一个全新的 Grid 容器,填补了非懒加载二维布局的空白。之前我们只有 LazyHGrid/LazyVGrid,它们为了滚动性能牺牲了双向自动尺寸计算;新 Grid 则一次加载所有子视图,能自动算出行高和列宽,特别适合排行榜、设置面板这类内容固定的场景。
核心内容
SwiftUI 早就有 HStack、VStack 和 lazy grids。它们能解决大部分界面排列问题。
问题出现在更具体的场景里。
投票 App 的排行榜需要三列:名称、进度条、票数。名称列只要容纳最长名称,票数列只要容纳最大数字,进度条拿走剩余空间。这个界面内容固定,不需要滚动。lazy grid 为了只加载可见内容,不能同时自动测量行和列。
SwiftUI 2022 补上了这个空白。Grid 一次加载所有子视图,所以能同时计算行高和列宽。Layout 协议让你写自己的容器,直接参与 SwiftUI 的测量和放置流程。ViewThatFits 从多个候选视图里选择第一个能放下的版本。AnyLayout 在不同布局之间切换时保留视图身份,让动画连续。
这场演讲的例子很完整:排行榜用 Grid,投票按钮用等宽自定义 stack,大字号时用 ViewThatFits 切成竖排,顶部头像用 radial layout,再用 AnyLayout 在三方平局时切回 HStack。
详细内容
Grid:静态二维内容交给 SwiftUI 自动测量
(04:28)排行榜是典型的二维布局。每一行是一只宠物,每一列分别显示名称、投票比例和票数。
struct Leaderboard: View {
var pets: [Pet]
var totalVotes: Int
var body: some View {
Grid(alignment: .leading) {
ForEach(pets) { pet in
GridRow {
Text(pet.type)
ProgressView(
value: Double(pet.votes),
total: Double(totalVotes))
Text("\(pet.votes)")
.gridColumnAlignment(.trailing)
}
Divider()
}
}
.padding()
}
}
关键点:
Grid(alignment: .leading)设置整个网格的默认对齐方式。ForEach(pets)用数据生成多行内容,Pet在示例中实现了Identifiable。GridRow里的三个视图分别进入三列。ProgressView是 flexible view,会拿到文本列之外的剩余空间。Text("\(pet.votes)")显示票数。.gridColumnAlignment(.trailing)只影响票数所在的这一列,让数字右对齐。Divider()放在GridRow外面时会跨过整个网格宽度。
Grid 适合这里,是因为排行榜不是长列表。它可以测量所有 cell,自动得到每一列和每一行需要的尺寸。
Layout:把自定义容器写进布局引擎
(10:53)投票按钮还有另一个需求:每个按钮宽度相同,但宽度只等于最宽文字的理想宽度。HStack 会让按钮按自身文字宽度排列;给文字加 flexible frame 又会让按钮填满容器。
Layout 协议解决的是这个中间地带。
struct MyEqualWidthHStack: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
// Return a size.
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
// Place child views.
}
}
关键点:
MyEqualWidthHStack声明自己是一个 SwiftUI layout container。sizeThatFits负责回答容器想要多大。proposal是父容器给这个布局的尺寸建议。subviews是一组代理,用来向子视图提出尺寸建议并读取结果。cache可以在两个方法之间共享中间计算;这个简单示例暂时不用。placeSubviews负责把子视图放进bounds。bounds的 origin 不能假设是(0, 0),放置时要用bounds.minX、bounds.midY这类属性。
(13:44)等宽布局先测量每个按钮的理想尺寸,再找出最大宽度和最大高度。
private func maxSize(subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
关键点:
subviews.map遍历所有子视图代理。$0.sizeThatFits(.unspecified)向子视图询问理想尺寸。reduce(.zero)从零尺寸开始累积最大值。max(currentMax.width, subviewSize.width)找到最宽按钮。max(currentMax.height, subviewSize.height)找到最高按钮。- 返回的
maxSize会同时用于测量容器和放置子视图。
(15:40)间距不要随手写成固定值。SwiftUI 的子视图代理能提供系统推荐 spacing。
private func spacing(subviews: Subviews) -> [CGFloat] {
subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 }
return subviews[index].spacing.distance(
to: subviews[index + 1].spacing,
along: .horizontal)
}
}
关键点:
subviews.indices.map为每个子视图生成一个到下一个视图的间距。guard index < subviews.count - 1 else { return 0 }让最后一个视图后面没有额外间距。subviews[index].spacing读取当前视图的 spacing preference。distance(to:along:)同时考虑相邻两个视图在公共边上的偏好。.horizontal表示计算水平方向的间距。
(16:33)有了最大子视图尺寸和间距,sizeThatFits 就能返回容器尺寸。
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
// Return a size.
guard !subviews.isEmpty else { return .zero }
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }
return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
height: maxSize.height)
}
关键点:
- 空布局直接返回
.zero。 maxSize(subviews:)得到所有按钮共享的宽高。spacing(subviews:)得到相邻按钮之间的系统推荐间距。spacing.reduce(0) { $0 + $1 }求总间距。- 宽度等于最大按钮宽度乘以按钮数量,再加总间距。
- 高度等于最高按钮的高度。
(16:51)placeSubviews 用同样的尺寸,把每个按钮放到自己的中心点。
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
// Place child views.
guard !subviews.isEmpty else { return }
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
var x = bounds.minX + maxSize.width / 2
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: x, y: bounds.midY),
anchor: .center,
proposal: placementProposal)
x += maxSize.width + spacing[index]
}
}
关键点:
- 空布局不需要放置任何子视图。
placementProposal给每个按钮相同的宽高建议。bounds.minX + maxSize.width / 2得到第一个按钮中心点的 x 坐标。bounds.midY让按钮垂直居中。subviews[index].place把子视图放到指定点。anchor: .center表示at指向子视图中心。- 每轮循环后,
x前进一个按钮宽度和当前间距。
(18:07)使用自定义 layout 的方式和使用内置 stack 一样。
MyEqualWidthHStack {
ForEach($pets) { $pet in
Button {
pet.votes += 1
} label: {
Text(pet.type)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
关键点:
MyEqualWidthHStack包住一组投票按钮。ForEach($pets)遍历绑定数组,所以按钮能直接修改pet.votes。pet.votes += 1是投票动作。Text(pet.type)显示候选项名称。.frame(maxWidth: .infinity)允许文字视图扩展到 layout 给出的宽度。.buttonStyle(.bordered)使用系统 bordered button 样式。
这里也解释了为什么不推荐用 GeometryReader 反推按钮宽度。GeometryReader 测量的是它的容器,再把信息传给子视图;如果用它把测量结果传回上层 frame,可能绕开布局引擎并形成反复测量和重排。
ViewThatFits:为 Dynamic Type 准备备用布局
(21:08)大字号会让横向按钮放不下。这个时候,示例没有把 MyEqualWidthHStack 写得更复杂,而是给 ViewThatFits 两个候选布局。
struct StackedButtons: View {
@Binding var pets: [Pet]
var body: some View {
ViewThatFits {
MyEqualWidthHStack {
Buttons(pets: $pets)
}
MyEqualWidthVStack {
Buttons(pets: $pets)
}
}
}
}
关键点:
StackedButtons接收@Binding var pets,保持按钮可以修改投票数据。ViewThatFits会按顺序检查内部候选视图。- 第一个候选是横向等宽布局。
- 第二个候选是竖向等宽布局。
- 横向版本放不下时,SwiftUI 选择竖向版本。
Buttons(pets: $pets)把按钮内容抽成一个子视图,避免在两个候选布局里重复按钮代码。
这适合处理 Dynamic Type(动态字体)、窄屏和分屏。你提供几个清晰的布局版本,让 SwiftUI 在当前可用空间里选择。
LayoutValueKey:让子视图把布局数据交给容器
(22:52)顶部头像使用 radial layout。基础版本根据子视图数量,把每个头像放在圆周上。
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let radius = min(bounds.size.width, bounds.size.height) / 3.0
let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
let offset = 0 // This depends on rank...
for (index, subview) in subviews.enumerated() {
var point = CGPoint(x: 0, y: -radius)
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) + offset))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
}
关键点:
radius取布局区域宽高较小值的三分之一。angle按子视图数量把 360 度均分。- 初始点
CGPoint(x: 0, y: -radius)位于圆心正上方。 CGAffineTransform(rotationAngle:)根据索引旋转这个点。point.x += bounds.midX和point.y += bounds.midY把本地坐标移动到 layout 的中心。subview.place把头像放在计算出的圆周点上。
(23:42)要让圆形布局按排名旋转,layout 需要知道每个头像的 rank。LayoutValueKey 是这里的通信通道。
private struct Rank: LayoutValueKey {
static let defaultValue: Int = 1
}
extension View {
func rank(_ value: Int) -> some View {
layoutValue(key: Rank.self, value: value)
}
}
关键点:
Rank遵守LayoutValueKey。defaultValue给未设置 rank 的视图提供默认值。defaultValue也确定了这个 layout value 的类型是Int。extension View增加一个更好读的.rank(_:)修饰符。layoutValue(key:value:)把 rank 存到视图上,供 layout 的子视图代理读取。
(24:21)读取 rank 后,layout 就能按排名计算整体旋转偏移。
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let radius = min(bounds.size.width, bounds.size.height) / 3.0
let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
let ranks = subviews.map { subview in
subview[Rank.self]
}
let offset = getOffset(ranks)
for (index, subview) in subviews.enumerated() {
var point = CGPoint(x: 0, y: -radius)
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) + offset))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
}
关键点:
subviews.map遍历子视图代理。subview[Rank.self]读取每个子视图上的 rank。getOffset(ranks)根据排名数组计算旋转角度;演讲没有展开这个函数内部逻辑。- 放置循环与基础版本相同,只是 rotation angle 加上了
offset。 - 这样布局容器不需要访问业务模型,只读取每个子视图显式提供的布局值。
AnyLayout:切换布局时保留视图身份
(25:18)三方平局时,圆形布局无法通过旋转把三个头像排成一条线。示例用 AnyLayout 在 radial layout 和 HStackLayout 之间切换。
struct Profile: View {
var pets: [Pet]
var isThreeWayTie: Bool
var body: some View {
let layout = isThreeWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())
Podium() // Creates the background that shows ranks.
.overlay(alignment: .top) {
layout {
ForEach(pets) { pet in
Avatar(pet: pet)
.rank(rank(pet))
}
}
.animation(.default, value: pets)
}
}
}
关键点:
isThreeWayTie决定当前要使用哪种布局。AnyLayout(HStackLayout())把内置横向布局包装成同一种类型。AnyLayout(MyRadialLayout())把自定义 radial layout 也包装成同一种类型。let layout = ...让后面的 view hierarchy 保持结构一致。layout { ... }对同一组Avatar应用不同布局。.rank(rank(pet))把排名继续传给 radial layout。.animation(.default, value: pets)让投票数据变化时产生平滑过渡。
重点是视图身份。SwiftUI 看到的是同一组头像在改变布局,而不是一组旧头像消失、一组新头像出现。动画因此能连续。
核心启发
-
做什么:给设置页做一个自动测量的二维表格。 为什么值得做:
Grid能让标签列和数值列按内容自动取宽,开关、进度条等 flexible view 拿剩余空间。 怎么开始:用Grid(alignment:)和GridRow重写固定内容表格,对需要右对齐的列加.gridColumnAlignment(.trailing)。 -
做什么:把一组工具按钮做成等宽但不铺满窗口的控件。 为什么值得做:
Layout可以先测量所有按钮的理想尺寸,再用最宽按钮作为统一宽度。 怎么开始:实现一个Layout类型,在sizeThatFits中用.unspecified测子视图,在placeSubviews中用ProposedViewSize(width:height:)放置子视图。 -
做什么:为大字号和窄窗口提供自动切换的按钮排列。 为什么值得做:
ViewThatFits能先尝试横向布局,放不下时切到竖向布局,不需要把所有判断塞进一个自定义 layout。 怎么开始:把重复的按钮内容抽成Buttons子视图,再把横向和竖向两个 layout 放进ViewThatFits。 -
做什么:做一个根据业务状态旋转的头像、徽章或排名展示。 为什么值得做:
LayoutValueKey允许每个子视图向 layout 提供 rank、weight、priority 这类布局数据。 怎么开始:定义一个LayoutValueKey,给View加便捷修饰符,在placeSubviews中用subview[Key.self]读取。 -
做什么:在紧凑模式、展开模式和平局模式之间做连续布局动画。 为什么值得做:
AnyLayout切换布局类型时保留子视图身份,适合做状态驱动的界面重排。 怎么开始:把候选布局包装成AnyLayout,用同一个layout { ForEach(...) }承载内容,再对驱动状态加.animation。
关联 Session
- What’s new in SwiftUI — SwiftUI 2022 新能力总览,包含本场用到的布局能力。
- The SwiftUI cookbook for navigation — 继续用 SwiftUI 的声明式 API 组织 App 导航结构。
- SwiftUI on iPad: Organize your interface — 讲 iPad 上更大屏幕和多列界面的 SwiftUI 组织方式。
- The craft of SwiftUI API design: Progressive disclosure — 从 API 设计角度理解 SwiftUI 如何把简单入口和高级能力放在一起。
评论
GitHub Issues · utterances