指定 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 中確認完成行為

建置完成後,請依照以下步驟檢查範例。這些是根據實作與文件所述的編輯行為推導出的預期檢查結果,並非實際執行結果。

  1. 將 Blue Card 替換成 Green Card。輸入期間,摘要應仍顯示 Card: Blue Card。
  2. 按 Return。摘要應變為 Card: Green Card。
  3. 再次編輯卡片標題,然後點擊 Shelf label 欄位。完成的卡片標題應出現在摘要中。
  4. 編輯架位值,然後按 Tab。確認架位摘要有更新,並檢查鍵盤導覽。
  5. 提交空值。對應的摘要應只顯示其前綴、不含值;本範例接受空字串。
  6. 結束編輯但不變更文字。相等性檢查應避免再次指派狀態。

按 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 更新期間保護草稿,讓這個界線足以支援日常的標籤編輯,無需自訂文字版面配置機制。

資料來源