若要讓換行的 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 框架失效。
- 觀察編輯器邊框。初始要求的編輯器區域為
420 × 180點。 - 啟用
Compact editor。寬度應變為280點,而高度維持180點。句子應換行成更多行。 - 捲動瀏覽提供的段落。文件應在同一個邊框內移動。
- 輸入更多行。字元數應改變,但編輯器的要求高度不應增加。
- 刪除文字。即使文件內容很少,視口仍應維持要求的尺寸。
| 症狀 | 首要檢查項目 |
|---|---|
| 邊框尺寸正確,但長行不會換行 | 檢查文件的寬度自動調整遮罩和 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 配置可視矩形,捲動檢視容納增長中的文件,而文字容器則追蹤可用寬度。如此便能透過明確的方式診斷框架尺寸,而不必在固定高度的編輯器中加入文字測量。
來源
- NSViewRepresentable — Apple。
- sizeThatFits(_:nsView:context:) — Apple。
- ProposedViewSize — Apple。
- frame(width:height:alignment:) — Apple。
- documentView — Apple。
- 在 NSScrollView 中放入 NSTextView 物件 — Apple 文件封存。
- TextKit 和文字檢視的新功能 — Apple。
- autoresizingMask — Apple。
- widthTracksTextView — Apple。
- heightTracksTextView — Apple。
- isVerticallyResizable — Apple。
- textContainerInset — Apple。
- textDidChange(_:) — Apple。
- Binding — Apple。
- Concurrency — Swift.org。