若要让下拉标签更改时平滑换行,请测量每个控件的理想尺寸,使用统一的换行算法放置控件,并在容器上为选择变化添加动画。本教程使用合成选项构建一个小型 macOS 控制面板。
将换行与动画分开
读者的需求很明确:构建一个桌面面板,选择较长的下拉标签时,相邻控件可以移到另一行。控件在位置变化时应保留自身身份,并且面板应向周围界面报告新的高度。
这里有两个独立的职责。自定义 Layout 用于计算目标尺寸和位置。动画则告知 SwiftUI 如何从旧几何状态过渡到新状态。Apple 将 Layout 记为扩展点,用于实现比内置容器行为更复杂的排列方式。它的两个必需方法分别报告容器尺寸并放置子视图。Layout
在本例中,每个下拉控件都是一个 Menu,其操作会更新选择项。Apple 支持使用自定义标签构建菜单,因此显示的标签可以同时包含类别和所选选项。布局会排列窗口中关闭状态的控件;本例不会尝试控制菜单展开内容的几何状态。Menu
设计采用三项明确策略:
- 控件保留理想尺寸,而不是压缩标签来维持同一行。
- 当下一个控件会超出建议宽度时,控件按现有顺序换行。
- 宽于可用宽度的控件单独占一行,并使布局报告这个更宽的尺寸。
最后一项策略很重要。对一组控件换行,并不能让某个不可拆分的控件变窄。如果应用必须让任意长的标签都适应狭窄面板,还需要单独制定标签策略,例如设计一个有意限制宽度的标签。Apple 指出,fixedSize() 会保留理想尺寸,因此视图可能超出父视图边界。fixedSize()
测量和放置使用同一套计算
SwiftUI 的布局系统会沿视图层级向下传递尺寸建议。尺寸未指定的建议表示请求视图的理想尺寸;它不同于包含零或无穷大的建议。在这里,理想尺寸测量值决定每个控件占用的空间,而父视图建议的宽度决定行的断点。ProposedViewSize
关键在于保持一致:容器预留的空间必须与放置算法使用的空间相同。Apple 明确要求尺寸计算和放置计算保持一致,包括间距以及对尺寸建议的响应。它还规定,放置边界使用父视图的坐标空间,因此放置时必须考虑 bounds.minX 和 bounds.minY。placeSubviews(in:proposal:subviews:cache:)
在 Xcode 中创建一个 macOS SwiftUI 应用,并将部署目标设为 macOS 14 或更高版本。本例使用 macOS 13 起可用的 Layout,以及 macOS 14 起可用的 geometryGroup()。将下面的布局声明和下一节中的应用代码放入同一个 Swift 文件,替换生成的应用入口。两个代码块组合后就是完整示例。Layout availability、geometryGroup() availability
import SwiftUI
struct WrappingControls: Layout {
var spacing: CGFloat = 8
struct Plan {
var size: CGSize
var origins: [CGPoint]
}
func plan(
proposal: ProposedViewSize,
subviews: Subviews
) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, origins: [])
}
let gap = max(0, spacing)
let limit: CGFloat
if let width = proposal.width, width.isFinite {
limit = max(0, width)
} else {
limit = .infinity
}
var origins: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
var rowHasItems = false
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let nextX = rowHasItems ? x + gap : 0
if rowHasItems && nextX + size.width > limit {
y += rowHeight + gap
x = 0
rowHeight = 0
rowHasItems = false
}
if rowHasItems {
x += gap
}
origins.append(CGPoint(x: x, y: y))
x += size.width
rowHeight = max(rowHeight, size.height)
usedWidth = max(usedWidth, x)
rowHasItems = true
}
let width = limit.isFinite ? max(limit, usedWidth) : usedWidth
return Plan(
size: CGSize(width: width, height: y + rowHeight),
origins: origins
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
plan(proposal: proposal, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let result = plan(proposal: proposal, subviews: subviews)
for (index, subview) in subviews.enumerated() {
let origin = result.origins[index]
subview.place(
at: CGPoint(
x: bounds.minX + origin.x,
y: bounds.minY + origin.y
),
anchor: .topLeading,
proposal: .unspecified
)
}
}
}
两种布局方法都会调用 plan,并使用 SwiftUI 提供的尺寸建议。测量和放置也都使用相同的子视图建议 .unspecified。Apple 的子视图测量 API 会返回子视图针对给定建议选择的尺寸;本例使用该响应,而不是根据字符数量估算宽度。sizeThatFits(_:)
对于有限的建议宽度,布局至少填满该宽度,并如实报告任何超宽子视图。对于未指定或无限的宽度,它会为这些有限尺寸的控件返回有限的单行理想尺寸。高度由内容决定;布局会有意忽略建议高度,并报告行所需的空间。
此实现使用 Void 缓存并重新计算小型布局方案。Apple 建议只有在性能分析显示有益时才添加缓存。如果之后缓存测量结果,仍须确保重新创建缓存不会改变布局结果。makeCache(subviews:)
将变化的选择关联到稳定的控件
这个合成面板有三个类别:Shape、Pattern 和 Arrangement。每个类别都持有自己的选中字符串,所有选项都在示例中定义。标签会在现有 Menu 中变化;程序不会在选择变化时替换菜单。
将这段代码追加到布局声明之后。项目中只保留一个 @main 应用声明。
struct ControlChoice: Identifiable, Equatable {
let id: Int
let title: String
let options: [String]
var selected: String
}
struct DropdownPanel: View {
@State var choices: [ControlChoice] = [
ControlChoice(
id: 1,
title: "Shape",
options: ["Circle", "Rounded rectangle"],
selected: "Circle"
),
ControlChoice(
id: 2,
title: "Pattern",
options: ["Solid", "Alternating diagonal lines"],
selected: "Solid"
),
ControlChoice(
id: 3,
title: "Arrangement",
options: ["Rows", "Evenly spaced columns"],
selected: "Rows"
)
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Layout options")
.font(.headline)
WrappingControls(spacing: 8) {
ForEach(choices) { choice in
Menu {
ForEach(choice.options, id: \.self) { option in
Button(option) {
select(option, for: choice.id)
}
}
} label: {
Text("\(choice.title): \(choice.selected)")
.lineLimit(1)
}
.fixedSize()
.geometryGroup()
}
}
.animation(
.easeInOut(duration: 0.25),
value: choices.map(\.selected)
)
Text("Choose a longer option, then narrow the window.")
.foregroundStyle(.secondary)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 320, minHeight: 240)
}
func select(_ option: String, for id: Int) {
guard let index = choices.firstIndex(where: { $0.id == id }),
choices[index].options.contains(option) else {
return
}
choices[index].selected = option
}
}
@main
struct RewrappingMenusApp: App {
var body: some Scene {
WindowGroup {
DropdownPanel()
}
}
}
动画修饰符会观察一个选中字符串数组。Apple 要求被观察的值遵循 Equatable;此数组满足该要求。观察选择值也意味着,窗口宽度变化本身不会触发此修饰符请求新动画。布局仍会在尺寸建议变化时重新计算换行。animation(_:value:)
稳定的类别 ID 会在更新期间关联每个菜单。ForEach 根据带标识的数据构建视图,而更改视图的显式 .id 会重置其身份和关联状态。因此,添加 .id(choice.selected) 会破坏本例所需的连续性。每个合成类别中的选项字符串都是唯一的,因此 id: \.self 适用于这些菜单操作。ForEach、id(_:)
Apple 的自定义布局示例表明,保留结构身份可以让 animation(_:value:) 为布局变化添加动画。本例将这一原则用于同一种布局类型中不断变化的测量结果。选中字符串会立即变化;预期的动画是由此产生的移动和尺寸变化,而不是在两个标签的字符之间进行插值。Composing custom layouts with SwiftUI
geometryGroup() 修饰符为每个完整菜单提供了几何边界。Apple 对这一边界的说明是:先解析并为父视图几何状态添加动画,再将其传递给后代视图。在这里使用它,是遵循这一文档所述机制,让每个控件的内部内容一起移动;它不会决定行的断点,也无法修正错误的换行计算。geometryGroup()
调整动画前先检查行断点
一个简单的示例有助于检查换行策略。以下尺寸仅用于说明,并非 macOS 控件的实际测量值:假设有三个控件,宽度分别为 80、100 和 90 点,高度均为 24 点,间距为 8 点,可用空间为 300 点。
初始行需要 286 点:80 + 8 + 100 + 8 + 90。如果中间的控件增宽到 150 点,整行就需要 336 点。前两个控件仍能放下,共占 238 点,因此第三个控件会移到下一行。新的内容高度为 56 点:24 + 8 + 24。
| 条件 | 此算法的预期结果 |
|---|---|
| 总宽度恰好等于限制 | 控件保留在该行。 |
| 下一个控件会超出限制 | 在该控件之前开始新的一行。 |
| 控件高度不同 | 按已完成行中最高控件的高度递进。 |
| 某个控件超出建议宽度 | 将它单独放置,并报告所需的更宽尺寸。 |
| 宽度未指定或为无限 | 按控件合计的理想宽度返回单行布局。 |
| 没有控件 | 返回零尺寸。 |
我使用确定性的合成尺寸单独检查了布局计算,其中包括较长标签、超宽控件和未指定宽度的情况。此环境中没有编译或进行 SwiftUI 应用的视觉测试。请在 macOS 上构建并运行,以验证原生控件的外观和动画。
运行应用后,先在保持窗口宽度不变的情况下选择较长的选项。调整窗口,直到某次选择变化跨越行断点,然后在短选项和长选项之间交替切换。确认控件下方的说明文字会移动,为新的行高腾出空间;短标签回来后,多余的垂直空间也会消失。
接下来,在选择不变的情况下调整窗口大小。这有助于区分换行缺陷和动画缺陷:最终位置错误表明问题出在测量或放置,而最终位置正确但移动效果不理想,则应检查动画作用范围和几何分组。
诊断其他可能的问题
如果标签发生变化但行断点没有变化,请检查布局实际测量到的内容。每个菜单外面固定宽度的包装视图,可能会导致短标签和长标签报告相同的外部宽度。在此实现中,每个菜单都会有意保留其理想尺寸,并且布局测量的是完整控件,而不是单独估算的文本。fixedSize()、sizeThatFits(_:)
如果控件相互重叠,或下一个区域遮住第二行,请先比较尺寸计算和放置过程。两处都使用相同的间距、子视图建议、换行条件和行高计算。不要用一个宽度计算尺寸,又用另一个宽度进行放置。共享的 plan 函数就是为了降低引入此类差异的可能性。placeSubviews(in:proposal:subviews:cache:)
如果控件在选择变化时消失又重新出现,请先检查身份,再添加更多动画修饰符。保持类别 ID 稳定,只更新其选择项。Apple 文档指出,更改显式视图 ID 会重置视图身份,因此用变化的标签作为持久控件的身份键并不合适。id(_:)
如果部署目标是 macOS 13,请移除 .geometryGroup(),保留 Layout 实现和由选择驱动的动画。这样可以保留换行方法,但会移除 macOS 14 起可用的文档所述几何边界;请在支持的系统上评估最终的移动效果。Layout、geometryGroup()
最终效果基于三项具体决策:测量实际控件、用同一行布局方案计算报告尺寸并放置控件,以及在标签变化时保留控件身份。在这些决策产生正确的最终几何状态后,由选择驱动的动画就能让换行过程更易于观察。
来源
- Layout — Apple 开发者文档。
- ProposedViewSize — Apple 开发者文档。
- placeSubviews(in:proposal:subviews:cache:) — Apple 开发者文档。
- sizeThatFits(_:) — Apple 开发者文档。
- makeCache(subviews:) — Apple 开发者文档。
- Menu — Apple 开发者文档。
- fixedSize() — Apple 开发者文档。
- animation(_:value:) — Apple 开发者文档。
- ForEach — Apple 开发者文档。
- id(_:) — Apple 开发者文档。
- Composing custom layouts with SwiftUI — Apple 开发者文档。
- geometryGroup() — Apple 开发者文档。