Use AppKit’s NSTokenField inside SwiftUI, returning a custom object for recognized labels and a string for ordinary text. Choose the display style per object so known labels appear as tokens while free text stays plain.

This guide builds a compact label editor in your own Mac app. Its synthetic vocabulary contains Cedar and Slate; people can also enter phrases such as draft notes. The editor reports its contents when editing ends. This is a field for mixed entries, with commas acting as separators—not a sentence editor that preserves every punctuation character.

Choose the control that matches the editing task

Apple describes NSTokenField as a text field that converts entered text into visually distinct tokens. Its delegate can supply represented objects, display strings, editable strings, and a separate style for each object. Those hooks provide the supported starting point for this interface. NSTokenField, NSTokenFieldDelegate.

Make one distinction before implementing it: accepting arbitrary entries and editing an arbitrary document are different tasks. A comma-separated label field can consume commas as separators. A template editor may need to retain commas, spaces, and text at exact positions around inline objects.

Reader’s editing task Appropriate starting point
Enter recognized labels alongside arbitrary text entries NSTokenField with per-object styles
Enter a single ordinary string SwiftUI TextField
Compose continuous text with inline objects and exact punctuation NSTextView with attributed text and attachments

SwiftUI documents TextField as an editable interface bound to a value. NSViewRepresentable supplies the documented bridge for placing an AppKit view into SwiftUI. For richer document editing, NSTextView supports rich text, attachments, selection, input management, and marked text. These capabilities support the choices above; the table is an implementation recommendation derived from them. TextField, NSViewRepresentable, NSTextView.

Keep token identity separate from ordinary text

Use an ordered model with two cases: a recognized token and literal text. This retains a distinction that would disappear if you flattened everything into one display string. Swift arrays preserve element order; Apple’s token-field guide specifies that objectValue contains an array of represented objects, including strings when strings are the represented values. Array, Getting and Setting Token-Field Values.

For this example, recognition is deliberately exact and case-sensitive. Cedar becomes a token; cedar remains text. The conversion does not trim, lowercase, reject, or deduplicate ordinary entries. That policy makes the example predictable without silently rewriting what someone entered.

The following three files form one complete example app. Create a macOS App project in Xcode using SwiftUI and a current macOS SDK. Replace the generated app entry point with the third file, and add the first two files to the same target. The code uses the current documented token-style cases, including .none and .rounded. Older examples may use different spellings; check the SDK you compile against. NSTokenField.TokenStyle.

Define the model and represented object

Put this in Entry.swift. The immutable LabelToken is the object AppKit receives for a recognized label. Entry is the value model returned to the SwiftUI interface.

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
}

Bridge the native field into SwiftUI

Put this in MixedEntryField.swift. The representable initializes the control once and keeps the callback current during SwiftUI updates. Its coordinator implements the AppKit delegate. This follows Apple’s documented creation, update, and coordinator lifecycle. SwiftUI controls the represented view’s layout, so the wrapper does not set its 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)
        }
    }
}

The conversion method returns the original editing string for an unrecognized entry. Apple explicitly documents returning the editing string when there is no custom represented object. The style delegate overrides the field-wide style for each object; recognized labels use .rounded, and strings use .none. Apple’s archived architecture guide also describes substituting plain-text tokens for encapsulated tokens through this delegate hook. representedObjectForEditing, styleForRepresentedObject, How Token Fields Work.

Both string-conversion methods matter. The display method supplies a readable label for a represented object. The editing method supplies its editable textual form; Apple documents returning nil there when a token should not be editable. This example returns the label instead. displayStringForRepresentedObject, editingStringForRepresentedObject.

Add a small interface for inspecting committed values

Put this in MixedTokenDemoApp.swift. Keep only one @main app declaration in the target. The second text field provides an explicit destination for moving keyboard focus out of the mixed field.

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

Commit changes without disturbing active editing

This example exposes a committed snapshot rather than a binding that changes on every keystroke. Apple defines controlTextDidEndEditing as the notification that editing finished and changes were committed. Reading the represented objects at that boundary gives the surrounding interface a clear point at which to use the result. controlTextDidEndEditing.

Avoid assigning objectValue unconditionally from updateNSView. Apple documents that setting this property while a cell is being edited aborts editing before assigning the new value. Reading it during editing also invokes validation. These are consequential operations, not passive synchronization. objectValue.

The wrapper therefore treats initialEntries as an initial seed. Changing that argument after creation does not reload the field. If your application later needs external replacements, design an explicit replacement operation with a policy for pending edits. Finish and accept the current edit, or deliberately discard it, before replacing the represented values.

The decoder also avoids compactMap: an unfamiliar represented object produces a failure instead of disappearing from the result. The interface reports that failure and leaves the field intact. This matters when extending the example with additional object types.

Verify the mixed-entry behavior on your target macOS versions

This manuscript’s code was inspected against the retrieved API declarations; it was not compiled or run here because the available environment is Linux without a Swift toolchain. Build and exercise the app on a Mac before incorporating it into a larger interface.

Use this diagnostic sequence to distinguish recognition, styling, and commit problems:

  1. Confirm that the initial Cedar object has a token appearance.
  2. Add Slate and type a comma. Confirm that the recognized label receives a token appearance.
  3. Add draft notes, then move focus to the second field. Confirm that the phrase remains plain and appears as text in the committed output.
  4. Enter lowercase cedar. Confirm that it remains text under the example’s exact-match policy.
  5. Edit an existing recognized label into an unknown phrase, finish editing, and check its resulting type.
  6. Remove every entry and finish editing. Confirm that the output reports an empty field.
  7. Try selection, deletion, undo, and text composition with the input methods you intend to support. Check that unrelated SwiftUI updates do not restart editing.

A comma is syntax in this example: it separates entries and is not preserved as literal content. Spaces are not configured as separators. Do not extend the delimiter set casually if phrases must contain the added characters. The delimiter set is controlled through tokenizingCharacterSet. tokenizingCharacterSet.

Do not use repeated Return presses as your only commit test. Apple’s archived description explains that Return can tokenize the current string or send the control’s action. Moving focus to another editable control makes this example’s end-editing boundary easier to inspect. The archive supplies behavioral background; current documentation supplies the Swift declarations used above. How Token Fields Work.

When should you use TextKit instead?

Choose the richer text-system path when the required result is continuous prose containing inline tokens at arbitrary positions. For example, preserving the exact punctuation in Arrange [Cedar], then add a note. requires a different editing contract from consuming commas between entries.

The supported foundation is NSTextView with attributed text and NSTextAttachment objects. Apple documents attachments as attribute values in an attributed string, with an attachment cell on macOS for drawing and mouse interaction. View-based attachment capabilities are additionally documented for macOS 12 and later. These APIs provide building blocks; they do not supply the complete token model, insertion policy, or export format for your app. NSTextView, NSTextAttachment.

Preserve the TextKit boundary when taking that route: Apple warns that explicitly accessing an NSTextView or text container’s layoutManager on macOS 12 and later switches it to compatibility mode using NSLayoutManager. Decide which text layout system your implementation uses before combining examples. NSTextView.

For a compact field of recognized labels and free-text entries, begin with the native token field and its delegate conversions. Keep ordinary text intact, make separators explicit, and read committed represented objects. Move to a document editor when exact inline text structure becomes part of the task.

Sources