Назначьте NSTextFieldDelegate, реализуйте controlTextDidEndEditing(_:) и скопируйте stringValue поля в привязку SwiftUI. Координатор связывает этот обратный вызов с состоянием представления, поэтому метка может обновиться после завершения редактирования.

В этом руководстве создаётся небольшое приложение для Mac с двумя редактируемыми метками и сводкой, показывающей зафиксированные значения. Задача состоит в том, чтобы сводка оставалась неизменной, пока пользователь печатает, а затем обновлялась после завершения сеанса редактирования в AppKit. Apple указывает, что обратный вызов завершения редактирования происходит после того, как элемент управления фиксирует изменения. Документация Apple по controlTextDidEndEditing(_:)

Какое событие должно обновлять представление?

Для существующего NSTextField используйте метод делегата, вызываемый при завершении редактирования элемента управления. Делегат получает уведомление, свойство object которого содержит элемент управления, отправивший его, поэтому обработчик может определить поле и прочитать его значение. Документация Apple по textDidEndEditingNotification

Выберите событие в соответствии с нужным поведением интерфейса:

Требуемое поведение Подходящий способ
Обновлять зафиксированное значение после завершения редактирования текстового поля AppKit Реализовать controlTextDidEndEditing(_:) в его делегате.
Вызывать существующий обработчик AppKit target-action по завершении редактирования Настроить свойство ячейки sendsActionOnEndEditing.
Обрабатывать отправку из нативного SwiftUI TextField Добавить .onSubmit для документированного поведения отправки.

У этих способов разные контракты. Apple указывает, что при включённом sendsActionOnEndEditing действие отправляется после нажатия Return, перехода из поля по Tab или щелчка в другом текстовом поле; если параметр выключен, действие отправляется только после Return. В документации SwiftUI указано, что .onSubmit для TextField реагирует на Return. Следовательно, он не задаёт общий обратный вызов при потере фокуса для встроенного поля AppKit. Документация Apple по sendsActionOnEndEditing, документация Apple по onSubmit(of:_:)

В этом приложении делегат — единственное место, где фиксируется текст поля. Если позже вы добавите обработчик target-action, не выполняйте один и тот же побочный эффект фиксации в обоих обработчиках.

Создайте полноценный пример SwiftUI и AppKit

Создайте в Xcode проект приложения для macOS на Swift с интерфейсом SwiftUI. Для этого примера используйте актуальную цепочку инструментов с поддержкой Swift 6 и установите целевую версию macOS 13 или новее. Замените сгенерированный файл точки входа приложения приведённой ниже программой и удалите сгенерированный 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
            }
        }
    }
}

Это законченный пример приложения с синтетическими локальными значениями. Его сверили с полученной документацией API, но здесь не компилировали и не запускали: доступная среда выполнения — Linux, в ней нет цепочки инструментов Swift и SDK macOS. Соберите и проверьте приложение в Xcode на Mac.

Как значение попадает в сводку

Каждое значение @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.
Поле редактируется Сохранить буфер редактирования.
Редактирование завершено, и текст изменился Записать итоговый текст в привязку.
Редактирование завершено, но текст не изменился Пропустить избыточное присваивание состояния.

Эта политика отдаёт приоритет локальному черновику на время сеанса редактирования. Если другой код приложения изменит ту же привязку, пока пользователь редактирует поле, последующая фиксация может перезаписать это изменение. В демонстрации нет конкурирующего источника изменений. В более крупном редакторе решите, как обрабатывать одновременные изменения: например, завершать текущий сеанс перед переключением выбранного элемента или хранить черновое и зафиксированное значения отдельно и предлагать пользователю сделать явный выбор. Не удаляйте незавершённый текст только ради синхронизации перерисовки.

Для этой задачи настройка раскладки TextKit не нужна. Важна граница сеанса редактирования элемента управления и его редактора поля. В руководстве Apple по архитектуре текста объясняется связь между NSTextField, его делегатом и общим редактором поля NSTextView; это руководство находится в архиве, а актуальная справка по currentEditor() подтверждает модель общего редактора. Руководство Apple «Text Editing», актуальная справка Apple по редактору поля

Проверьте завершение редактирования в приложении

После сборки выполните следующие действия, чтобы проверить пример. Это ожидаемые проверки, основанные на реализации и документированном поведении редактирования, а не результаты фактического запуска.

  1. Замените Blue Card на Green Card. Пока вы печатаете, в сводке должно оставаться Card: Blue Card.
  2. Нажмите Return. Сводка должна измениться на Card: Green Card.
  3. Снова отредактируйте заголовок карточки, затем щёлкните поле Shelf label. В сводке должно появиться завершённое название карточки.
  4. Измените значение полки, затем нажмите Tab. Убедитесь, что сводка полки обновилась, и проверьте навигацию с клавиатуры.
  5. Зафиксируйте пустое значение. В соответствующей сводке должен отображаться префикс без значения; пример допускает пустые строки.
  6. Завершите редактирование, не меняя текст. Проверка равенства должна предотвратить повторное присваивание состояния.

Нажатие Return может завершить редактирование, не вызвав ожидаемого перемещения фокуса. Apple указывает, что после уведомления о завершении редактирования NSTextField пытается выполнить своё действие и другие действия, связанные с Return, а также может выделить текст. Нажатия Tab и Shift-Tab пытаются перейти к следующему или предыдущему элементу клавиатурного фокуса. При проверке интерфейса рассматривайте завершение редактирования и перемещение фокуса как отдельные поведения. Документация Apple по textDidEndEditing(_:)

Если обратный вызов не срабатывает

Сначала проверьте, что field.delegate по-прежнему указывает на координатор. Это слабая ссылка, поэтому временный объект делегата без отдельного владельца не будет существовать достаточно долго. В этом случае координатор representable обеспечивает нужное время жизни. Документация Apple по delegate, пояснение Apple о жизненном цикле координатора

Затем убедитесь, что редактирование действительно завершилось. Щелчок по произвольной области фона — ненадёжная проверка этого условия. В этой демонстрации используйте Return или другое редактируемое поле. Если результат отличается, проверьте конкретный переход между объектами-ответчиками.

Если приложение выполняет проверку данных, проверьте control(_:textShouldEndEditing:). Документированное возвращаемое значение этого метода определяет, может ли редактирование завершиться. Отклонённую попытку покинуть поле не следует считать успешной фиксацией. Документация Apple по control(_:textShouldEndEditing:)

Также проверьте сценарии отмены. Apple прямо указывает, что вызов abortEditing() для отмены ожидающих изменений не вызывает controlTextDidEndEditing(_:). Поэтому для операции отмены нужно отдельно определить поведение состояния. Документация Apple по обратному вызову завершения редактирования

Запросите завершение редактирования явной командой приложения

Для будущей команды Done, у которой есть ссылка на это поле, поддерживаемый способ работы с объектами-ответчиками — попросить собственное окно поля вызвать makeFirstResponder(nil). Прежде чем продолжить, проверьте возвращённое логическое значение: Apple указывает, что текущий объект-ответчик может отказаться уступить статус, и тогда метод вернёт false. Используйте фактическое окно поля, не предполагая, что ключевое окно приложения относится к этому редактору. Документация Apple по makeFirstResponder(_:)

Обращайтесь к AppKit и вызывайте обработчики интерфейса этого моста на главном акторе. Текущее объявление делегата изолировано главным актором, а в документации Swift указано, что MainActor использует исполнитель, эквивалентный главной очереди отправки. В примере эта изоляция задана напрямую через @MainActor; между завершением редактирования и записью в привязку отложенная задача не добавляется. Объявление обратного вызова Apple, документация Swift по MainActor

В реализации есть чёткая граница фиксации: AppKit завершает редактирование, координатор считывает поле, а привязка обновляет зависимый текст SwiftUI. Если защищать черновик от обновлений representable, этот подход хорошо подходит для повседневного редактирования меток и не требует настройки раскладки текста.

Источники