要让 SwiftUI 滚动视图适应较短内容,请测量其内部内容,并将视口高度设为该测量值与指定上限中的较小值。较长的内容随后会在此上限范围内滚动。本教程将为你自己的 Mac 应用中的小型标签面板实现这种行为。
为什么较短的滚动视图会留下空白?
首先要区分两个尺寸:内容的高度,以及显示内容的视口高度。Apple 将 ScrollView 描述为显示其底层内容的一部分,并在滚动时调整可见部分。一组较短的标签本身并不会确定你偏好的视口高度。Apple 的 ScrollView 文档解释了这一区别。
对于紧凑的桌面面板,应明确指定尺寸规则:viewportHeight = min(contentHeight, maximumHeight)。在内容实际获得的宽度下测量内容,包括任何应该随内容一起滚动的内边距。将结果应用于外层滚动视图。Swift 的 min(_:_:) 会返回较小的值,而 SwiftUI 的 frame(width:height:alignment:) 会在提供高度参数时设定固定高度。Swift min(_:_:),SwiftUI 固定框架。
举例来说,假设最大值为 240 点。测量结果为 86 点的内容会生成 86 点高的视口;测量结果为 410 点的内容会生成 240 点高的视口,其余内容可通过滚动访问。这些只是示例输入,并非下方示例的实测数据。
| 更改项 | 控制内容 | 是否能设定所需的紧凑高度? |
|---|---|---|
| 测量内容高度、限制其上限并应用于滚动视图 | 视口高度 | 是,在下方所示的受限布局中 |
.scrollBounceBehavior(.basedOnSize) |
内容尺寸相关的回弹行为 | 否 |
标签上的 .fixedSize(horizontal: false, vertical: true) |
内容的理想垂直尺寸 | 有助于测量;外层视口仍需设置高度规则 |
Apple 将 .basedOnSize 记录为一种回弹配置,并将 fixedSize(horizontal:vertical:) 描述为沿选定尺寸固定视图的理想尺寸。这两种描述都没有规定根据内容计算视口高度。滚动回弹行为,按轴固定尺寸。
构建带有滚动高度上限的紧凑面板
请使用当前版本的 Xcode,其中 macOS SDK 提供单值 onGeometryChange(for:of:action:) 重载。Apple 当前文档显示,该重载从 macOS 13.0 起可用。下方完整示例以 macOS 13.3 或更高版本为目标,因为它还使用了 scrollBounceBehavior(_:axes:),该接口从 macOS 13.3 起可用。几何变化回调,回弹修饰符的可用性。
使用 SwiftUI 创建 macOS 应用项目。将生成的应用声明和内容视图替换为这个单文件程序,并确保目标中只保留一个 @main 声明。所有标签都是本地合成字符串。Show more labels 开关用于在较短和较长的内容之间切换。
import SwiftUI
@main
struct CompactLabelsApp: App {
var body: some Scene {
WindowGroup {
CompactLabelsView()
}
}
}
struct CompactLabelsView: View {
@State private var showMore = false
@State private var viewportHeight: CGFloat = 240
private var labels: [String] {
if showMore {
return (1...18).map { number in
"Sample label \(number): a longer description that can wrap when the panel becomes narrower."
}
}
return ["Sample label A", "Sample label B"]
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Show more labels", isOn: $showMore)
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 8) {
if labels.isEmpty {
Text("No labels to display")
} else {
ForEach(labels.indices, id: \.self) { index in
Text(labels[index])
}
}
}
.font(.body)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.onGeometryChange(for: CGFloat.self) { proxy in
min(proxy.size.height.rounded(.up), 240)
} action: { newHeight in
viewportHeight = newHeight
}
}
.frame(height: viewportHeight)
.scrollBounceBehavior(.basedOnSize, axes: .vertical)
.border(Color.secondary.opacity(0.4))
Text("The label panel grows up to 240 points.")
.font(.caption)
}
.padding(20)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.frame(minWidth: 300, minHeight: 360)
}
}
本程序已经过检查,但这里没有编译或运行,因为此环境不包含 Swift 编译器或 macOS 运行时。将它集成到现有界面之前,请在 Xcode 中构建,并执行下方的检查。
为什么要在滚动视图内部测量
几何回调会观察带内边距的内部堆栈。若测量外层滚动视图,得到的会是你已经指定的视口尺寸,这会形成循环尺寸规则,而不是找出内容所需的高度。
该转换只返回经过取整并限制上限的高度。Apple 建议将几何信息转换为所需的值,并避免因频繁的几何变化而触发大范围更新。这里,滚动位置的变化与计算无关;高于上限的内容仍会得到相同的 240 点结果。Apple 关于几何变化的指导。
向上取整可避免故意将视口设得比测得的内容略矮。初始的 240 点状态在测量结果到达前提供了可用视口;它是启动用的值,不是测得的内容高度。之后回调会替换它。
为什么宽度和修饰符顺序很重要
让水平方向的尺寸保持灵活。示例使用 .fixedSize(horizontal: false, vertical: true),让标签使用可用宽度,同时保留其理想垂直尺寸。Apple 提醒,固定理想尺寸可能导致视图超出父视图边界;因此,在窄面板中显示换行标签时,同时固定两个尺寸并不适合作为默认选择。Apple 的 fixedSize(horizontal:vertical:) 文档。
12 点内边距位于测量修饰符之前,因此会计入测得的内容高度。边框位于外层高度设置之后,用于标示视口边界。这样的安排便于看出未使用的空间属于滚动面板还是周围的窗口。
验证较短内容、换行和收缩行为
按以下顺序检查示例中的行为,并在将面板放入应用的实际容器后再次检查:
- 在
Show more labels关闭时启动应用。边框应围住两个标签及其内边距,标题说明紧接在下方。 - 打开
Show more labels。面板应增长到 240 点的上限。滚动确认最后一个标签仍可访问。 - 缩窄再加宽窗口。换行标签的高度应随之变化,且最后几行不应消失。
- 再次关闭开关。面板应收缩回较短内容的高度。
- 临时将短数组改为
[]。明确显示的No labels to display消息应决定内容高度。 - 使用实际面板中的文本样式和周围控件重复检查。
空内容状态是有意作出的界面选择:此示例在数组为空时仍保留一条消息和内边距。如果你希望彻底移除面板,那么在没有标签时有条件地省略整个滚动视图。这是与测量非空面板不同的产品决策。
周围的示例窗口保留了其最小高度。此代码调整的是标签视口;它不会要求 Mac 窗口在移除标签时自动收缩。标题说明下方的任何空白都属于外层布局,不在带边框的滚动视图内。
诊断残留空白或被裁切的标签
如果面板仍然很高,首先检查测量修饰符的附加位置。它应观察应用了内部内边距后的内容堆栈。接着检查祖先视图是否设置了显式高度或最小高度,从而为面板周围保留空间。Apple 文档说明,提供的框架高度是固定尺寸,因此单独的外层框架即使在内部视口收缩后也可能保留空间。SwiftUI 框架尺寸。
如果内容本身仍然异常高,请从被测子树中移除垂直扩展设置。对于这种小型标签模式,避免使用垂直间隔视图,或使用最小高度取决于视口的内容框架。这些选择会使内容高度依赖视口,从而破坏尺寸规则所需的独立测量。
如果底部内边距被裁切,请确认它已在测量前包含在内。如果集成后文本换行方式发生变化,请检查新父视图实际提供的宽度。在一种宽度下测得的高度不应被当作另一种宽度下的固定高度。
让 240 点的上限适合外层界面。示例提供的窗口最小高度足以容纳其控件、内边距、标题说明和达到上限的面板。更矮的容器需要更小的空间预算或不同的周围布局;这个固定上限不会自动计算其他控件下方剩余的空间。
这种方法适用于数量不多且会立即布局的标签。Apple 将 LazyVStack 描述为仅在屏幕渲染需要时创建项目。因此,不要假设将内部堆栈替换为惰性堆栈后,仍会保留同样的完整内容测量策略。对于大型集合,使用固定视口和惰性内容通常更合适。Apple 的 LazyVStack 文档。
何时适用 AppKit 和 TextKit 测量?
对于这个 SwiftUI 标签面板,应测量已渲染的 SwiftUI 子树。AppKit 文本测量会引入另一套布局配置,而它必须与显示的标签一致。
如果你的应用已经使用 NSScrollView,应保留文档视图与视口之间的相同区别。Apple 将 documentView 定义为被滚动的视图,而 contentSize 是滚动视图内容视图的尺寸。尽管名称如此,contentSize 并不是对整个文档的测量。AppKit documentView,AppKit contentSize。
对于基于 NSLayoutManager 构建的现有 TextKit 布局,usedRect(for:) 会报告文本容器中已布局字形的边界矩形;Apple 明确指出,查询它不会生成字形或执行布局。需要完整的容器布局时,ensureLayout(for:) 是先强制执行该布局的文档化方法。TextKit usedRect(for:),TextKit ensureLayout(for:)。
即便如此,字形矩形也不等于完整的文本视图框架。Apple 记录了文本容器周围的额外空间 textContainerInset,以及容器内部的行片段内边距。应根据实际文本视图配置进行计算,而不是直接将字形高度复制到滚动视口中。这些方法说明的是 NSLayoutManager 路径,并非适用于所有文本组件的通用尺寸方案。AppKit textContainerInset。
对于小型 SwiftUI 标签面板,实用的做法是保持宽度灵活,测量带内边距的内部内容,并限制外层视口高度。分别在内容较短和较长时验证结果,然后在真实父布局中进行测试,确保周围约束支持同样的紧凑行为。
来源
- ScrollView — Apple
- min(::) — Apple
- frame(width:height:alignment:) — Apple
- scrollBounceBehavior(_:axes:) — Apple
- fixedSize(horizontal:vertical:) — Apple
- onGeometryChange(for:of:action:) — Apple
- LazyVStack — Apple
- documentView — Apple
- contentSize — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- textContainerInset — Apple