要让 SwiftUI 文本在 NSTableView 单元格内换行,请为托管视图指定明确的宽度,使用 Auto Layout 将其高度与单元格关联,并启用自动行高。本指南将用这种布局方式构建一个小型 Mac 标签列表。
任务很明确:在自己的应用中显示长度各异的合成标签,并让行高足以显示内容,同时适应可用宽度的变化。示例面向 macOS 13 或更高版本,并使用基于视图的表格。
SwiftUI 和 NSTableView 如何共同决定尺寸?
布局涉及三个层次。表格分配单元格的矩形区域。Auto Layout 将单元格与嵌入其中的 NSHostingView 关联起来。SwiftUI 决定标签如何适应分配给它的空间。Apple 将 NSHostingView 描述为在布局期间向 AppKit 报告 SwiftUI 内容尺寸偏好的桥接视图。NSHostingView
要让文本换行,必须先确定宽度,之后高度才有意义。宽列中的标签可能只需一行,而窄列中则可能需要多行。设置托管视图的初始框架并不能为表格单元格建立持久布局:Apple 文档说明,表格会设置由其单元格代理返回的视图的框架。tableView(_:viewFor:row:)
因此,下面的实现采用一种尺寸布局方式:表格提供水平空间,托管视图通过带水平内边距的约束贴合单元格边缘,而 SwiftUI 标签在接受建议宽度的同时保留理想垂直尺寸。启用 usesAutomaticRowHeights 后,表格会使用 Auto Layout 计算行高。usesAutomaticRowHeights
不要将 intrinsicContentSize 和 fittingSize 视为可互换的测量值。Apple 将 fittingSize 定义为满足视图及其子视图约束的最小尺寸。因此,如果未施加预期的宽度约束就查询该值,它无法回答当前列宽下单元格应有多高。fittingSize
为 macOS 13 及更高版本构建完整的标签表格
在 Xcode 中创建一个 macOS App 项目,使用 SwiftUI 界面,并将部署目标设为 macOS 13 或更高版本。用下面的第一个文件替换生成的应用声明,然后将另外两个 Swift 文件添加到同一个应用目标中。这三个文件合起来构成一个完整程序;只保留一个 @main 声明。
要求 macOS 13 是因为显式使用了 NSHostingView.sizingOptions。托管视图本身从 macOS 10.15 起可用,自动表格行高从 macOS 10.13 起可用。较旧的部署目标需要在 sizingOptions 周围添加可用性检查,或使用省略该自定义设置的实现版本。NSHostingView、sizingOptions、usesAutomaticRowHeights
创建应用和合成示例数据
第一个文件 LabelSizingApp.swift 提供三个标签和一个用于更改最后一个标签的按钮。这样既可以检查初始布局,也可以检查内容更新。
import SwiftUI
@main
struct LabelSizingApp: App {
@State private var expanded = false
private var labels: [String] {
[
"Amber tile",
"Blue tile with a longer descriptive label that can wrap.",
expanded
? "Green tile with several descriptive words. This longer label makes it easier to inspect wrapping when the window becomes narrower."
: "Green tile"
]
}
var body: some Scene {
WindowGroup {
VStack(alignment: .leading, spacing: 12) {
Button("Toggle longer label") {
expanded.toggle()
}
LabelTable(labels: labels)
}
.padding()
.frame(minWidth: 300, minHeight: 240)
}
}
}
将托管视图连接到单元格
在 HostedLabelCell.swift 中,SwiftUI 内容接受可用宽度,并请求其理想高度。按轴设置的 fixedSize 修饰符很重要:固定水平轴会要求文本保留其理想宽度,可能导致文本超出父视图边界。fixedSize(horizontal:vertical:)
import AppKit
import SwiftUI
struct RowLabel: View {
let text: String
var body: some View {
Text(verbatim: text)
.font(.system(size: 14))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
}
}
@MainActor
final class HostedLabelCell: NSTableCellView {
private let host = NSHostingView(
rootView: RowLabel(text: "")
)
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
host.translatesAutoresizingMaskIntoConstraints = false
host.sizingOptions = [.intrinsicContentSize]
addSubview(host)
NSLayoutConstraint.activate([
host.leadingAnchor.constraint(
equalTo: leadingAnchor, constant: 12
),
host.trailingAnchor.constraint(
equalTo: trailingAnchor, constant: -12
),
host.topAnchor.constraint(equalTo: topAnchor),
host.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("This sample creates cells programmatically.")
}
func configure(text: String) {
host.rootView = RowLabel(text: text)
}
}
四条边缘约束将托管视图与单元格连接起来。禁用嵌入式托管视图的自动调整遮罩转换后,这些显式约束便可控制该视图;Apple 建议对需要动态调整约束的视图采用这种做法。外层单元格仍由表格负责定位。translatesAutoresizingMaskIntoConstraints
这里,sizingOptions 保留固有内容尺寸能力,但不会请求托管视图的最小和最大尺寸约束。Apple 文档说明,这些行为可以分别选择;将该属性设为空选项集不会创建任何由内容推导出的约束。对于这个由内容决定尺寸的单元格,保留固有内容尺寸是一种实现选择,并非所有托管视图都应采用的通用设置。sizingOptions
启用自动行高并配置复用单元格
添加 LabelTable.swift,以创建滚动视图、表格和代理。程序有意采用自动尺寸调整,不实现手动行高代理。
import AppKit
import SwiftUI
@MainActor
struct LabelTable: NSViewRepresentable {
let labels: [String]
func makeCoordinator() -> Coordinator {
Coordinator(labels: labels)
}
func makeNSView(context: Context) -> NSScrollView {
let table = NSTableView()
let column = NSTableColumn(
identifier: Coordinator.cellID
)
column.title = "Label"
column.width = 360
column.minWidth = 120
column.resizingMask = .autoresizingMask
table.addTableColumn(column)
table.headerView = nil
table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
table.intercellSpacing = NSSize(width: 0, height: 0)
table.usesAutomaticRowHeights = true
table.dataSource = context.coordinator
table.delegate = context.coordinator
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.documentView = table
return scrollView
}
func updateNSView(
_ scrollView: NSScrollView,
context: Context
) {
guard let table = scrollView.documentView as? NSTableView,
context.coordinator.labels != labels else {
return
}
context.coordinator.labels = labels
table.reloadData()
}
@MainActor
final class Coordinator: NSObject,
NSTableViewDataSource, NSTableViewDelegate {
static let cellID = NSUserInterfaceItemIdentifier("LabelCell")
var labels: [String]
init(labels: [String]) {
self.labels = labels
super.init()
}
func numberOfRows(in tableView: NSTableView) -> Int {
labels.count
}
func tableView(
_ tableView: NSTableView,
viewFor tableColumn: NSTableColumn?,
row: Int
) -> NSView? {
let cell = (
tableView.makeView(
withIdentifier: Self.cellID,
owner: self
) as? HostedLabelCell
) ?? HostedLabelCell(frame: .zero)
cell.identifier = Self.cellID
cell.configure(text: labels[row])
return cell
}
}
}
代理会先尝试复用视图,再创建单元格,并且每次都会设置当前标签。这符合 Apple 推荐的单元格视图构建顺序。makeView(withIdentifier:owner:) 可以返回一个复用视图;如果没有可用的匹配视图,则返回 nil。tableView(_:viewFor:row:)、makeView(withIdentifier:owner:)
示例只在输入数组变化时重新加载。对于三行数据,完整重载易于理解,但 Apple 文档说明,这会丢弃并重新获取可见的行视图和单元格视图。在更大型的应用中,应视情况选择范围更小的更新策略。AppKit 视图的创建和更新应放在主执行器上;Swift 将 MainActor 描述为使用与主分发队列等效的执行器。reloadData()、MainActor
如何诊断内容裁切或行高过大?
先检查宽度,再检查高度传递,最后检查更新。这一顺序遵循布局中的依赖关系,有助于避免同时改动多个不相关设置。
| 现象 | 首先检查 | 有效的修正方法 |
|---|---|---|
| 长文本在水平方向超出单元格 | 是否启用了水平理想尺寸? | 对这个需要换行的标签使用 fixedSize(horizontal: false, vertical: true)。 |
| 文本已换行,但最后几行被裁切 | 托管视图是否连接到单元格的上下两条边? | 恢复顶部和底部约束,并移除冲突的固定高度。 |
| 所有行的高度始终相同 | 是否启用了自动尺寸调整,并且是否实现了手动行高? | 检查表格配置,并采用一种明确的尺寸策略。 |
| 短文本显示在不必要地过高的单元格中 | 内容是否包含固定高度或额外的垂直空间? | 调整表格前,先检查 SwiftUI 视图层级及其内边距。 |
| 标签更改后仍显示旧内容 | 模型变化后是否重新配置了单元格? | 更新所显示的根视图,或重新加载受影响的内容。 |
可以用宽度预算让检查更具体。假设单元格宽度为 320 点,示例中左右各 12 点的内边距会为托管视图留下 296 点。其垂直内边距总共占 16 点。其余所需高度取决于实际文本布局;这些数字无法预测某个标签会占几行。
扩展的 SwiftUI 框架会在可用水平空间内建立前导对齐。它不会分配无限的实际宽度:Apple 的框架文档说明,框架会参与父视图提出的尺寸建议,并应用指定的尺寸约束。frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
在窗口较宽和较窄时分别运行示例,并让长标签显示出来,然后切换回短标签。检查所有文本是否都可见,以及行高是否能够再次缩小。这些是应在所支持的 macOS 版本上执行的验收检查。代码已根据检索到的 API 文档进行检查;由于此环境没有 Swift 编译器或 macOS 运行时,因此未在此处编译或运行。
手动计算行高时会有哪些变化?
手动尺寸调整是一条独立的实现路径。如果需要手动调整,请在高度代理之外,针对预期内容宽度计算高度,并让代理返回预先计算的结果。实用的缓存键应包含项目、可用宽度,以及会改变布局的展示参数,例如字体或内边距。
Apple 要求手动返回的行高大于零,并且不包含单元格间距。其文档还禁止在 tableView(_:heightOfRow:) 中获取单元格或行视图,并警告不要进行可能触发表格递归布局的几何查询。因此,在该回调中测量可见单元格并不适合作为手动实现的基础。tableView(_:heightOfRow:)
预先计算的高度发生变化时,使用 noteHeightOfRows(withIndexesChanged:) 通知表格。Apple 文档说明,如果实现了相应代理方法,这会立即根据代理提供的高度重新排列表格。列宽调整也必须使依赖宽度的测量值失效;columnDidResizeNotification 是针对该事件的文档通知。如果用手动缓存替换自动方式,就必须考虑这些事项。noteHeightOfRows(withIndexesChanged:)、columnDidResizeNotification
用 TextKit 测量 SwiftUI 标签合适吗?
如果应用确实通过 TextKit 容器布局文本,TextKit 会很有用。其文档所述的测量范围比整个 SwiftUI 单元格更窄:NSLayoutManager.usedRect(for:) 会报告已在指定容器中完成布局的字形边界,但本身不会生成字形或执行布局。如果尚未完成布局,请先调用 ensureLayout(for:),再读取该测量值。usedRect(for:)、ensureLayout(for:)
还要考虑容器配置。Apple 文档说明,默认的 lineFragmentPadding 会在每个行片段两端留出 5 点,从而影响可用布局宽度。Apple 还指出,该属性并非用于表示文本边距。lineFragmentPadding
实际而言,单独配置的 TextKit 测量本身无法确定这个 SwiftUI 视图层级的高度。单元格还包含 SwiftUI 布局行为和内边距。对于这个标签表格,应通过托管视图和 Auto Layout 完成尺寸调整,并在应用支持的宽度下验证换行、内容变化和行高缩小。
来源
- NSHostingView — Apple
- sizingOptions — Apple
- usesAutomaticRowHeights — Apple
- tableView(_:viewFor:row:) — Apple
- fittingSize — Apple
- fixedSize(horizontal:vertical:) — Apple
- translatesAutoresizingMaskIntoConstraints — Apple
- makeView(withIdentifier:owner:) — Apple
- reloadData() — Apple
- MainActor — Apple
- frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) — Apple
- tableView(_:heightOfRow:) — Apple
- noteHeightOfRows(withIndexesChanged:) — Apple
- columnDidResizeNotification — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- lineFragmentPadding — Apple