要让换行的 NSTextView 适应 SwiftUI 框架,请封装一个 NSScrollView,通过 sizeThatFits 接受有限的建议尺寸,并让文本文档在其中纵向增长。将文本容器配置为跟随编辑器的宽度,以便长行换行。

本教程将为你自己的 Mac 应用构建一个可见区域有界的纯文本编辑器。添加文本时应出现垂直滚动,而不会扩大 SwiftUI 布局。完整示例面向 macOS 13 或更高版本,因为 Apple 从该版本起提供了 representable 的尺寸回调。Apple 的 sizeThatFits 文档

为什么 NSTextView 看起来会忽略 SwiftUI 框架?

这里需要协调三种尺寸。SwiftUI 为所封装的 AppKit 视图设定尺寸。NSScrollView 提供可见区域。它的文档视图包含文本,而 NSTextContainer 决定文本布局的可用区域。Apple 将文档视图描述为滚动视图在其内容视图中滚动的视图。Apple 的 documentView 文档

SwiftUI 的 .frame(width:height:) 会创建一个外层布局框架,但不会单独配置文本容器的换行行为。Apple 还说明,子视图的边界可以超出外层框架。因此,边框有助于诊断问题:即使封装的内容表现不同,边框仍可能具有所要求的尺寸。Apple 的框架文档

核心约束在于所有权。Apple 明确指出,SwiftUI 控制封装的 AppKit 视图的 frame 和 bounds;自行更改这些属性会与 SwiftUI 冲突。请通过 representable 的尺寸回调返回你偏好的尺寸。在此实现中,被封装的视图是滚动视图,而 AppKit 管理其内部的文本文档。Apple 的 NSViewRepresentable 文档

更改布局标志前,先确定所需行为:

所需行为 外层尺寸策略 文本文档策略
固定大小的编辑器面板 接受提供的宽度和高度 水平换行;纵向增长并滚动
填满有界窗格的编辑器 接受窗格提供的有限建议尺寸 水平换行;纵向增长并滚动
展开以显示全部文本的编辑器 根据建议宽度计算高度 协调内容测量和布局更新

前两行是可运行示例所涵盖的任务。第三行需要单独的内容高度策略;接受建议高度并不能实现该行为。

构建一个有界且会换行的编辑器

在 Xcode 中创建 macOS SwiftUI 应用,并将部署目标设为 macOS 13 或更高版本。将以下代码文件添加为 FramedTextEditor.swift。第二个代码块提供应用入口点和示例内容;这两个文件共同构成完整示例。

此配置遵循 Apple 文档所述的垂直滚动布局:文本视图纵向增长,其宽度跟随父视图,而文本容器跟随文本视图的宽度。Apple 的组装指南已归档,因此示例使用当前的 Swift API,并通过 NSTextView(usingTextLayoutManager: true) 明确选择 TextKit 2。Apple 在 TextKit 演示中说明了该初始化器的选择。Apple 的滚动文本视图指南,TextKit 和文本视图的新功能

import AppKit
import SwiftUI

@MainActor
struct FramedTextEditor: NSViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator($text)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.autohidesScrollers = true

        let textView = NSTextView(usingTextLayoutManager: true)
        // This is the internal document, not the represented root view.
        textView.setFrameSize(scrollView.contentSize)
        textView.minSize = .zero
        textView.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = [.width]
        textView.isRichText = false
        textView.importsGraphics = false
        textView.font = .systemFont(ofSize: 14)
        textView.textContainerInset = NSSize(width: 8, height: 8)

        guard let container = textView.textContainer else {
            preconditionFailure("Expected a text container")
        }
        container.size = NSSize(
            width: max(1, scrollView.contentSize.width),
            height: CGFloat.greatestFiniteMagnitude
        )
        container.widthTracksTextView = true
        container.heightTracksTextView = false

        textView.string = text
        textView.delegate = context.coordinator
        scrollView.documentView = textView
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        context.coordinator.text = $text
        guard let textView = nsView.documentView as? NSTextView else {
            preconditionFailure("Expected an NSTextView document")
        }
        if textView.string != text {
            textView.string = text
        }
    }

    func sizeThatFits(
        _ proposal: ProposedViewSize,
        nsView: NSScrollView,
        context: Context
    ) -> CGSize? {
        let ideal = CGSize(width: 320, height: 180)
        let proposed = proposal.replacingUnspecifiedDimensions(by: ideal)
        return CGSize(
            width: proposed.width.isFinite
                ? max(0, proposed.width) : ideal.width,
            height: proposed.height.isFinite
                ? max(0, proposed.height) : ideal.height
        )
    }

    static func dismantleNSView(
        _ nsView: NSScrollView,
        coordinator: Coordinator
    ) {
        (nsView.documentView as? NSTextView)?.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {
        var text: Binding<String>

        init(_ text: Binding<String>) {
            self.text = text
            super.init()
        }

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            if text.wrappedValue != textView.string {
                text.wrappedValue = textView.string
            }
        }
    }
}

用第二个文件替换生成的应用入口点和内容视图。目标中只保留一个 @main 声明。所有标签和文本均为虚构内容,示例完全在应用自己的窗口中运行。

import SwiftUI

@main
@MainActor
struct FrameSizingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            EditorDemo()
        }
    }
}

@MainActor
struct EditorDemo: View {
    @State private var compact = false
    @State private var text = Array(
        repeating: "Sample note: arrange the blue tiles beside the green tiles.",
        count: 16
    ).joined(separator: "\n\n")

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Compact editor", isOn: $compact)

            FramedTextEditor(text: $text)
                .frame(width: compact ? 280 : 420, height: 180)
                .border(Color.secondary)

            Text("Character count: \(text.count)")
                .font(.caption)
        }
        .padding(20)
        .frame(minWidth: 460, minHeight: 280, alignment: .topLeading)
    }
}

这里已检查这些文件,但未进行编译或运行:当前环境是 Linux,没有 Swift 可执行文件或 macOS 框架。请按照以下验证步骤在 Xcode 中构建并检查应用。

尺寸与换行设置如何工作?

接受有限的建议尺寸,但不测量文档

对于此编辑器,有限的建议宽度和高度会成为首选视口尺寸。320 × 180 的回退值是一个示例设计选择,用于未指定或非有限的尺寸。这并非测量得到的文本尺寸。零值仍保持为零,因此最小尺寸探测不会变成随意设定的正数最小值。

SwiftUI 在一次布局过程中可能会询问不同的尺寸。其标准建议值包括最小尺寸、最大尺寸和理想尺寸查询。此示例有意为无界建议值提供有限的回退值;它适用于有界的编辑器面板,并非适用于所有容器的通用尺寸策略。若返回 nil,则会改用 SwiftUI 的默认尺寸算法。Apple 的 ProposedViewSize 文档,Apple 的 representable 尺寸回调

此回调不会更改滚动视图的框架、替换文本或写入 SwiftUI 状态。它的作用是回答尺寸查询。因此,在此实现中,文本长度不会决定外层编辑器的高度。

让宽度跟随视口,高度跟随内容

两个宽度设置的作用各不相同。内部文本视图的 .width 自动调整大小掩码会使其宽度随父视图变化。文本容器的 widthTracksTextView = true 会使其布局宽度随文本视图变化。编辑器变窄时,这两种关系都很重要。Apple 的 autoresizingMask 文档,Apple 的 widthTracksTextView 文档

在垂直方向,isVerticallyResizable = true 允许文档视图调整高度以适应文本。较大的文本容器高度为纵向布局留出空间,而 heightTracksTextView = false 避免将该布局限制绑定到每次文档视图尺寸调整上。滚动视图仍是有界的可见区域。Apple 的垂直尺寸调整文档,Apple 的 heightTracksTextView 文档

边框与文本之间可能会有一些空隙。此示例明确设置了八点的文本容器内边距。文本还可能因容器的行片段内边距而有额外缩进,因此视口宽度和文本视图宽度都不一定等于字符实际占用的宽度。Apple 的 textContainerInset 文档

将文本更新与尺寸协商分开处理

协调器通过 textDidChange 转发编辑更改,绑定则将这些更改连接到父视图的状态。updateNSView 处理从 SwiftUI 传回的更改。其相等性检查可避免普通编辑后相同的值传回时重新设置编辑器中的字符串。Apple 的文本更改委托文档,Apple 的 Binding 文档

不同的传入字符串会被视为完整替换。如果你的应用也会在编辑过程中以编程方式替换文本,请先定义如何保留选区、撤销历史和正在进行的输入组合,再扩展此示例。此演示只通过编辑器更改字符串。

示例使用 @MainActor 标注其 UI 类型和协调器。Swift 并发指南将主执行器隔离描述为保护 UI 状态并要求带注解代码在该执行器上运行的机制。《Swift 编程语言:并发》

验证结果并查找剩余的布局问题

在示例应用中运行以下检查。它们分别检查视口尺寸、文本换行、滚动和状态同步,而不是把所有症状都归因于 SwiftUI 框架失效。

  1. 查看编辑器边框。初始要求的编辑器区域为 420 × 180 点。
  2. 启用 Compact editor。宽度应变为 280 点,高度保持为 180 点。句子应换成更多行。
  3. 滚动查看提供的段落。文档应在同一边框内滚动。
  4. 输入更多行。字符数应发生变化,但编辑器要求的高度不应增加。
  5. 删除文本。即使文档几乎没有内容,视口也应保留所要求的尺寸。
现象 首先检查
边框尺寸正确,但长行不换行 检查文档的宽度自动调整大小掩码和 widthTracksTextView
添加文本会扩大周围布局 确认 representable 返回的是滚动视图,并且没有从 sizeThatFits 返回内容高度
无法滚动到后面的行 检查垂直滚动、文档的垂直尺寸调整以及文本容器的高度限制
在其他父布局中编辑器仍然很窄 查看该父视图建议的宽度;回调会接受有限的建议尺寸,不会凭空创造额外空间
无关状态更新期间文本位置发生变化 检查是否无条件赋值给 textView.string

这些检查是根据上述尺寸关系整理出的诊断步骤,并非已报告的运行时测试结果。

内容驱动的高度或较旧的 macOS 版本该如何处理?

展开以显示每一行的编辑器是另一类任务。它需要根据建议宽度计算内容高度,并制定最大高度策略。仅启用内部文档的垂直尺寸调整,并不会向 SwiftUI 传递新的首选高度。同样,像本示例这样接受建议高度,也无法计算由文本决定的高度。

添加测量功能时,应保留所选的文本引擎。Apple 说明,访问 NSTextView 实例的 layoutManager 可能会触发 TextKit 1 兼容模式。Apple 为文本容器使用范围记录的 TextKit 2 对应属性是 NSTextLayoutManager.usageBoundsForTextContainer。此有界编辑器不需要任何测量 API。Apple 的 TextKit 迁移指南

版本限制也很重要。NSViewRepresentable 从 macOS 10.15 起可用,但其 sizeThatFits 回调需要 macOS 13。对于更早的部署目标,请保留滚动文档布局,并使用旧版 representable 的默认尺寸行为,以及 AppKit 尺寸优先级或专为该目标设计的容器。不要指望此回调在那里控制布局;请单独验证相应实现。Apple 的 representable 文档,Apple 的尺寸回调可用性说明

对于有界的 Mac 编辑器,请明确各自的职责:SwiftUI 分配可见矩形,滚动视图容纳不断增长的文档,文本容器跟随可用宽度。这样的布局让你可以具体诊断框架尺寸问题,而固定高度的编辑器无需进行文本测量。

来源