Use o NSTokenField do AppKit dentro do SwiftUI, retornando um objeto personalizado para rótulos reconhecidos e uma string para texto comum. Escolha o estilo de exibição para cada objeto, para que os rótulos conhecidos apareçam como tokens e o texto livre permaneça simples.

Este guia cria um editor compacto de rótulos para seu próprio app para Mac. Seu vocabulário sintético contém Cedar e Slate; as pessoas também podem inserir frases como draft notes. O editor informa seu conteúdo quando a edição termina. Este é um campo para entradas mistas, no qual as vírgulas funcionam como separadores — não um editor de frases que preserva todos os sinais de pontuação.

Escolha o controle adequado à tarefa de edição

A Apple descreve o NSTokenField como um campo de texto que converte o texto inserido em tokens visualmente distintos. Seu delegado pode fornecer objetos representados, strings de exibição, strings editáveis e um estilo separado para cada objeto. Esses recursos oferecem um ponto de partida compatível para esta interface. NSTokenField, NSTokenFieldDelegate.

Antes de implementá-lo, faça uma distinção: aceitar entradas arbitrárias e editar um documento arbitrário são tarefas diferentes. Um campo de rótulos separados por vírgulas pode consumir as vírgulas como separadores. Um editor de modelos talvez precise preservar vírgulas, espaços e texto em posições exatas ao redor de objetos incorporados.

Tarefa de edição do usuário Ponto de partida adequado
Inserir rótulos reconhecidos junto com entradas de texto arbitrárias NSTokenField com estilos por objeto
Inserir uma única string comum SwiftUI TextField
Compor texto contínuo com objetos incorporados e pontuação exata NSTextView com texto atribuído e anexos

A documentação do SwiftUI descreve TextField como uma interface editável vinculada a um valor. NSViewRepresentable fornece a ponte documentada para inserir uma visualização do AppKit no SwiftUI. Para edição de documentos mais avançada, NSTextView oferece suporte a texto formatado, anexos, seleção, gerenciamento de entrada e texto marcado. Esses recursos fundamentam as escolhas acima; a tabela é uma recomendação de implementação derivada deles. TextField, NSViewRepresentable, NSTextView.

Mantenha a identidade dos tokens separada do texto comum

Use um modelo ordenado com dois casos: um token reconhecido e texto literal. Isso preserva uma distinção que desapareceria se você reduzisse tudo a uma única string de exibição. Os arrays do Swift preservam a ordem dos elementos; o guia de campos de token da Apple especifica que objectValue contém um array de objetos representados, incluindo strings quando elas são os valores representados. Array, Getting and Setting Token-Field Values.

Neste exemplo, o reconhecimento é deliberadamente exato e diferencia maiúsculas de minúsculas. Cedar se torna um token; cedar permanece como texto. A conversão não remove espaços, não converte para minúsculas, não rejeita nem elimina entradas comuns repetidas. Essa política torna o exemplo previsível sem reescrever silenciosamente o que alguém inseriu.

Os três arquivos a seguir formam um exemplo completo de app. Crie um projeto de app para macOS no Xcode usando SwiftUI e um SDK atual do macOS. Substitua o ponto de entrada gerado do app pelo terceiro arquivo e adicione os dois primeiros arquivos ao mesmo destino. O código usa os casos de estilo de token documentados atualmente, incluindo .none e .rounded. Exemplos mais antigos podem usar grafias diferentes; confira o SDK com o qual você compila. NSTokenField.TokenStyle.

Defina o modelo e o objeto representado

Coloque este código em Entry.swift. O LabelToken imutável é o objeto que o AppKit recebe para um rótulo reconhecido. Entry é o modelo de valor retornado à interface do 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
}

Conecte o campo nativo ao SwiftUI

Coloque este código em MixedEntryField.swift. O representable inicializa o controle uma única vez e mantém o callback atualizado durante as atualizações do SwiftUI. Seu coordenador implementa o delegado do AppKit. Isso segue o ciclo de vida documentado pela Apple para criação, atualização e coordenação. O SwiftUI controla o layout da visualização representada, então o wrapper não define seu frame. 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)
        }
    }
}

O método de conversão retorna a string de edição original para uma entrada não reconhecida. A Apple documenta explicitamente que se deve retornar a string de edição quando não há um objeto representado personalizado. O delegado de estilo substitui o estilo geral do campo para cada objeto; os rótulos reconhecidos usam .rounded, e as strings usam .none. O guia de arquitetura arquivado da Apple também descreve a substituição de tokens encapsulados por tokens de texto simples por meio desse recurso do delegado. representedObjectForEditing, styleForRepresentedObject, How Token Fields Work.

Os dois métodos de conversão de strings são importantes. O método de exibição fornece um rótulo legível para um objeto representado. O método de edição fornece sua forma textual editável; a Apple documenta que se retorne nil nele quando um token não deve ser editável. Este exemplo retorna o rótulo. displayStringForRepresentedObject, editingStringForRepresentedObject.

Adicione uma pequena interface para inspecionar os valores confirmados

Coloque este código em MixedTokenDemoApp.swift. Mantenha apenas uma declaração @main no destino. O segundo campo de texto oferece um destino explícito para mover o foco do teclado para fora do campo misto.

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)
    }
}

Confirme as alterações sem interromper a edição ativa

Este exemplo expõe um instantâneo confirmado, em vez de um vínculo que muda a cada tecla pressionada. A Apple define controlTextDidEndEditing como a notificação de que a edição terminou e as alterações foram confirmadas. Ler os objetos representados nesse momento dá à interface ao redor um ponto claro para usar o resultado. controlTextDidEndEditing.

Evite atribuir objectValue incondicionalmente a partir de updateNSView. A Apple documenta que definir essa propriedade enquanto uma célula está em edição interrompe a edição antes de atribuir o novo valor. Lê-la durante a edição também invoca a validação. São operações com consequências, não uma sincronização passiva. objectValue.

Por isso, o wrapper trata initialEntries como um valor inicial. Alterar esse argumento após a criação não recarrega o campo. Se seu app precisar posteriormente de substituições externas, crie uma operação explícita de substituição com uma política para edições pendentes. Finalize e aceite a edição atual ou descarte-a deliberadamente antes de substituir os valores representados.

O decodificador também evita compactMap: um objeto representado desconhecido gera uma falha, em vez de desaparecer do resultado. A interface informa essa falha e mantém o campo intacto. Isso é importante ao estender o exemplo com outros tipos de objeto.

Verifique o comportamento de entradas mistas nas versões do macOS que você pretende usar

O código deste manuscrito foi conferido com base nas declarações de API consultadas; não foi compilado nem executado aqui porque o ambiente disponível é Linux e não tem um toolchain do Swift. Compile e teste o app em um Mac antes de incorporá-lo a uma interface maior.

Use esta sequência de diagnóstico para distinguir problemas de reconhecimento, estilo e confirmação:

  1. Confirme que o objeto inicial Cedar tem aparência de token.
  2. Adicione Slate e digite uma vírgula. Confirme que o rótulo reconhecido recebe aparência de token.
  3. Adicione draft notes e mova o foco para o segundo campo. Confirme que a frase permanece simples e aparece como texto na saída confirmada.
  4. Insira cedar em minúsculas. Confirme que permanece como texto segundo a política de correspondência exata do exemplo.
  5. Edite um rótulo reconhecido existente, transformando-o em uma frase desconhecida, finalize a edição e confira o tipo resultante.
  6. Remova todas as entradas e finalize a edição. Confirme que a saída informa que o campo está vazio.
  7. Teste seleção, exclusão, desfazer e composição de texto com os métodos de entrada que você pretende oferecer. Confira se atualizações não relacionadas do SwiftUI não reiniciam a edição.

Neste exemplo, a vírgula é sintaxe: ela separa as entradas e não é preservada como conteúdo literal. Os espaços não são configurados como separadores. Não amplie casualmente o conjunto de delimitadores se as frases precisarem conter os caracteres adicionados. O conjunto de delimitadores é controlado por tokenizingCharacterSet. tokenizingCharacterSet.

Não use várias pressões de Return como seu único teste de confirmação. A descrição arquivada da Apple explica que Return pode tokenizar a string atual ou acionar a ação do controle. Mover o foco para outro controle editável facilita a inspeção do limite de fim de edição deste exemplo. O arquivo fornece contexto sobre o comportamento; a documentação atual fornece as declarações do Swift usadas acima. How Token Fields Work.

Quando você deve usar o TextKit?

Escolha o caminho do sistema de texto mais avançado quando o resultado necessário for prosa contínua com tokens incorporados em posições arbitrárias. Por exemplo, preservar a pontuação exata em Arrange [Cedar], then add a note. exige um contrato de edição diferente do que consumir vírgulas entre entradas.

A base compatível é NSTextView com texto atribuído e objetos NSTextAttachment. A Apple documenta anexos como valores de atributos em uma string atribuída, com uma célula de anexo no macOS para desenho e interação com o mouse. Os recursos de anexos baseados em visualizações também estão documentados para macOS 12 e versões posteriores. Essas APIs fornecem blocos de construção; elas não fornecem o modelo completo de tokens, a política de inserção nem o formato de exportação do seu app. NSTextView, NSTextAttachment.

Preserve os limites do TextKit ao seguir esse caminho: a Apple alerta que acessar explicitamente o layoutManager de um NSTextView ou contêiner de texto no macOS 12 e versões posteriores o alterna para o modo de compatibilidade usando NSLayoutManager. Decida qual sistema de layout de texto sua implementação usará antes de combinar exemplos. NSTextView.

Para um campo compacto de rótulos reconhecidos e entradas de texto livre, comece pelo campo de tokens nativo e suas conversões de delegado. Mantenha o texto comum intacto, torne os separadores explícitos e leia os objetos representados confirmados. Passe para um editor de documentos quando a estrutura exata do texto embutido fizer parte da tarefa.

Fontes