在 SwiftUI 中使用 AppKit 的 NSTokenField,對辨識出的標籤傳回自訂物件,對一般文字則傳回字串。依物件選擇顯示樣式,讓已知標籤顯示為 token,而自由文字維持一般文字。

本指南會在你自己的 Mac app 中打造精簡的標籤編輯器。範例詞彙包含 Cedar 和 Slate;使用者也可以輸入 draft notes 之類的詞組。編輯結束時,編輯器會回報其內容。這是供混合項目使用的欄位,以逗號作為分隔符,而非保留所有標點符號的句子編輯器。

選擇符合編輯工作的控制項

Apple 將 NSTokenField 描述為一種文字欄位,可將輸入的文字轉換成視覺上有所區別的 token。它的委派可以提供代表物件、顯示字串、可編輯字串,以及每個物件各自的樣式。這些掛鉤為此介面提供了受支援的起點。NSTokenField、NSTokenFieldDelegate。

實作前先釐清一個區別:接受任意項目和編輯任意文件是兩種不同的工作。以逗號分隔的標籤欄位可以將逗號當作分隔符。範本編輯器則可能需要保留逗號、空格,以及行內物件周圍精確位置的文字。

讀者的編輯工作 合適的起點
將已辨識標籤與任意文字項目一起輸入 使用每個物件各自樣式的 NSTokenField
輸入單一一般字串 SwiftUI TextField
編寫包含行內物件和精確標點符號的連續文字 使用屬性化文字與附件的 NSTextView

SwiftUI 將 TextField 說明為綁定至某個值的可編輯介面。NSViewRepresentable 提供將 AppKit 檢視放入 SwiftUI 的文件化橋接方式。若要進行更豐富的文件編輯,NSTextView 支援富文字、附件、選取、輸入管理和標記文字。這些功能支援上述選擇;表格則是根據這些功能得出的實作建議。TextField、NSViewRepresentable、NSTextView。

將 token 身分與一般文字分開

使用有順序的模型,包含兩種類型:已辨識的 token 和字面文字。這能保留差異;若將所有內容攤平成單一顯示字串,這項差異就會消失。Swift 陣列會保留元素順序;Apple 的 token 欄位指南指出,objectValue 內含代表物件陣列,其中也包括以字串作為代表值的情況。Array、Getting and Setting Token-Field Values。

在這個範例中,辨識方式刻意採用完全相符且區分大小寫的規則。Cedar 會成為 token;cedar 則維持文字。轉換過程不會修剪、轉成小寫、拒絕或去除重複的一般項目。這項規則讓範例行為可預期,也不會暗中改寫使用者輸入的內容。

以下三個檔案構成一個完整的範例 app。在 Xcode 中使用 SwiftUI 和目前的 macOS SDK 建立 macOS App 專案。將產生的 app 進入點替換為第三個檔案,並將前兩個檔案加入同一個 target。程式碼使用目前文件化的 token 樣式列舉值,包括 .none 和 .rounded。較舊的範例可能使用不同的拼法;請確認你所使用的 SDK。NSTokenField.TokenStyle。

定義模型和代表物件

將以下內容放入 Entry.swift。不可變的 LabelToken 是 AppKit 接收的已辨識標籤物件。Entry 則是傳回 SwiftUI 介面的值模型。

import Foundation

enum Entry: Equatable {
    case token(String)
    case text(String)

    var summary: String {
        switch self {
        case .token(let value):
            return "Token: \(String(reflecting: value))"
        case .text(let value):
            return "Text: \(String(reflecting: value))"
        }
    }
}

final class LabelToken: NSObject {
    let label: String

    init(_ label: String) {
        self.label = label
        super.init()
    }
}

enum FieldReadError: Error {
    case unexpectedValue
    case unexpectedObject
}

將原生欄位橋接到 SwiftUI

將以下內容放入 MixedEntryField.swift。Representable 只會初始化控制項一次,並在 SwiftUI 更新期間保持回呼為最新狀態。它的 coordinator 會實作 AppKit 委派。這遵循 Apple 文件所述的建立、更新和 coordinator 生命週期。SwiftUI 會控制代表檢視的版面配置,因此包裝層不會設定其框架。NSViewRepresentable。

import AppKit
import SwiftUI

@MainActor
struct MixedEntryField: NSViewRepresentable {
    let initialEntries: [Entry]
    var onCommit: (Result<[Entry], FieldReadError>) -> Void

    func makeCoordinator() -> Coordinator {
        Coordinator(onCommit: onCommit)
    }

    func makeNSView(context: Context) -> NSTokenField {
        let field = NSTokenField()
        field.delegate = context.coordinator
        field.isEditable = true
        field.isSelectable = true
        field.placeholderString = "Cedar, Slate, or free text"
        field.tokenizingCharacterSet = CharacterSet(charactersIn: ",")
        field.setAccessibilityLabel("Labels and free text")
        field.objectValue = initialEntries.map { entry -> Any in
            switch entry {
            case .token(let label):
                return LabelToken(label)
            case .text(let text):
                return text
            }
        }
        return field
    }

    func updateNSView(_ field: NSTokenField, context: Context) {
        context.coordinator.onCommit = onCommit
    }

    final class Coordinator: NSObject, NSTokenFieldDelegate {
        var onCommit: (Result<[Entry], FieldReadError>) -> Void
        private let knownLabels: Set<String> = ["Cedar", "Slate"]

        init(onCommit: @escaping
             (Result<[Entry], FieldReadError>) -> Void) {
            self.onCommit = onCommit
            super.init()
        }

        func tokenField(
            _ tokenField: NSTokenField,
            representedObjectForEditing editingString: String
        ) -> Any? {
            if knownLabels.contains(editingString) {
                return LabelToken(editingString)
            }
            return editingString
        }

        func tokenField(
            _ tokenField: NSTokenField,
            styleForRepresentedObject object: Any
        ) -> NSTokenField.TokenStyle {
            object is LabelToken ? .rounded : .none
        }

        private func label(for object: Any) -> String? {
            if let token = object as? LabelToken {
                return token.label
            }
            return object as? String
        }

        func tokenField(
            _ tokenField: NSTokenField,
            displayStringForRepresentedObject object: Any
        ) -> String? {
            label(for: object)
        }

        func tokenField(
            _ tokenField: NSTokenField,
            editingStringForRepresentedObject object: Any
        ) -> String? {
            label(for: object)
        }

        func controlTextDidEndEditing(_ notification: Notification) {
            guard let field = notification.object as? NSTokenField else {
                return
            }
            onCommit(readEntries(from: field))
        }

        private func readEntries(
            from field: NSTokenField
        ) -> Result<[Entry], FieldReadError> {
            guard let value = field.objectValue else {
                return .success([])
            }
            guard let objects = value as? [Any] else {
                return .failure(.unexpectedValue)
            }

            var entries: [Entry] = []
            for object in objects {
                if let token = object as? LabelToken {
                    entries.append(.token(token.label))
                } else if let text = object as? String {
                    entries.append(.text(text))
                } else {
                    return .failure(.unexpectedObject)
                }
            }
            return .success(entries)
        }
    }
}

轉換方法會對未辨識的項目傳回原始編輯字串。Apple 明確說明,沒有自訂代表物件時應傳回編輯字串。樣式委派會針對每個物件覆寫欄位的整體樣式;已辨識的標籤使用 .rounded,字串使用 .none。Apple 已封存的架構指南也說明,可透過這個委派掛鉤,以純文字 token 取代封裝式 token。representedObjectForEditing、styleForRepresentedObject、How Token Fields Work。

兩個字串轉換方法都很重要。顯示方法會為代表物件提供易讀的標籤。編輯方法則會提供其可編輯的文字形式;Apple 文件說明,若 token 不應可編輯,可在此處傳回 nil。此範例則傳回標籤。displayStringForRepresentedObject、editingStringForRepresentedObject。

加入簡單介面以檢視已提交的值

將以下內容放入 MixedTokenDemoApp.swift。target 中只保留一個 @main app 宣告。第二個文字欄位提供明確的目的地,讓鍵盤焦點離開混合欄位。

import SwiftUI

@main
struct MixedTokenDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@MainActor
struct ContentView: View {
    @State private var focusDestination = ""
    @State private var output = "Finish editing to inspect the entries."

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Labels and free text")
                .font(.headline)

            MixedEntryField(
                initialEntries: [.token("Cedar")]
            ) { result in
                switch result {
                case .success(let entries):
                    output = entries.isEmpty
                        ? "No entries."
                        : entries.map(\.summary).joined(separator: "\n")
                case .failure:
                    output = "Could not read all entries. The field was kept."
                }
            }
            .frame(height: 28)

            Text("Use commas between entries; then move to the next field.")
                .font(.caption)

            TextField("Move focus here to finish editing",
                      text: $focusDestination)

            Text(output)
                .font(.system(.body, design: .monospaced))
                .textSelection(.enabled)
        }
        .padding(20)
        .frame(minWidth: 540, minHeight: 250)
    }
}

提交變更,同時避免干擾使用中的編輯

此範例公開已提交的快照,而不是每次按鍵都會變更的繫結。Apple 將 controlTextDidEndEditing 定義為編輯完成且變更已提交時發出的通知。在這個時點讀取代表物件,可讓周圍的介面明確知道何時能使用結果。controlTextDidEndEditing。

避免無條件地從 updateNSView 指派 objectValue。Apple 文件指出,若在儲存格編輯期間設定此屬性,會先中止編輯,再指派新值。在編輯期間讀取它也會觸發驗證。這些都是會造成實際影響的操作,而非被動同步。objectValue。

因此,包裝層會將 initialEntries 視為初始種子。建立後變更這個引數不會重新載入欄位。如果你的 app 之後需要外部替換值,請設計明確的替換操作,並訂定處理待處理編輯的規則。在替換代表值之前,先完成並接受目前的編輯,或明確捨棄它。

解碼器也會避免使用 compactMap:遇到不熟悉的代表物件時會產生失敗,而不會讓它從結果中消失。介面會回報該失敗,並保留欄位內容。當你為範例加入其他物件類型時,這點很重要。

在目標 macOS 版本上驗證混合項目的行為

本稿的程式碼已對照取得的 API 宣告進行檢視;此處未進行編譯或執行,因為可用環境是沒有 Swift 工具鏈的 Linux。將程式碼整合至較大的介面前,請先在 Mac 上建置並操作此 app。

使用以下診斷步驟,區分辨識、樣式和提交問題:

  1. 確認初始的 Cedar 物件呈現為 token。
  2. 加入 Slate 並輸入逗號。確認已辨識的標籤會呈現為 token。
  3. 加入 draft notes,然後將焦點移至第二個欄位。確認該詞組維持一般文字,並在已提交的輸出中以文字顯示。
  4. 輸入小寫的 cedar。確認根據範例的完全相符規則,它仍維持文字。
  5. 將現有的已辨識標籤編輯成未知詞組,完成編輯後檢查其結果類型。
  6. 移除所有項目並完成編輯。確認輸出顯示欄位為空。
  7. 使用你預定支援的輸入法,嘗試選取、刪除、復原和文字組合。確認無關的 SwiftUI 更新不會重新啟動編輯。

在此範例中,逗號是語法:它用來分隔項目,不會保留為字面內容。空格並未設為分隔符。若詞組必須包含你新增的字元,請勿隨意擴充分隔符集合。分隔符集合由 tokenizingCharacterSet 控制。tokenizingCharacterSet。

不要只用連續按 Return 作為提交測試。Apple 已封存的說明指出,Return 可能會將目前字串轉換為 token,或觸發控制項的動作。將焦點移至另一個可編輯控制項,能讓此範例更容易檢視結束編輯的時點。封存文件提供行為背景;上方使用的 Swift 宣告則來自目前文件。How Token Fields Work。

何時應改用 TextKit?

若需求是在連續散文中任意位置插入行內 token,請選擇更豐富的文字系統方案。例如,要在 Arrange [Cedar], then add a note. 中保留精確標點符號,需要不同於消耗項目間逗號的編輯規則。

受支援的基礎是使用屬性化文字和 NSTextAttachment 物件的 NSTextView。Apple 文件指出,附件是屬性化字串中的屬性值;在 macOS 上,附件儲存格負責繪圖和滑鼠互動。macOS 12 及更新版本也提供文件化的檢視式附件功能。這些 API 提供基本元件,但不會提供 app 所需的完整 token 模型、插入規則或匯出格式。NSTextView、NSTextAttachment。

採用此方案時,請留意 TextKit 的界線:Apple 警告,在 macOS 12 及更新版本中,明確存取 NSTextView 或文字容器的 layoutManager,會使其切換至使用 NSLayoutManager 的相容模式。在混用範例前,請先決定實作採用哪種文字排版系統。NSTextView。

若要建立包含已辨識標籤和自由文字項目的精簡欄位,請從原生 token 欄位及其委派轉換開始。保留一般文字,明確指定分隔符,並讀取已提交的代表物件。當精確的行內文字結構成為工作需求時,再改用文件編輯器。

來源