要在 Mac 应用中测量标签而不拉伸它,请将 onGeometryChange 附加到要检查的视图上。对于较早的部署目标,可在视图背景中放置 GeometryReader。修饰符的位置决定了你读取的是哪个宽度。

本指南测量一个合成标签(包括其内边距),同时确保其周围的布局不受测量结果影响。它还会说明如何区分标签的当前宽度、理想宽度和容器宽度。

为什么 GeometryReader 会让布局扩展?

SwiftUI 布局涉及提议和响应:父视图提议可用空间,子视图则报告其计算出的尺寸。添加 frame 会在视图层级中引入另一个视图,因此标签和它的 frame 可能具有不同的宽度。Apple 在简单视图的布局中解释了这一过程。

GeometryReader 是一个具有灵活首选尺寸的容器。因此,将紧凑的标签包在其中,会改变呈现给父视图的布局参与者。你测量的是读取器的容器,它占用的空间可能远大于文本所需的空间。请参阅 Apple 的 GeometryReader 文档。

要进行观察,请将测量附加到现有视图上。必须使用背景读取器时,放置位置很重要:Apple 说明,背景会接收其所附加视图的尺寸。这样,标签就能决定测量区域。请参阅为视图添加背景。

选择实现方式前,先确定界面需要回答哪个问题:

你的问题 合适的做法
这个标签在当前布局中的宽度是多少? 观察标签的几何信息。
标签周围的带内边距徽标有多宽? 在内边距之后进行观察。
包含它的行有多宽? 观察该行或它的 frame。
整段文本的理想宽度是多少? 单独检查理想尺寸。
多种呈现方式中哪一种适合? 考虑使用 ViewThatFits。
如何一起测量并放置多个子视图? 考虑使用自定义 Layout。

区分这些情况可以避免一个常见错误:把受约束、可能已截断的标签宽度,当成完整显示整段字符串所需的空间。

在 macOS 13 及更高版本中测量当前宽度

Apple 当前的文档列出,onGeometryChange(for:of:action:) 的单值 action 重载可用于 macOS 13 及更高版本。它会将几何代理转换为可等价比较的值,并在该值变化时调用 action。请使用包含此 API 的 SDK,并保留这里展示的单参数 action。请参阅 onGeometryChange(for:of:action:)。

在 Xcode 中创建一个使用 SwiftUI、部署目标为 macOS 13 或更高版本的 macOS 应用项目。将生成的 app 和 content-view 声明替换为以下完整程序,并确保目标中只保留一个 @main app 声明。此示例仅使用本地合成标签。

import SwiftUI
import Foundation

@main
struct WidthDemoApp: App {
    var body: some Scene {
        WindowGroup {
            WidthDemoView()
        }
    }
}

struct WidthDemoView: View {
    @State private var measuredWidth: CGFloat = 0
    @State private var offeredWidth: Double = 260
    @State private var showLongLabel = false

    private var label: String {
        showLongLabel
            ? "Sample collection of blue circles"
            : "Blue circles"
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(label)
                .font(.system(size: 16))
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(Color.blue.opacity(0.12))
                .onGeometryChange(for: CGFloat.self) { proxy in
                    proxy.size.width
                } action: { newWidth in
                    measuredWidth = newWidth
                }
                .frame(
                    width: CGFloat(offeredWidth),
                    alignment: .leading
                )

            Text(String(
                format: "Measured badge: %.1f pt",
                Double(measuredWidth)
            ))
            .monospacedDigit()
            .frame(width: 340, alignment: .leading)

            Slider(value: $offeredWidth, in: 120...340)

            Button("Change sample label") {
                showLongLabel.toggle()
            }
        }
        .frame(width: 360, alignment: .leading)
        .padding(20)
    }
}

观察器读取 proxy.size.width;Apple 将 GeometryProxy.size 定义为容器视图的尺寸。此处,被观察的视图包含文本和内边距,后续的 frame 则提供一个可独立控制的宽度提议。请参阅 GeometryProxy.size。

测量值会更新单独的读数,不会设置被测徽标的宽度。固定的演示区域和读数宽度便于检查结果,同时避免诊断文本调整周围堆栈的尺寸。

只返回 CGFloat 也能让观察专注于宽度:仅高度发生变化时,转换后的值不会改变。Apple 建议将几何信息转换为所需的特定数据,因为几何信息可能频繁变化。请参阅 onGeometryChange(for:of:action:)。

这些示例已根据检索到的 API 声明进行了检查。这里没有编译或运行它们,因为当前环境是没有 Swift 工具链的 Linux;请在 Mac 上使用 Xcode 构建并检查界面。

测量修饰符应该放在哪里?

将修饰符链视为一连串相互包裹的视图。测量应紧接在你希望计入的最后一个影响尺寸的修饰符之后。Apple 对布局的解释说明了 frame 如何包裹视图,以及内边距如何改变内容可用的空间。请参阅简单视图的布局。

示例中的测量位置 测量内容
两个内边距修饰符之前 文本视图在当前布局中的宽度
两个内边距修饰符之后 带内边距徽标当前的宽度
显式宽度 frame 之后 外层 frame 的宽度
周围的 VStack 上 堆栈的宽度

举例来说,假设文本视图在当前提议下报告的宽度为 96 点。左右两侧各增加 12 点内边距后,宽度变为 120 点。宽度为 260 点的外层 frame 则是另一个不同的测量值。这些数字用于说明修饰符的边界,并非示例字符串的实测结果。

若要排查意外的数值,可暂时在修饰符链中与观察器相同的位置添加边框。将该边界与预期目标进行比较。读取器附加到整行时,即使可见标签只占其中很小一部分,也可能正确报告整行的宽度。

在 macOS 12 部署目标中使用背景读取器

对于 macOS 12 部署目标,可使用背景 GeometryReader,通过 preference 发布宽度。下方使用的尾随闭包 background API 从 macOS 12 起可用。Apple 建议在修改后的视图应主导布局时使用背景。请参阅 background(alignment:content:)。

这是一个完整的替代程序。请用它替换前面的示例,而不要再添加第二个 app 声明。

import SwiftUI
import Foundation

private struct BadgeWidthKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }

    static func reduce(
        value: inout CGFloat,
        nextValue: () -> CGFloat
    ) {
        value = nextValue()
    }
}

@main
struct LegacyWidthDemoApp: App {
    var body: some Scene {
        WindowGroup {
            LegacyWidthDemoView()
        }
    }
}

struct LegacyWidthDemoView: View {
    @State private var measuredWidth: CGFloat = 0
    @State private var showLongLabel = false

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(showLongLabel ? "Blue circles collection" : "Blue circles")
                .font(.system(size: 16))
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(Color.blue.opacity(0.12))
                .background {
                    GeometryReader { proxy in
                        Color.clear.preference(
                            key: BadgeWidthKey.self,
                            value: proxy.size.width
                        )
                    }
                }
                .onPreferenceChange(BadgeWidthKey.self) { newWidth in
                    measuredWidth = newWidth
                }

            Text(String(
                format: "Measured badge: %.1f pt",
                Double(measuredWidth)
            ))
            .frame(width: 340, alignment: .leading)

            Button("Change sample label") {
                showLongLabel.toggle()
            }
        }
        .frame(width: 360, alignment: .leading)
        .padding(20)
    }
}

PreferenceKey 会将值传递给祖先视图,而 onPreferenceChange 会接收该值的变化。读取器负责发出测量值,而不是在其内容闭包中赋值给 state。请参阅 Apple 的 PreferenceKey 和 onPreferenceChange(_:perform:) 文档。

此归约器特意假设只有一个徽标发出值。若多个子视图复用同一个 key,它们的值会被合并,而此实现会保留归约后的最后一个值。若要分别跟踪多个标签,应设计带 key 的测量值集合,而不是让一个标量代表多个视图。

如何验证测量结果保持独立?

将第一个程序用作一组简单的诊断步骤:

  1. 先使用 Blue circles 和较宽裕的可用宽度。比较彩色徽标和读数。
  2. 拖动滑块。外层 frame 会独立变化;只要空间仍然充足,徽标就可以保持紧凑。
  3. 选择 Change sample label,然后缩小可用宽度。检查当前受约束的徽标,不要期待它显示整段字符串的理想宽度。
  4. 将观察器移到显式 frame 之后。此时它应该报告该 frame 的宽度。
  5. 在其他地方使用徽标测量值之前,将观察器移回 frame 之前。

不要把观察到的宽度用于计算同一视图的宽度。例如,将它的 frame 设为测得宽度加内边距,可能会不断将更大的结果反馈到布局中。这直接源于你创建的依赖关系:观察会改变 state,state 会改变尺寸,而新尺寸又会产生另一次观察。

如果实际任务是在完整标签和紧凑替代项之间作出选择,macOS 13 及更高版本中的 ViewThatFits 可以选择第一个理想尺寸适合受约束轴的子视图。这样可能就不必存储宽度。请参阅 ViewThatFits。

如果需要一起测量并放置多个子视图,可在 macOS 13 及更高版本中使用自定义 Layout。它的测量和放置方法允许容器在定位子视图之前查询它们,从而将这一决策保留在布局过程中。请参阅 Layout。

理想尺寸或 AppKit 文本度量能回答同一个问题吗?

仅当你打算保留文本的理想宽度时,才使用 .fixedSize(horizontal: true, vertical: false)。Apple 文档指出,这可能使文本超出父视图的边界。为了获得测量值而添加它,会改变所显示视图的尺寸行为,因此无法保留原有的受约束布局。请参阅 fixedSize(horizontal:vertical:)。

字符数也不适合作为宽度测量。Swift 的 String.count 统计由扩展字素簇表示的 Character 值,不会返回排版尺寸。将此计数乘以猜测的字符宽度,并不能测量 SwiftUI 视图。请参阅 字符串和字符。

对于 AppKit 绘制任务,NSAttributedString.size() 会返回绘制该属性字符串所需的尺寸,其中可能包含小数尺寸。Apple 建议使用此结果设置视图尺寸时向上取整。这回答的是文本绘制相关的问题,不包含 SwiftUI 视图的内边距或外层 frame。请参阅 size()。

对于现有的 TextKit 1 布局,NSLayoutManager.usedRect(for:) 会报告文本容器中当前已布局字形所占用的区域。它本身不会生成字形或执行布局;如果必须先完成布局,请调用 ensureLayout(for:)。这些 API 测量的是已配置的文本容器,因此应将结果与 SwiftUI 几何信息分开处理。请参阅 usedRect(for:) 和 ensureLayout(for:)。

对于 SwiftUI Mac 界面中已显示的标签,请在所需边界处观察其几何信息,保持读数独立,并测试内容和可用空间的变化。这样便能获得有用的宽度,同时保留你想检查的布局。

来源