Используйте AppKit NSTokenField в SwiftUI: для распознанных меток возвращайте пользовательский объект, а для обычного текста — строку. Задавайте стиль отображения для каждого объекта отдельно: известные метки будут выглядеть как токены, а произвольный текст останется обычным.

В этом руководстве создаётся компактный редактор меток для вашего приложения Mac. В его искусственном наборе значений есть Cedar и Slate; пользователи также могут вводить фразы, например draft notes. Редактор сообщает содержимое после завершения редактирования. Это поле для смешанных записей, в котором запятые служат разделителями, а не редактор предложений, сохраняющий все знаки пунктуации.

Выберите элемент управления, подходящий для задачи редактирования

Apple описывает NSTokenField как текстовое поле, которое преобразует введённый текст в визуально отличимые токены. Его делегат может предоставлять представленные объекты, строки отображения, редактируемые строки и отдельный стиль для каждого объекта. Эти возможности дают поддерживаемую отправную точку для такого интерфейса. NSTokenField, NSTokenFieldDelegate.

Перед реализацией проведите одно различие: приём произвольных записей и редактирование произвольного документа — разные задачи. Поле меток, разделённых запятыми, может использовать запятые как разделители. В редакторе шаблонов может потребоваться сохранять запятые, пробелы и текст в точных позициях вокруг встроенных объектов.

Задача редактирования Подходящая отправная точка
Вводить распознанные метки вместе с произвольными текстовыми записями NSTokenField с отдельными стилями для объектов
Вводить одну обычную строку SwiftUI TextField
Составлять непрерывный текст со встроенными объектами и точной пунктуацией NSTextView с форматированным текстом и вложениями

В документации SwiftUI TextField описан как редактируемый интерфейс, связанный со значением. NSViewRepresentable предоставляет документированный мост для размещения представления AppKit в SwiftUI. Для более сложного редактирования документов NSTextView поддерживает форматированный текст, вложения, выделение, управление вводом и помеченный текст. Эти возможности обосновывают приведённые выше варианты; таблица содержит рекомендации по реализации, выведенные из них. TextField, NSViewRepresentable, NSTextView.

Храните идентичность токена отдельно от обычного текста

Используйте упорядоченную модель с двумя вариантами: распознанный токен и обычный текст. Так сохраняется различие, которое исчезло бы при объединении всего в одну строку отображения. Массивы Swift сохраняют порядок элементов; в руководстве Apple по полям токенов указано, что objectValue содержит массив представленных объектов, включая строки, если представленными значениями служат строки. Array, Getting and Setting Token-Field Values.

В этом примере распознавание намеренно точное и чувствительное к регистру. Cedar становится токеном; cedar остаётся текстом. Преобразование не обрезает пробелы, не переводит текст в нижний регистр, не отклоняет и не удаляет дубликаты обычных записей. Такая политика делает пример предсказуемым и не переписывает незаметно введённый пользователем текст.

Следующие три файла составляют полноценное приложение-пример. Создайте в Xcode проект приложения macOS на SwiftUI с актуальным SDK для macOS. Замените созданную точку входа приложения третьим файлом и добавьте первые два файла в ту же цель сборки. В коде используются актуальные документированные варианты стиля токенов, в том числе .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. Представление создаёт элемент управления один раз и обновляет обратный вызов при обновлениях SwiftUI. Его координатор реализует делегат AppKit. Это соответствует документированному Apple жизненному циклу создания, обновления и координатора. 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 по архитектуре также описана замена инкапсулированных токенов обычным текстом с помощью этого метода делегата. representedObjectForEditing, styleForRepresentedObject, How Token Fields Work.

Важны оба метода преобразования строк. Метод отображения задаёт понятную подпись для представленного объекта. Метод редактирования задаёт его редактируемую текстовую форму; Apple указывает, что в этом методе нужно возвращать nil, если токен не должен редактироваться. В этом примере вместо этого возвращается метка. displayStringForRepresentedObject, editingStringForRepresentedObject.

Добавьте небольшой интерфейс для просмотра зафиксированных значений

Поместите этот код в MixedTokenDemoApp.swift. В цели сборки должна быть только одна декларация приложения @main. Второе текстовое поле позволяет явно перевести фокус клавиатуры из смешанного поля.

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.

Не присваивайте objectValue безусловно из updateNSView. По документации Apple, если задать это свойство во время редактирования ячейки, редактирование будет прервано до присваивания нового значения. Чтение этого свойства во время редактирования также запускает проверку. Это значимые операции, а не пассивная синхронизация. objectValue.

Поэтому оболочка использует initialEntries как начальное значение. Если изменить этот аргумент после создания, поле не загрузит новое значение. Если позже приложению потребуется внешняя замена содержимого, предусмотрите явную операцию замены и определите, что делать с ожидающими изменениями. Завершите и примите текущее редактирование либо намеренно отбросьте его перед заменой представленных значений.

Декодер также не использует compactMap: при неизвестном представленном объекте он возвращает ошибку вместо того, чтобы пропустить объект. Интерфейс сообщает об этой ошибке и оставляет содержимое поля без изменений. Это важно при расширении примера другими типами объектов.

Проверьте работу со смешанными записями в целевых версиях macOS

Код этой статьи был сверён с полученными объявлениями API; здесь он не компилировался и не запускался, поскольку доступная среда — Linux без инструментария Swift. Перед включением в более крупный интерфейс соберите и проверьте приложение на Mac.

Для выявления проблем с распознаванием, стилями и фиксацией используйте такую последовательность диагностики:

  1. Убедитесь, что исходный объект Cedar отображается как токен.
  2. Добавьте Slate и введите запятую. Убедитесь, что распознанная метка получила вид токена.
  3. Добавьте draft notes, затем переведите фокус на второе поле. Убедитесь, что фраза осталась обычным текстом и отображается как текст в зафиксированном результате.
  4. Введите строчное cedar. Убедитесь, что согласно политике точного совпадения из примера оно осталось текстом.
  5. Преобразуйте существующую распознанную метку в неизвестную фразу, завершите редактирование и проверьте тип результата.
  6. Удалите все записи и завершите редактирование. Убедитесь, что в результате указано пустое поле.
  7. Проверьте выделение, удаление, отмену и ввод текста с помощью методов ввода, которые планируете поддерживать. Убедитесь, что несвязанные обновления SwiftUI не перезапускают редактирование.

В этом примере запятая служит синтаксисом: она разделяет записи и не сохраняется как обычный текст. Пробелы не настроены как разделители. Не добавляйте новые разделители без необходимости, если фразы должны содержать эти символы. Набор разделителей задаётся через tokenizingCharacterSet. tokenizingCharacterSet.

Не проверяйте фиксацию изменений только многократным нажатием Return. В архивном описании Apple поясняется, что Return может превратить текущую строку в токен или вызвать действие элемента управления. Перевод фокуса на другой редактируемый элемент упрощает проверку границы завершения редактирования в этом примере. Архив даёт контекст поведения, а в приведённом выше коде используются объявления Swift из актуальной документации. How Token Fields Work.

Когда следует использовать TextKit?

Выберите более мощную систему работы с текстом, если нужен непрерывный текст со встроенными токенами в произвольных позициях. Например, сохранение точной пунктуации в Arrange [Cedar], then add a note. требует другого контракта редактирования, чем использование запятых как разделителей между записями.

Поддерживаемая основа для такого подхода — NSTextView с форматированным текстом и объектами NSTextAttachment. По документации Apple вложения являются значениями атрибутов форматированной строки; на macOS для их отображения и взаимодействия с мышью используется ячейка вложения. Возможности вложений на основе представлений также документированы для macOS 12 и новее. Эти API дают строительные блоки, но не предоставляют готовую модель токенов, правила вставки или формат экспорта для вашего приложения. NSTextView, NSTextAttachment.

Если вы выбрали этот путь, соблюдайте границы TextKit: Apple предупреждает, что явный доступ к layoutManager у NSTextView или текстового контейнера в macOS 12 и новее переводит его в режим совместимости с использованием NSLayoutManager. До объединения примеров определите, какую систему компоновки текста использует ваша реализация. NSTextView.

Для компактного поля с распознанными метками и произвольными текстовыми записями начните с нативного поля токенов и преобразований в делегате. Сохраняйте обычный текст, явно задавайте разделители и считывайте зафиксированные представленные объекты. Переходите к редактору документов, когда задача потребует точной структуры встроенного текста.

Источники