移除从测量尺寸回到产生该尺寸的 frame 的依赖路径,即可停止 SwiftUI 几何循环。对于包含多个标签且这些标签宽度相同的 Mac 窗口,请在 Layout 容器内计算它们的尺寸,而不要将测量值存储在状态中。
本指南会在你自己的应用窗口中构建一个小型标签面板。标签使用本地合成文本,共享相同宽度,并会在窗口变窄时换行。完整示例面向 macOS 13 或更高版本;后续章节会说明,当界面包含 AppKit 文本视图时,如何采用同样的尺寸处理原则。
几何测量如何变成布局循环?
SwiftUI 布局涉及提议和响应:父视图提议一个尺寸,子视图报告自己选择的尺寸。自定义容器通过 ProposedViewSize 参与这种协商。因此,反复测量视图是正常的;仅凭多次测量并不能说明存在问题。请参阅 Apple 的 ProposedViewSize 文档。
当测量结果改变了被测布局的输入时,就会出现有问题的依赖关系。例如,标签报告其带内边距的宽度,某个操作将该宽度存储起来,周围的 frame 再使用存储的值并额外添加内边距。由此产生的 frame 会改变下一次测量结果。Apple 明确介绍了这种从 geometry reader 回馈到 frame 的循环,并建议对此类排列使用布局系统。请参阅 使用 SwiftUI 组合自定义布局。
简单计算就能看出,等值检查可能无济于事。假设某个示例实现测量上一次 frame 的宽度加上 16 点内边距,再将结果设为下一次 frame 的宽度。从 120 点开始,结果会依次变为 136、152 和 168 点。每次结果都不同于上一次,因此防止赋入未变化数值的检查无法阻止它持续增长。这些数字仅用于说明依赖关系,并非来自运行中的应用测量值。
在选择修复方案前,先追踪你自己代码中的依赖关系:
- 找到 geometry reader、偏好值处理程序或几何变化操作。
- 找出所有接收其测量结果的状态值。
- 找到依赖这些值的 frame、内边距、字体选择或条件视图。
- 检查这些选择是否会影响被测视图或其祖先视图。
- 暂时将测量值替换为常量。如果反复变化停止了,先检查这条依赖路径。
改变测量值的传递方式并不一定能消除循环。偏好值仍可能将相同尺寸传回同一个祖先视图。延后状态赋值可能只是将反馈移到后续更新中。关键问题在于:下一次测量是否仍取决于上一次结果。
应该使用哪种尺寸调整机制?
根据需要做出的决定来选择机制。先考虑内置布局容器;Apple 将自定义 Layout 实现描述为需要更多控制时的途径。请参阅 Layout 文档。
| 所需结果 | 支持的方案 | 关键条件 |
|---|---|---|
| 垂直排列普通标签 | 内置堆栈 | 让子视图参与正常的尺寸调整。 |
| 选择宽屏或紧凑呈现方式 | ViewThatFits |
按优先顺序提供备选方案。 |
| 让多个标签共享一个基于内容的宽度 | 自定义 Layout |
在布局方法中测量和放置子视图。 |
| 响应现有视图的几何信息 | onGeometryChange |
仅观察操作所需的值。 |
| 调整嵌入式 AppKit 视图的尺寸 | NSViewRepresentable.sizeThatFits |
根据提议返回首选尺寸。 |
ViewThatFits 会在受约束的轴上,选择第一个理想尺寸能够放下的子视图。如果实际需求是在不同呈现方式之间进行选择,例如水平排列和垂直排列的备用方案,这种方式就很有用。这样可以省去单独存储测量宽度的状态变量。请参阅 Apple 的 ViewThatFits 文档。
对于下方的共享宽度标签面板,尺寸规则很明确:取理想标签宽度中的最大值,并将其限制在有限的提议宽度之内。然后在选定宽度下测量每个标签的高度。这样,窗口变窄时,换行会增加面板高度,而几何测量值无需经由应用状态传递。
构建无需几何状态的共享宽度标签面板
在 Xcode 中创建一个使用 SwiftUI 的 macOS 应用项目,将部署目标设为 macOS 13 或更高版本,然后用下面这个完整的单文件示例替换生成的应用和内容视图声明。目标中只保留一个 @main 应用声明。本文已根据文档中的 API 检查该程序,但此处未进行编译或运行,因为当前环境是 Linux。
import SwiftUI
private struct EqualWidthColumn: Layout {
private let gap: CGFloat = 8
private func columnWidth(
proposal: ProposedViewSize,
subviews: Subviews
) -> CGFloat {
let idealWidth = subviews.reduce(CGFloat.zero) { result, view in
max(result, view.sizeThatFits(.unspecified).width)
}
guard let offeredWidth = proposal.width,
offeredWidth.isFinite else {
return idealWidth
}
return min(idealWidth, max(0, offeredWidth))
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let width = columnWidth(proposal: proposal, subviews: subviews)
let childProposal = ProposedViewSize(width: width, height: nil)
let contentHeight = subviews.reduce(CGFloat.zero) { result, view in
result + view.sizeThatFits(childProposal).height
}
let totalGap = gap * CGFloat(subviews.count - 1)
return CGSize(width: width, height: contentHeight + totalGap)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let childProposal = ProposedViewSize(
width: bounds.width,
height: nil
)
var y = bounds.minY
for view in subviews {
let height = view.sizeThatFits(childProposal).height
view.place(
at: CGPoint(x: bounds.minX, y: y),
anchor: .topLeading,
proposal: childProposal
)
y += height + gap
}
}
}
private struct ContentView: View {
private let labels = [
"Amber tile",
"Blue tile with a longer local label",
"Green tile"
]
var body: some View {
ScrollView(.vertical) {
EqualWidthColumn {
ForEach(labels, id: \.self) { label in
Text(label)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.12))
)
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(minWidth: 220, minHeight: 180)
}
}
@main
struct GeometryLoopDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
两个必需的布局方法分工明确:sizeThatFits 报告容器的尺寸,而 placeSubviews 定位其子视图。子视图代理支持测量和放置,无需直接访问底层视图。两种方法都遵循文档中规定的职责。请参阅 Apple 的 Layout 文档。
实现中的关键细节是宽度提议及其复用。每个子视图都会以确定的宽度和未指定的高度进行测量,使文本能够确定所需高度。放置子视图时,使用容器实际边界的宽度以及相同的高度策略。使用 bounds.minX 和 bounds.minY 作为起点,也避免了假定放置始于坐标原点。
容器会通过返回零来处理空集合。宽度未指定时,它会选择理想宽度;宽度有限时,则会将理想宽度限制在提议宽度之内。对于这些标签,无限宽度提议也会得到有限的理想宽度。这些都是针对这个内容自适应面板制定的规则。Apple 说明,在一次布局过程中,父视图可能会使用零、无限、未指定以及其他提议来探测布局。请参阅 sizeThatFits(proposal:subviews:cache:)。
示例没有已测尺寸状态、几何回调,也不会把渲染后的宽度赋回 frame。垂直滚动视图为较高的换行内容提供了显示空间。该布局假设子视图类似于示例中的标签,能够合理响应有限宽度。若固定宽度的子视图超出可用空间,则需要单独决定呈现方式,例如使用水平滚动或提供替代视图。
什么时候仍适合观察几何信息?
当测量结果只驱动有限的响应时,观察几何信息仍然有用。Apple 的 onGeometryChange API 将转换和操作分开:转换会提取一个可比较相等性的值,操作则在转换后的值发生变化时运行。Apple 建议避免触发范围过大的应用更新,因为几何信息可能会频繁变化。请参阅 onGeometryChange(for:of:action:)。
例如,叠加视图可以显示某个稳定的包含区域是否超过指定宽度。观察该包含区域,并在转换中返回布尔值。确保叠加视图不会影响该区域的尺寸。这种设计遵循文档中的转换与操作模式,同时消除了循环尺寸依赖。
将矩形简化为布尔值可以减少通知,但这本身并不能保证稳定。如果切换到紧凑呈现方式会让被测区域发生足够大的变化,从而又切换回来,那么布尔值就可能无限交替。请测量宽度由周围窗口布局决定的区域,或者使用 ViewThatFits 在布局过程中决定呈现方式。
可用性也取决于重载版本。接收单个新值的操作在使用包含该 API 的 SDK 构建时支持 macOS 13 或更高版本;接收旧值和新值的重载要求 macOS 15 或更高版本。完整示例仅使用 Layout,因此无需处理这种区别。在将回调签名复制到较旧的部署目标之前,请查看 Apple 的几何变化文档和旧值与新值重载说明。
AppKit 和 TextKit 的测量结果应如何进入 SwiftUI 布局?
如果内容是 AppKit 视图,请使用 representable 的尺寸调整边界。NSViewRepresentable.sizeThatFits(_:nsView:context:) 在 macOS 13 或更高版本中可用,会根据提议返回首选尺寸,并可能在一次布局过程中针对不同提议被多次调用。返回 nil 表示请求默认尺寸调整算法。请将其视为尺寸查询,而不是将测量尺寸写入 SwiftUI 状态的地方。请参阅representable 尺寸调整方法。
SwiftUI 控制被表示的 AppKit 视图的 frame 和 bounds。Apple 明确警告不要自行设置这些布局属性,因为这样会与 SwiftUI 冲突,并导致未定义行为。在 representable 的更新方法中配置内容,并通过受支持的尺寸调整机制报告尺寸。请参阅 NSViewRepresentable。
对于文本,首先确认视图使用的是哪种布局引擎。TextKit 2 提供了 NSTextLayoutManager.usageBoundsForTextContainer,该 API 在 macOS 12 或更高版本中可用,用于测量文本容器的使用区域。Apple 文档说明,可以通过 KVO 观察该属性。这种观察方式可以支持调整尺寸的请求,但仍需确保宽度和高度依赖关系保持一致。请参阅 usageBoundsForTextContainer。
对于现有的 TextKit 1 实现,NSLayoutManager.usedRect(for:) 会报告已完成布局的字形所占区域;它不会生成字形,也不会执行布局。如果测量需要已完成的布局,请先对相关容器调用 ensureLayout(for:),然后再读取已使用的矩形。这两个 API 从 macOS 10.7 起均可用。请参阅 usedRect(for:) 和 ensureLayout(for:)。
不要为了获取测量值而访问 TextKit 2 文本视图的旧版 layoutManager。Apple 说明,访问旧版布局管理器可能会使文本视图切换到 TextKit 1 兼容模式。请先检查 textLayoutManager,并将旧版测量代码放在适当的回退分支中。请参阅 TextKit 和文本视图的新功能。
对于自动换行的文本视图,应将提议宽度作为文本布局的输入,并根据该配置推导首选高度。明确处理文本视图内边距和容器填充;容器坐标中的矩形并不自动等于外层视图的尺寸。AppKit 界面操作应遵循其 actor 隔离要求。Swift 的并发章节介绍了主 actor 上的执行方式;将工作移入任务并不会改变尺寸计算公式中的循环依赖。
如何确认布局现在能够稳定下来?
在 Xcode 中运行示例,缓慢地双向调整窗口大小。预期行为是标签共享宽度、在较窄时换行,并在内容高度超过可见区域时支持垂直滚动。移除测量状态依赖后,再用你实际的视图结构重复检查。
使用以下检查来区分预期的布局工作与持续反馈:
- 调整窗口大小后让窗口保持静止。检查在内容没有变化时,测量值和状态赋值是否仍在持续变化。
- 测试更长的合成标签和更大的字体。确认面板可以垂直扩展,且文本不会被裁切。
- 测试空标签集合。外围内边距应用前,布局应返回零。
- 检查测量和放置时使用的提议。支持换行的子视图应收到一致的宽度假设。
- 如果之后添加缓存,请在有效性规则中包含尺寸调整输入;不要复用针对不同宽度计算出的高度。
一次布局过程中发生多次尺寸调整调用是文档所述的正常行为。实际目标是在内容和约束不变时得到稳定结果。当测量值决定排列方式时,请将测量保留在布局协商过程中;仅当几何观察不会重新生成其所观察的尺寸时,才用它来驱动响应。
来源
- 使用 SwiftUI 组合自定义布局 — Apple
- ProposedViewSize — Apple
- Layout — Apple
- ViewThatFits — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- onGeometryChange(for:of:action:) — Apple
- onGeometryChange(for:of:action:) — 旧值和新值 — Apple
- sizeThatFits(_:nsView:context:) — Apple
- NSViewRepresentable — Apple
- usageBoundsForTextContainer — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- TextKit 和文本视图的新功能 — Apple
- 并发 — Swift 编程语言 — Swift.org