若要讓換行的 NSTextView 符合 SwiftUI 框架,請包裝 NSScrollView,透過 sizeThatFits 接受有限的建議尺寸,並讓文字文件在其中垂直增長。設定文字容器追蹤編輯器的寬度,讓長行能夠換行。

本教學會為你自己的 Mac App 建立一個具有固定可視區域的純文字編輯器。新增文字應產生垂直捲動,而不會放大 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 App,並將部署目標設為 macOS 13 或更新版本。新增以下檔案,命名為 FramedTextEditor.swift。第二個程式碼區塊提供 App 進入點和範例內容;兩個檔案合起來就是完整範例。

此設定採用 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
            }
        }
    }
}

將產生的 App 進入點和內容檢視換成第二個檔案。目標中只能保留一個 @main 宣告。所有標籤和文字都是虛構內容,範例完全在自己的 App 視窗內運作。

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 中建置並檢查 App。

尺寸調整和換行設定如何運作?

接受有限的建議尺寸,而不測量文件

對此編輯器而言,有限的建議寬度和高度會成為偏好的視口尺寸。對未指定或非有限的尺寸,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 文件

不相等的傳入字串會被視為完整取代。如果你的 App 也會在編輯期間以程式方式取代文字,請先定義如何保留選取範圍、復原記錄和進行中的輸入組合,再擴充此範例。示範本身只會透過編輯器變更字串。

範例使用 @MainActor 標註 UI 型別和協調器。Swift 並行程式設計指南說明,主執行緒隔離是保護 UI 狀態,並要求經過標註的程式碼在該執行器上執行的機制。Swift 程式語言:並行程式設計

驗證結果並找出剩餘的版面配置問題

在範例 App 中執行以下檢查。這些檢查會分別檢視視口尺寸、文字換行、捲動和狀態同步,而不會把所有症狀都視為 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 對應 API,用於取得文字容器的使用範圍,是 NSTextLayoutManager.usageBoundsForTextContainer。此有界限的編輯器不需要任何一種測量 API。Apple 的 TextKit 移轉指南

版本界線也很重要。NSViewRepresentable 從 macOS 10.15 起提供,但其 sizeThatFits 回呼需要 macOS 13。若部署目標較早,請保留捲動文件配置,並使用較舊 representable 的預設尺寸調整行為,搭配 AppKit 尺寸優先順序,或採用為該目標設計的容器。不要期待此處的回呼能控制該版本上的版面配置;請另行驗證該實作。Apple 的 representable 文件、Apple 的尺寸調整回呼適用版本

對有界限的 Mac 編輯器而言,應清楚劃分各自職責:SwiftUI 配置可視矩形,捲動檢視容納增長中的文件,而文字容器則追蹤可用寬度。如此便能透過明確的方式診斷框架尺寸,而不必在固定高度的編輯器中加入文字測量。

來源