要让 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 完成尺寸调整,并在应用支持的宽度下验证换行、内容变化和行高缩小。

来源