要在 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 的测量值集合,而不是让一个标量代表多个视图。
如何验证测量结果保持独立?
将第一个程序用作一组简单的诊断步骤:
- 先使用
Blue circles和较宽裕的可用宽度。比较彩色徽标和读数。 - 拖动滑块。外层 frame 会独立变化;只要空间仍然充足,徽标就可以保持紧凑。
- 选择
Change sample label,然后缩小可用宽度。检查当前受约束的徽标,不要期待它显示整段字符串的理想宽度。 - 将观察器移到显式 frame 之后。此时它应该报告该 frame 的宽度。
- 在其他地方使用徽标测量值之前,将观察器移回 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 界面中已显示的标签,请在所需边界处观察其几何信息,保持读数独立,并测试内容和可用空间的变化。这样便能获得有用的宽度,同时保留你想检查的布局。
来源
onGeometryChange(for:of:action:) — Apple
简单视图的布局 — Apple
GeometryReader — Apple
为视图添加背景 — Apple
size — Apple
background(alignment:content:) — Apple
PreferenceKey — Apple
onPreferenceChange(_:perform:) — Apple
ViewThatFits — Apple
Layout — Apple
fixedSize(horizontal:vertical:) — Apple
字符串和字符 — Swift.org
size() — Apple
usedRect(for:) — Apple
ensureLayout(for:) — Apple