指定 NSTextFieldDelegate、實作 controlTextDidEndEditing(_:),並將欄位的 stringValue 複製到 SwiftUI 綁定。協調器會將該回呼連接到檢視狀態,因此編輯結束後,標籤即可更新。
本教學會建立一個小型 Mac App,包含兩個可編輯標籤,以及一個顯示其已提交值的摘要。目標是在使用者輸入時保持摘要不變,並在 AppKit 結束編輯工作階段時更新摘要。Apple 文件指出,控制項提交變更後會呼叫編輯結束回呼。Apple 的 controlTextDidEndEditing(_:) 文件
哪個事件應該用來更新檢視?
對於現有的 NSTextField,請使用其控制項編輯委派回呼。委派會收到一則通知,其 object 是發出通知的控制項,因此處理常式可以識別欄位並讀取其值。Apple 的 textDidEndEditingNotification 文件
請根據介面所需的行為選擇事件:
| 所需行為 | 適用方式 |
|---|---|
| AppKit 文字欄位完成編輯時更新已提交的值 | 在其委派上實作 controlTextDidEndEditing(_:)。 |
| 編輯完成時呼叫現有的 AppKit target-action 處理常式 | 設定 cell 的 sendsActionOnEndEditing 屬性。 |
處理原生 SwiftUI TextField 的提交 |
為其附加 .onSubmit,以使用文件所述的提交行為。 |
這些方式的行為契約各不相同。Apple 文件指出,啟用 sendsActionOnEndEditing 後,按 Return、按 Tab 離開欄位,或點擊另一個文字欄位時,都會發出 action;停用後,只有按 Return 才會發出 action。SwiftUI 文件說明,TextField 的 .onSubmit 會在按下 Return 時回應。因此,它並未為內嵌的 AppKit 欄位提供通用的失去焦點回呼。Apple 的 sendsActionOnEndEditing 文件、Apple 的 onSubmit(of:_:) 文件
在此 App 中,委派是提交欄位文字的唯一位置。若之後也加入 target-action 處理常式,請避免在兩個處理常式中執行相同的提交副作用。
建立完整的 SwiftUI 與 AppKit 範例
在 Xcode 中使用 Swift 和 SwiftUI 介面建立 macOS App 專案。本範例請使用支援 Swift 6 的近期工具鏈,並將部署目標設為 macOS 13 或更新版本。以以下完整程式取代產生的 App 進入點檔案;如果產生的 ContentView.swift 也宣告了另一個 ContentView,請將其移除。確保只有一個 @main 進入點。Apple 的 App 文件說明了這種進入點結構。Apple 的 App 文件
本範例選擇 macOS 13 作為目標版本,這並非橋接功能的推出版本:Apple 列出的 NSViewRepresentable 可用起始版本為 macOS 10.15。AppKit 使此實作只能用於 macOS 目標。Apple 的 NSViewRepresentable 文件
import SwiftUI
import AppKit
@main
@MainActor
struct EditingDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@MainActor
struct ContentView: View {
@State private var cardTitle = "Blue Card"
@State private var shelfLabel = "Shelf A"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Card title")
CommitTextField(value: $cardTitle, label: "Card title")
.frame(height: 24)
Text("Shelf label")
CommitTextField(value: $shelfLabel, label: "Shelf label")
.frame(height: 24)
Divider()
Text("Committed values")
.font(.headline)
Text("Card: \(cardTitle)")
Text("Shelf: \(shelfLabel)")
}
.padding(20)
.frame(minWidth: 420)
}
}
@MainActor
struct CommitTextField: NSViewRepresentable {
@Binding var value: String
let label: String
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField(string: value)
field.placeholderString = label
field.delegate = context.coordinator
return field
}
func updateNSView(_ field: NSTextField, context: Context) {
context.coordinator.parent = self
field.placeholderString = label
field.isEnabled = context.environment.isEnabled
// Leave the active editing buffer alone.
if field.currentEditor() == nil, field.stringValue != value {
field.stringValue = value
}
}
static func dismantleNSView(
_ field: NSTextField,
coordinator: Coordinator
) {
field.delegate = nil
}
@MainActor
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: CommitTextField
init(parent: CommitTextField) {
self.parent = parent
super.init()
}
func controlTextDidEndEditing(_ notification: Notification) {
guard let field = notification.object as? NSTextField else {
return
}
let committedValue = field.stringValue
if parent.value != committedValue {
parent.value = committedValue
}
}
}
}
這是一個使用合成區域值的完整 App 範例。我們已根據取得的 API 文件檢視此範例,但未在此處編譯或執行:目前可用的執行環境是 Linux,沒有 Swift 工具鏈或 macOS SDK。請在 Mac 上使用 Xcode 建置並測試。
值如何傳到摘要
每個父層的 @State 值都會提供一個綁定給欄位包裝器。編輯結束時,協調器會透過該綁定寫入值。接著 SwiftUI 會因為讀取到變更後的狀態而更新摘要。Apple 文件說明了由狀態驅動的檢視更新,以及如何使用綁定讓子檢視修改由父層持有的狀態。Apple 的 State 文件
makeNSView 會建立可編輯的 AppKit 控制項,並將協調器指定為其委派。updateNSView 處理從 SwiftUI 傳入該控制項的變更;委派回呼則處理反向傳遞的值。Apple 的整合指引在承載 AppKit 檢視時,也採用相同的協調器與綁定配置。Apple 的「Use SwiftUI with AppKit」講座
指派 context.coordinator.parent = self 可讓協調器儲存的 representable 保持為最新狀態。協調器會在承載檢視的生命週期內持續存在,而 representable 的值可能會改變。Apple 明確示範在 updateNSView 期間更新該儲存值。Apple 對協調器生命週期的說明
SwiftUI 更新時保留草稿
關鍵的防護條件是 field.currentEditor() == nil。本範例刻意將尚未完成的文字保留在 AppKit 中,同時讓 SwiftUI 保留上一次提交的值。若沒有編輯中防護,在 SwiftUI 更新期間將已提交的值複製到欄位,可能會取代尚未完成的草稿。
Apple 文件指出,currentEditor() 會傳回控制項的欄位編輯器;若沒有編輯器,則傳回 nil。欄位編輯器是由視窗中的多個控制項共用的 NSTextView。因此,只檢查視窗的 firstResponder 是否等於 NSTextField,並不能取代直接詢問控制項的編輯器狀態。Apple 的 currentEditor() 文件
因此,範例採用以下明確的設計策略:
| 情況 | 範例行為 |
|---|---|
| 欄位閒置且其綁定值改變 | 在下一次 representable 更新時,將綁定值複製到欄位。 |
| 欄位有作用中的編輯器 | 保留編輯緩衝區。 |
| 編輯結束時文字不同 | 將完成的文字寫入綁定。 |
| 編輯結束時文字未變 | 略過多餘的狀態指派。 |
此策略讓本機草稿在編輯工作階段中優先。如果使用者編輯時,App 的其他程式碼也變更了相同的綁定值,稍後提交時可能會取代該變更。本範例沒有其他寫入者。在較大型的編輯器中,請決定如何處理並行變更,例如在切換選取項目前先結束目前的工作階段,或分別保留草稿與已提交的值,並提供明確選項。不要只為了同步重新繪製而丟棄未完成的文字。
此任務不需要自訂 TextKit 版面配置。相關界線是控制項的編輯工作階段及其欄位編輯器。Apple 的文字架構指南說明 NSTextField、其委派,以及共用的 NSTextView 欄位編輯器之間的關係;該指南已封存,而目前的 currentEditor() 參考資料則確認了共用編輯器模型。Apple 的「Text Editing」指南、Apple 目前的欄位編輯器參考資料
在 App 中確認完成行為
建置完成後,請依照以下步驟檢查範例。這些是根據實作與文件所述的編輯行為推導出的預期檢查結果,並非實際執行結果。
- 將
Blue Card替換成Green Card。輸入期間,摘要應仍顯示Card: Blue Card。 - 按 Return。摘要應變為
Card: Green Card。 - 再次編輯卡片標題,然後點擊
Shelf label欄位。完成的卡片標題應出現在摘要中。 - 編輯架位值,然後按 Tab。確認架位摘要有更新,並檢查鍵盤導覽。
- 提交空值。對應的摘要應只顯示其前綴、不含值;本範例接受空字串。
- 結束編輯但不變更文字。相等性檢查應避免再次指派狀態。
按 Return 可能會結束編輯,但不會產生你預期的焦點移動。Apple 文件指出,收到編輯結束通知後,NSTextField 會嘗試執行其 action 和其他 Return 處理,且可能會選取其文字。按 Tab 和 Shift-Tab 則會嘗試移至下一個或上一個 key view。檢查介面時,請將完成編輯與焦點目的地視為不同的行為。Apple 的 textDidEndEditing(_:) 文件
診斷未收到回呼的情況
首先,確認 field.delegate 仍指向協調器。該屬性是弱參照,因此沒有其他持有者的暫時委派物件不會持續存在。representable 協調器提供此處所需的生命週期。Apple 的 delegate 文件、Apple 對協調器生命週期的說明
接著,確認編輯確實已結束。在任意背景上點擊,並不能可靠地測試這個條件。在此範例中請使用 Return 或另一個可編輯欄位;如果結果不同,請檢查具體的 responder 狀態轉換。
如果你的 App 加入了驗證,請檢查 control(_:textShouldEndEditing:)。其文件所述的傳回值會決定是否可以結束編輯。若離開欄位的嘗試遭拒,便不應視為成功提交。Apple 的 control(_:textShouldEndEditing:) 文件
也請檢查取消路徑。Apple 明確指出,呼叫 abortEditing() 丟棄待處理的編輯時,不會呼叫 controlTextDidEndEditing(_:)。因此,取消操作需要有自己明確的狀態處理方式。Apple 的編輯結束回呼文件
透過明確的 App 指令要求完成編輯
如果未來的 Done 指令持有此欄位的參照,支援的 responder 做法是要求欄位所屬的視窗呼叫 makeFirstResponder(nil)。請在繼續之前檢查傳回的布林值:Apple 文件指出,目前的 responder 可能拒絕放棄第一響應者身分,導致此方法傳回 false。請使用欄位實際所屬的視窗,不要假設 App 層級的 key window 就是此編輯器所屬的視窗。Apple 的 makeFirstResponder(_:) 文件
請將此橋接中的 AppKit 存取和 UI 回呼放在主執行緒隔離的 actor 上。目前的委派宣告已隔離於主 actor,而 Swift 文件指出,MainActor 使用的執行器等同於主 dispatch queue。範例直接以 @MainActor 表達這項隔離;從編輯完成到寫入綁定之間不會加入延遲執行的 task。Apple 的回呼宣告、Swift 的 MainActor 文件
此實作只有一個明確的提交界線:AppKit 結束編輯,協調器讀取欄位,綁定更新相依的 SwiftUI 文字。在 representable 更新期間保護草稿,讓這個界線足以支援日常的標籤編輯,無需自訂文字版面配置機制。
資料來源
- controlTextDidEndEditing(_:) — Apple 開發者文件
- textDidEndEditingNotification — Apple 開發者文件
- sendsActionOnEndEditing — Apple 開發者文件
- onSubmit(of:_:) — Apple 開發者文件
- App — Apple 開發者文件
- NSViewRepresentable — Apple 開發者文件
- State — Apple 開發者文件
- Use SwiftUI with AppKit — Apple 開發者
- currentEditor() — Apple 開發者文件
- Text Editing — Apple 文件封存庫
- textDidEndEditing(_:) — Apple 開發者文件
- delegate — Apple 開發者文件
- control(_:textShouldEndEditing:) — Apple 開發者文件
- makeFirstResponder(_:) — Apple 開發者文件
- MainActor — Apple 文件