要使侧边控件与宽高比 SwiftUI 预览匹配,请根据可用窗口空间计算适配后的预览尺寸,然后为控件设置相同的高度。这个 macOS 示例会保持合成预览的比例,并让溢出的控件可以滚动。
任务是创建一个可调整大小的桌面编辑器,左侧显示预览,右侧显示固定宽度的控件面板。这里的“预览”指运行中的应用内的内容。目标是随着窗口大小变化,让两个面板的顶部和底部布局边缘对齐。
控件应跟随哪个高度?
首先,定义要匹配的矩形。按宽高比适配的预览可能占用的空间小于其外围容器。如果控件跟随容器的高度,即使两个视图的布局都正确,控件也可能延伸到可见预览的下方。
Apple 将宽高比定义为宽度除以高度。对于可调整大小的内容,aspectRatio(_:contentMode:) 配合 .fit 会在建议的空间内适配内容,同时保留该比例。文档中的椭圆示例说明了适配后的形状与包围它的正方形框架为何可能尺寸不同。Apple 的宽高比文档。
此实现会明确计算适配后的矩形。它为控件预留固定宽度和面板之间的间距,然后将预览适配到剩余宽度和可用高度中。两个面板都会采用计算出的预览高度。
这是本示例采用的布局策略:由预览决定控件视口的高度。较长的控件列表会在该视口内滚动,而不会撑高预览。Apple 将 ScrollView 描述为在可滚动区域内显示底层内容中可移动的一部分。Apple 的滚动视图文档。
一次性计算适配后的矩形
设 W 和 H 为扣除外部内边距后的可用内容尺寸,C 为控件宽度,G 为间距,R 为预览的宽高比。计算如下:
remainingWidth = max(0, W - C - G)
previewWidth = min(remainingWidth, H * R)
previewHeight = previewWidth / R
controlsHeight = previewHeight
第一个限制可防止预览占用控件的水平空间。第二个限制可防止宽窗口产生超出可用区域高度的预览。根据选定的宽度计算高度,可以在两种情况下都保持比例。
例如,假设内边距应用前的内容区域为 960 × 600 点。每边采用 16 点内边距,控件面板宽 220 点,间距为 16 点,预览宽高比为 16:9,则可用区域为 928 × 568 点。预览剩余宽度为 692 点,因此高度为 389.25 点。控件视口因此为 220 × 389.25 点。
这些是用于说明的输入值,并非应用程序的实测结果。它们也说明了为什么将控件高度设为整个 568 点可用高度会导致对齐错误。
布局尺寸使用 CGFloat,并确保比例为正的有限值。在 Swift 中,let ratio: CGFloat = 16.0 / 9.0 可明确表达预期的浮点运算。如果应用以后允许配置比例,请在除法前对其进行验证。
构建完整的 macOS 示例
在 Xcode 中创建 macOS SwiftUI 应用,并将其部署目标设为 macOS 13 或更高版本。将生成的应用和内容视图声明替换为以下单个文件,并确保目标中恰好只有一个 @main 声明。此示例不需要资源文件或外部包。
此部署要求来自此处使用的窗口尺寸 API。该场景使用 .contentMinSize;Apple 将其描述为根据内容推导窗口的最小尺寸,同时不设最大尺寸。Apple 的窗口可调整性文档,Apple 的内容最小尺寸文档。
import SwiftUI
@main
struct MatchedPreviewApp: App {
var body: some Scene {
WindowGroup {
PreviewEditor()
.frame(minWidth: 640, minHeight: 360)
}
.windowResizability(.contentMinSize)
}
}
struct PreviewEditor: View {
@State private var intensity: Double = 0.5
@State private var showsLabel = true
private let ratio: CGFloat = 16.0 / 9.0
private let controlsWidth: CGFloat = 220
private let gap: CGFloat = 16
var body: some View {
GeometryReader { geometry in
let availableHeight = max(0, geometry.size.height)
let remainingWidth = max(
0,
geometry.size.width - controlsWidth - gap
)
let previewWidth = min(
remainingWidth,
availableHeight * ratio
)
let previewHeight = previewWidth / ratio
HStack(alignment: .top, spacing: gap) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.opacity(0.2 + intensity * 0.7))
.aspectRatio(ratio, contentMode: .fit)
.overlay {
Text("Sample composition")
.font(.headline)
.opacity(showsLabel ? 1 : 0)
}
.frame(
width: previewWidth,
height: previewHeight
)
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
Text("Preview controls")
.font(.headline)
Toggle("Show label", isOn: $showsLabel)
VStack(alignment: .leading, spacing: 8) {
Text("Color intensity")
Slider(value: $intensity, in: 0...1)
}
ForEach(1...8, id: \.self) { index in
Text("Sample setting \(index)")
.font(.caption)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(
width: controlsWidth,
height: previewHeight
)
.background(Color.gray.opacity(0.08))
}
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .center
)
}
.padding(16)
}
}
GeometryReader 提供分配给编辑器区域的尺寸。Apple 将其描述为根据自身尺寸和坐标空间定义内容,并具有灵活的首选尺寸。将内边距放在读取器外面,意味着计算使用的是内缩后的内容区域。Apple 的几何读取器文档。
两个固定框架设定了共享高度。Apple 将 frame(width:height:alignment:) 描述为创建一个具有指定尺寸的不可见框架。控件背景放在该框架之后,因此会覆盖控件视口,包括短列表下方的空白区域。Apple 的固定框架文档。
最后一个框架会将整行内容在读取器中居中。它不会扩大任一面板。因此,高窗口可能在该行上方和下方留有空白,同时控件仍与预览匹配。
调整大小时保留状态和生命周期行为
将用户可编辑的值与计算出的几何尺寸分开。在示例中,intensity 和 showsLabel 是状态;预览宽度和高度则是根据当前可用尺寸推导出的局部值。
Apple 将状态描述为由 SwiftUI 管理的存储,其变化会更新视图层级中的依赖部分。因此,可以将滑块和切换开关的值保存在拥有它们的编辑器中。Apple 的状态文档。
布局计算不包含状态赋值、外观回调、异步工作或测量反馈。调整大小时,只会改变同一视图结构所用的尺寸。标签始终存在,并会在切换开关变化时改变不透明度。
将此方法应用到现有编辑器时,请保留其模型的所有权和绑定类型。在预览和控件周围应用尺寸策略;不要把模型创建移入几何计算中。此示例的运算只需要可用尺寸和选定的比例。
检查调整大小、溢出和修饰符顺序
使用以下尺寸检查计算结果。它们描述的是示例应用外部内边距之前的内容区域,而非窗口的外框尺寸。预期值四舍五入到小数点后两位。
| 内容区域 | 预览尺寸 | 控件视口 |
|---|---|---|
| 960 × 600 点 | 692 × 389.25 | 220 × 389.25 |
| 640 × 360 点 | 372 × 209.25 | 220 × 209.25 |
| 1200 × 400 点 | 654.22 × 368 | 220 × 368 |
| 700 × 900 点 | 432 × 243 | 220 × 243 |
使用一个简短的 Python 计算检查了运算结果,其中包括断言:适配后的预览不会超出可用高度,且两个面板加上间距后能在水平方向容纳。研究环境中没有 Swift 和 Xcode,因此完整的 SwiftUI 程序经过了检查,但未编译或运行。
在 Xcode 中运行应用,并将窗口调整为宽、矮和高等不同形状。确认面板边缘保持对齐;控件内容超出视口时可以滚动;调整大小期间滑块和切换开关的值保持不变。
如果应用此示例后仍未对齐,请检查以下具体情况:
- 面板额外内边距: 只在某个面板的固定框架外添加内边距,会增加该面板的总占用空间。如图所示,将控件内边距放在滚动视图内。
- 更大的预览包装器: 如果在预览外添加更大的框架,请确定控件应匹配该包装器还是适配后的内容。此处的计算匹配的是适配后的矩形。
- 溢出的自定义内容: 框架会设定布局尺寸,但不会自动隐藏超出边界的绘制内容。Apple 提供了
.clipped()来进行矩形裁剪;如果有意隐藏预览溢出,请使用它。保持控件可滚动,以便用户能访问所有设置。Apple 的裁剪文档。 - 宿主空间不足: 此示例依赖其内容最小尺寸。如果嵌入更小的宿主中,固定的控件宽度可能导致预览没有实用的宽度。可以缩小面板、改为垂直排列,或为该宿主设置明确的最小尺寸。
何时应改用自定义布局?
这种方法适用于接收有界窗口区域且使用已知宽高比的编辑器。它明确规定了尺寸策略,不依赖测量预览的内部内容。
如果可复用组件必须根据子视图决定首选尺寸,可以考虑自定义 Layout。Apple 文档中的契约将通过 sizeThatFits(proposal:subviews:cache:) 报告组合尺寸,与通过 placeSubviews(in:proposal:subviews:cache:) 放置子视图这两项职责分开。子视图代理提供测量和放置操作。Apple 的布局文档。
对于此处展示的有界桌面编辑器,使用共享的适配矩形就足够了:为控件预留水平空间,使预览适配可用的宽度和高度,并将预览高度赋给控件视口。这样就有了清晰的调整大小规则,同时控件内容仍可滚动。
来源
- aspectRatio(_:contentMode:) — Apple Developer 文档。
- ScrollView — Apple Developer 文档。
- windowResizability(_:) — Apple Developer 文档。
- contentMinSize — Apple Developer 文档。
- GeometryReader — Apple Developer 文档。
- frame(width:height:alignment:) — Apple Developer 文档。
- State — Apple Developer 文档。
- clipped(antialiased:) — Apple Developer 文档。
- Layout — Apple Developer 文档。