若要让下拉标签更改时平滑换行,请测量每个控件的理想尺寸,使用统一的换行算法放置控件,并在容器上为选择变化添加动画。本教程使用合成选项构建一个小型 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()

最终效果基于三项具体决策:测量实际控件、用同一行布局方案计算报告尺寸并放置控件,以及在标签变化时保留控件身份。在这些决策产生正确的最终几何状态后,由选择驱动的动画就能让换行过程更易于观察。

来源