在 Mac 上建立編輯器,讓父視圖保留已接受的文字,並在工作表中編輯另一份草稿。只有 Commit 會將草稿複製回去;Cancel 則會關閉工作表。本教學會在你自己的 App 中,為一則簡單的純文字筆記實作這個流程。
定義 Commit 與 Cancel 的行為
讀者要完成的任務很簡單:開啟現有筆記、修改幾行,然後接受整份修訂,或保留原文不變。範例使用合成文字,並只進行單次編輯工作階段。已接受的文字會保存在記憶體中;加入檔案儲存功能是另一項 App 設計決策。
Apple 的 TextEditor 會顯示可捲動的多行文字,並接受指向正在編輯之值的繫結。因此,若繫結到已接受的值,編輯內容就會立即變更該值。若要延後接受內容,請改為將編輯器繫結到草稿。請參閱 Apple 的 TextEditor 說明文件。
這項設計包含三個部分:由父視圖持有的已接受文字、開始編輯時建立的快照,以及由工作表持有且可變動的草稿狀態。Swift 的 String 具有值語意,因此變更草稿不會改動已接受的字串。其寫入時複製儲存方式只是實作上的最佳化;獨立值的行為仍相同。請參閱 String。
| 事件 | 草稿行為 | 已接受的文字 |
|---|---|---|
| 開啟編輯器 | 從目前已接受的文字開始 | 不變 |
| 輸入或刪除文字 | 在工作表中變更 | 不變 |
選擇 Commit |
傳回父視圖,然後關閉工作表 | 以草稿取代 |
選擇 Cancel |
關閉工作表,不傳回草稿 | 不變 |
| 重新開啟編輯器 | 開始新的工作階段 | 提供新的起始文字 |
這張表也是實作規格。關閉工作表本身不代表接受內容。明確區分兩者後,取消時就不需要執行還原操作。
建立完整的 macOS 範例
在 Xcode 中使用 SwiftUI 建立 macOS App 專案,並將部署目標設為 macOS 12 或更新版本。以下單一原始碼檔案會取代自動產生的 App 與內容視圖宣告;請移除舊的 App 宣告,確保目標中恰好只有一個 @main 進入點。此範例使用純文字編輯,而非較新的屬性文字初始化器。
最低需求為 macOS 12,是因為工作表使用了 DismissAction;純文字 TextEditor 則從 macOS 11 起即可使用。Apple 在 DismissAction 與 TextEditor 中說明了這些可用性界線。
import SwiftUI
import Foundation
@main
struct DraftEditorApp: App {
var body: some Scene {
WindowGroup {
NoteView()
}
}
}
private struct EditSession: Identifiable {
let id = UUID()
let initialText: String
}
private struct NoteView: View {
@State private var acceptedText = """
Sample note
Arrange the blue tiles beside the green tiles.
"""
@State private var editSession: EditSession?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Accepted note")
.font(.headline)
ScrollView {
Text(acceptedText.isEmpty ? "(Empty note)" : acceptedText)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button("Edit Note") {
editSession = EditSession(initialText: acceptedText)
}
}
.padding(20)
.frame(minWidth: 460, minHeight: 300)
.sheet(item: $editSession) { session in
DraftEditor(initialText: session.initialText) { newText in
acceptedText = newText
}
.id(session.id)
}
}
}
private struct DraftEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var draft: String
private let initialText: String
private let onCommit: (String) -> Void
init(
initialText: String,
onCommit: @escaping (String) -> Void
) {
self.initialText = initialText
self.onCommit = onCommit
_draft = State(initialValue: initialText)
}
private var hasChanges: Bool {
draft != initialText
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit note")
.font(.headline)
TextEditor(text: $draft)
.font(.body)
.accessibilityLabel("Note draft")
.frame(minHeight: 220)
Text(hasChanges ? "Draft changed" : "No changes")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button("Commit") {
onCommit(draft)
dismiss()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding(20)
.frame(minWidth: 480, minHeight: 340)
}
}
這是一個完整的 App 範例,包含初始文字、呈現狀態、編輯器與進入點。研究環境中沒有 Swift 編譯器與 macOS SDK,因此此範例是依據查閱的 API 說明文件檢視,未在此處編譯或執行。
為什麼已接受的值由父視圖持有
父視圖持有 acceptedText,因為它會顯示已接受的結果,並提供下一個編輯工作階段的起始值。子視圖持有 draft,因為只有編輯介面需要變更它。這符合 Apple 的建議:將狀態設為私有,並放在視圖階層中合適的位置。請參閱 State。
父視圖不會將 $acceptedText 傳入編輯器,而是傳入字串值與閉包。只有一處指派會接受修訂,也就是閉包中的 acceptedText = newText。在此實作中,無論輸入文字或取消,都無法觸發該指派。
為什麼每次開啟都會建立工作階段識別碼
選用的 EditSession 同時提供呈現狀態與初始文字。Apple 說明,當繫結的項目不是 nil 時,sheet(item:onDismiss:content:) 會呈現內容,並將該項目傳入內容閉包。請參閱 sheet(item:onDismiss:content:)。
每次按下 Edit Note 都會建立新的識別碼。將該識別碼套用至編輯器,能明確表示預期的狀態生命週期:一份草稿屬於一個編輯工作階段。初始化器會提供初始狀態值;它並不是持續將現有草稿與父視圖後續變更同步的機制。
對此任務而言,快照行為正合適。筆記的部分修訂不應因父視圖中其他無關部分更新,就突然重設。重新開啟時會建立新的草稿,內容取自最近一次接受的文字。
明確設計關閉與鍵盤行為
請如範例所示,在呈現的編輯器內讀取 @Environment(\.dismiss)。Apple 說明,該動作會套用在宣告它的環境中。若在負責呈現內容的根視圖中讀取它,目標可能會是該視圖所在的視窗,而非工作表。請參閱 DismissAction。
Cancel 按鈕會直接關閉工作表。由於草稿從未變更已接受的值,因此可以直接丟棄。Commit 按鈕會先呼叫接受閉包,再關閉工作表。不要把接受動作移到 onDismiss:該回呼描述的是關閉行為,使用者取消時也會觸發。
範例使用 Apple 文件所述的標準取消快捷鍵 .cancelAction,將 Escape 指派給 Cancel。它也透過明確的按鍵等效項與修飾鍵,將 Command-Return 指派給 Commit。這些是在 App 介面中使用的控制快捷鍵。請參閱 cancelAction 與 keyboardShortcut(_:modifiers:)。
由於範例沒有將 .defaultAction 指派給 Commit 按鈕,因此仍可使用一般 Return 鍵輸入多行內容。Apple 將 .defaultAction 定義為不帶修飾鍵的 Return。此多行介面選用 Command-Return 是編輯上的設計決定,並不表示每個 Mac 編輯器都應如此設定。請參閱 defaultAction。
請確保相關介面中的快捷鍵各不相同。Apple 說明,若多個控制項使用相同快捷鍵,系統會採用解析過程中找到的第一個相符控制項。請在插入點位於編輯器內時測試快捷鍵,並測試點擊畫面上可見的按鈕。請參閱 keyboardShortcut(_:modifiers:)。
用簡短的測試流程確認接受界線
在執行中的 Mac App 中進行以下檢查。這些步驟會檢驗草稿編輯器與即時編輯器之間的關鍵差異,也涵蓋容易忽略的空字串情況。
- 開啟
Edit Note,將第二行改為Arrange the green tiles in a row.,然後點擊Cancel。已接受的筆記應保留原本的第二行。 - 重新開啟編輯器。內容應是原先已接受的文字,不包含已取消的修訂。
- 進行相同的修訂,然後點擊
Commit。已接受的筆記現在應顯示修訂後的那一行。 - 重新開啟,新增換行,然後使用 Command-Return。整份多行草稿應成為已接受的文字。
- 重新開啟並修改內容,在編輯時測試 Escape。確認取消後已接受的文字維持不變。
- 重新開啟,刪除所有文字,然後點擊
Commit。父視圖應顯示(Empty note)。再次開啟時,編輯器應為空白。
此範例允許空文字。實作不會移除空白字元或換行,也不會因字串為空而停用接受功能。如果你的筆記類型必須包含內容,請加入明確可見的驗證規則,並同時套用在 Commit 動作與按鈕的啟用狀態上。
hasChanges 比較只會控制狀態標籤。即使內容未變更,仍允許使用 Commit,也只會將相同的值指派一次,不會造成影響。如果之後要在內容未變時停用它,請保留 Cancel,讓工作表始終有明確的離開方式。
判斷何時 SwiftUI 已足夠,以及何時 AppKit 有幫助
針對這項純文字任務,TextEditor 已能提供所需的編輯介面。要將草稿變更與已接受的文字分開,不需要自訂文字版面配置,也不需要 AppKit 橋接。這項區分屬於 App 的狀態設計。
目前的 SwiftUI 也支援屬性字串版的 TextEditor 初始化器,macOS 26 起即可使用。因此,製作豐富文字不一定要捨棄 SwiftUI,但需要變更草稿的型別,並遵守較新的部署需求。Apple 也說明了該編輯器在格式屬性轉換上的限制。請參閱 init(text:selection:)。
若需要 AppKit 專屬的編輯行為或直接控制文字版面配置,建議的整合方式是透過 NSViewRepresentable 來承載 NSTextView。Apple 說明了 representable 方法如何建立、更新與拆除 AppKit 視圖,以及如何透過協調器將委派的變更傳回 SwiftUI。請參閱 NSViewRepresentable。
在該橋接中保留相同的接受規格:AppKit 編輯會更新草稿,只有明確的 commit 動作會更新已接受的模型。作為實作上的預防措施,請避免在每次 SwiftUI 更新時都替換文字視圖的全部內容;應區分實際傳入的變更與編輯器自身傳出的變更。
TextKit 還有一項界線:NSTextView 透過 textLayoutManager 提供新版版面配置,並透過 layoutManager 提供舊版版面配置。Apple 說明,在 macOS 12 或更新版本上存取後者,會讓文字視圖切換成使用 NSLayoutManager 的相容模式。加入版面配置功能時,請審慎選擇引擎。請參閱 TextKit 與 NSTextView。
最後,範例中的 commit 操作在記憶體中同步執行。如果要擴充為寫入文件,請定義接受內容的意思是更新模型,還是必須等寫入完成。若採用寫入成功後才關閉的設計,寫入失敗時應保持工作表開啟,並保留草稿以供再次嘗試。如果編輯期間,同一則筆記可能透過 App 所有的其他介面變更,請在取代它之前加入版本檢查。
核心設計很精簡:每個編輯工作階段建立一份草稿,讓編輯器修改草稿,並只提供一條明確的接受路徑。界線清楚後,即使編輯介面擴充,取消行為仍可預期。
來源
- TextEditor — Apple
- String — Apple
- State — Apple
- sheet(item:onDismiss:content:) — Apple
- DismissAction — Apple
- cancelAction — Apple
- defaultAction — Apple
- keyboardShortcut(_:modifiers:) — Apple
- init(text:selection:) — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- NSTextView — Apple