在 Mac 应用中显示一小行标签时,使用 ViewThatFits 选择能够放下的最长完整前缀,并将隐藏项目数量计入其中。本教程会构建这样一行内容,不截断标签,也不在布局期间更新状态。

具体任务是在可调整大小的 macOS 窗口中显示六个合成标签。保持它们的顺序,只显示完整标签,并为省略的后缀显示诸如 还剩 3 项 这样的徽标。此方案假设水平空间足以容纳仅显示数量的徽标,垂直空间足以容纳一行内容。

选择完整的行候选项

Apple 将 ViewThatFits 说明为:在受约束的轴上,选择第一个理想尺寸适合所提议尺寸的子视图。macOS 13 及更高版本支持它。此处使用 .horizontal,因为任务关注的是行宽;外围界面必须提供行所需的高度。ViewThatFits

对于六个标签,按以下顺序提供七个候选项:六个标签且不带徽标、五个标签加 还剩 1 项、四个标签加 还剩 2 项,依此类推,最后只显示 还剩 6 项。每个候选项都是一行完整内容。徽标也参与适配判断,因此添加数量不会意外挤掉最后一个可见标签。

这实现了前缀策略:如果第一个标签很长,即使后面的短标签能够放下,也可能导致所有标签都被省略。这是有意为之。如果你的任务允许跳过单个标签,请先定义不同的选择策略,再进行实现。

对每个候选行应用 fixedSize(),使其标签和徽标保持理想尺寸。Apple 说明,此修饰符可以让视图超出父视图的边界;它本身并不保证视图一定适合。此处由外层选择容器执行适配判断。fixedSize()

构建完整的 macOS 示例

在 Xcode 中创建一个 macOS SwiftUI 应用,并将部署目标设为 macOS 13 或更高版本。用以下单文件程序替换自动生成的应用声明和内容视图。确保目标中恰好有一个标记为 @main 的应用声明。

示例使用显式标识符,而不是用标签文本作为身份标识。Apple 的 ForEach 文档要求元素具有标识,或显式指定标识符键路径;不同的标识符也允许两个项目显示相同的标题。ForEach

import SwiftUI

struct DemoItem: Identifiable {
    let id: Int
    let title: String
}

struct ItemChip: View {
    let title: String

    var body: some View {
        Text(title)
            .lineLimit(1)
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.blue.opacity(0.12))
            )
    }
}

struct PrefixCandidate: View {
    let items: [DemoItem]
    let visibleCount: Int

    var body: some View {
        HStack(spacing: 8) {
            ForEach(items.prefix(visibleCount)) { item in
                ItemChip(title: item.title)
            }

            if visibleCount < items.count {
                Text("\(items.count - visibleCount) more")
                    .lineLimit(1)
                    .padding(.horizontal, 10)
                    .padding(.vertical, 6)
                    .background(
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.gray.opacity(0.15))
                    )
            }
        }
        .fixedSize()
    }
}

struct WholeItemRow: View {
    let items: [DemoItem]

    var body: some View {
        if items.isEmpty {
            Text("No items")
                .fixedSize()
        } else {
            ViewThatFits(in: .horizontal) {
                ForEach(
                    Array((0...items.count).reversed()),
                    id: \.self
                ) { visibleCount in
                    PrefixCandidate(
                        items: items,
                        visibleCount: visibleCount
                    )
                }
            }
        }
    }
}

struct ContentView: View {
    let items: [DemoItem] = [
        DemoItem(id: 1, title: "Sketch"),
        DemoItem(id: 2, title: "Color samples"),
        DemoItem(id: 3, title: "Shapes"),
        DemoItem(id: 4, title: "Paper textures"),
        DemoItem(id: 5, title: "Layout notes"),
        DemoItem(id: 6, title: "Preview")
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Sample labels")
                .font(.headline)

            WholeItemRow(items: items)
                .frame(maxWidth: .infinity, alignment: .leading)

            Text("Resize the window to change how many labels fit.")
                .foregroundStyle(.secondary)
        }
        .font(.system(size: 14))
        .padding(24)
        .frame(minWidth: 320, idealWidth: 680, minHeight: 160)
    }
}

@main
struct WholeItemsDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

候选项标识符表示可能显示的项目数量,从项目总数递减到零。在每个候选项中,项目标识符用于标识各个标签。每次迭代都会创建一个 PrefixCandidate,其中包含一行水平排列的内容。

外层的弹性框架让行接收可用的水平空间,同时使选定内容与前导边缘对齐。将 fixedSize() 保留在 ViewThatFits 内部的候选项上。若将其应用于整个选择容器,就会改变该容器对父视图宽度提议的响应方式。

这是一个完整的源代码示例,但此处未能编译或运行,因为没有可用的 Swift 工具链。下一节中的示例计算已单独核对;实际文本宽度仍需在 Mac 应用中验证。

计算适配宽度时将徽标纳入其中

实用的判断规则是:选择整个候选项都能放下的最大可见项目数量。隐藏数量始终等于项目总数减去该候选项中可见项目的数量。无需单独估算并同步数量。

考虑一行包含四个项目的示例,其宽度分别为 72、96、64 和 88 点,包含项目内边距。假设间距为 8 点,任何非零隐藏数量的徽标宽度均为 60 点。这些是虚构的计算输入,并非对示例应用的测量结果。

当四个项目全部可见时,所需宽度为 72 + 96 + 64 + 88 + 3 × 8 = 344 点。当三个项目可见时,徽标会取代被省略的后缀,因此宽度变为 72 + 96 + 64 + 3 × 8 + 60 = 316 点。候选项包含三个项目和一个徽标,因此需要三个间距。

可用宽度 可见项目 隐藏项目 选定候选项宽度
344 点 4 0 344 点
343 点 3 1 316 点
300 点 2 2 244 点
220 点 1 3 140 点
60 点 0 4 60 点

在程序中,每个候选项都包含实际的徽标文本。因此,即使徽标字符串的宽度不同,也无需依赖示例中固定宽度的假设。候选项宽度不必按固定数值递减;可见项目数量递减表达了选择偏好。

仅显示数量的候选项确定了最小可用宽度。如果连这个徽标都无法放下,那么按照这些规则,就不可能在可用宽度内显示完整数量。请提供足够的最小宽度、允许采用其他布局,或将数量移到别处。不要把裁切视为成功显示完整项目。

示例为内容宽度设置了最小值,并为简短的合成徽标指定了适中的字体。如果更改文案、字体、内边距或外围布局,请再次验证仅显示数量的情况。为此示例选定的最小宽度并非通用的徽标尺寸。

将布局选择与视图生命周期分开

隐藏数量属于每个候选项的不可变输入。程序不会在测量方法中写入绑定、安排异步数量更新,也不会根据外观回调推断哪些项目可见。选择某一行时,也会同时选择与其匹配的徽标。

保持重复的候选内容简单。在多个候选项中重复一个标签,并不意味着该标签在所有候选项中共用同一身份。Apple 说明,状态存储与视图身份相关联,身份变化可能会替换状态。Demystify SwiftUI

如果之后将标签替换为可编辑控件,请将它们共享的值保存在共同祖先视图或模型中,并向各个呈现视图传入相应输入或绑定。Apple 建议将共享状态放在需要它的视图的最近共同祖先中。不要依赖候选项本地状态在呈现方式改变后仍然保留。Managing user interface state

此方案会在选定行中显示隐藏数量,但不会将选定数量暴露给同级视图或应用模型。Apple 文档中的 ViewThatFits 初始化方法接受受约束的轴和候选内容,但不提供选定数量绑定。请将导出该值视为一项额外的设计要求。init(in:content:)

了解自定义布局何时需要不同的设计

当你需要专门的几何布局时,自定义 Layout 才合适。Apple 将其核心职责定义为报告组合尺寸,并通过布局代理指定子视图位置。这些代理提供测量和放置操作,而不能直接访问底层视图。Layout

不要仅仅在放下最后一个项目后停止放置,就实现省略行为。Apple 的 LayoutSubview 文档要求每个子视图都放置一次。跳过放置不是文档所述的隐藏机制,而且布局代理不提供通用的内容重写操作,无法将另一个子视图转换成新的数量标签。LayoutSubview

如果你构建自定义布局,请确保测量和放置保持一致,包括间距和使用的尺寸提议。Apple 明确要求这些计算保持一致。即使布局会探测多种可能的排列方式,这项约定仍然适用。placeSubviews(in:proposal:subviews:cache:)

这种候选项方案的适用范围有意设定得较小。对于六个项目,它会描述七行候选项,并在这些行中共出现 21 次项目。在一般情况下,项目出现次数按 n × (n + 1) / 2 增长。这只是结构上的数量统计,并非性能基准,也不表示每次更新都会渲染所有候选项。此方案适用于少量标签;若要扩展到大量项目或内容开销较大的情况,请先进行性能分析。

在边界情况下验证行布局

运行应用并缓慢调整窗口大小。在改造组件前,检查以下情况:

  • 窗口较宽时,所有标签都会显示,且不会出现 还剩 0 项 的徽标。
  • 窗口变窄时,标签会从末尾开始消失,不会出现部分标签或省略号。
  • 可见项目数量加上徽标中的隐藏数量,始终等于六。
  • 如果第一个标签特别长,仅显示数量的候选项仍然可用。
  • 数组为空时,该行显示 没有项目。
  • 标题重复但标识符不同的情况下,两个项目仍会计入数量。
  • 更改字体或徽标文案后,仅显示数量的候选项在支持的最小宽度下仍然适配。

对于一行数量较少且顺序固定的内容,完整候选项让适配规则一目了然:每个候选项都包含其标签、间距和准确的隐藏数量。明确设置最小宽度条件并让重复内容保持轻量后,这一行就能适应窗口大小变化,而不会出现不完整项目或单独的数量更新周期。

来源