Asigna un NSTextFieldDelegate, implementa controlTextDidEndEditing(_:) y copia el stringValue del campo en un enlace de SwiftUI. Un coordinador conecta esa devolución de llamada con el estado de la vista, de modo que una etiqueta pueda actualizarse cuando termine la edición.
Este tutorial crea una pequeña app para Mac con dos etiquetas editables y un resumen que muestra sus valores confirmados. La tarea consiste en mantener el resumen sin cambios mientras alguien escribe y actualizarlo cuando AppKit termine la sesión de edición. Apple documenta que la devolución de llamada de fin de edición ocurre después de que el control confirma sus cambios. Documentación de controlTextDidEndEditing(_:) de Apple
¿Qué evento debería actualizar la vista?
Para un NSTextField existente, usa la devolución de llamada del delegado para la edición del control. El delegado recibe una notificación cuyo object es el control que la publicó, lo que permite al controlador identificar el campo y leer su valor. Documentación de textDidEndEditingNotification de Apple
Elige el evento según el comportamiento de interfaz que necesites:
| Comportamiento deseado | Opción adecuada |
|---|---|
| Actualizar un valor confirmado cuando un campo de texto de AppKit termina de editarse | Implementa controlTextDidEndEditing(_:) en su delegado. |
| Enviar un controlador target-action existente de AppKit al completar la edición | Configura la propiedad sendsActionOnEndEditing de la celda. |
Gestionar el envío desde un TextField nativo de SwiftUI |
Añade .onSubmit para el comportamiento de envío documentado. |
Estas opciones tienen contratos distintos. Apple documenta que, al habilitar sendsActionOnEndEditing, la acción se envía después de pulsar Retorno, salir del campo con Tab o hacer clic en otro campo de texto; al deshabilitarla, la acción se limita a Retorno. SwiftUI documenta que .onSubmit para un TextField responde a Retorno. Por tanto, no establece una devolución de llamada general al perder el foco para un campo de AppKit integrado. Documentación de sendsActionOnEndEditing de Apple, documentación de onSubmit(of:_:) de Apple
En esta app, el delegado es el único lugar donde se confirma el texto de un campo. Si más adelante también añades un controlador target-action, evita realizar el mismo efecto secundario de confirmación en ambos controladores.
Crea un ejemplo completo de SwiftUI y AppKit
Crea en Xcode un proyecto de app para macOS con Swift y la interfaz de SwiftUI. Usa una cadena de herramientas reciente compatible con Swift 6 y establece como destino de implementación macOS 13 o posterior para este ejemplo. Sustituye el archivo de entrada generado de la app por el programa completo que aparece a continuación y elimina el ContentView.swift generado si declara otro ContentView. Mantén exactamente un punto de entrada @main. La documentación de App de Apple describe esta estructura de punto de entrada. Documentación de App de Apple
El destino macOS 13 del ejemplo es una decisión del proyecto, no la versión en la que se introdujo el puente: Apple indica que NSViewRepresentable está disponible desde macOS 10.15. AppKit hace que esta implementación sea específica para un destino macOS. Documentación de NSViewRepresentable de Apple
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
}
}
}
}
Este es un ejemplo completo de app con valores locales ficticios. Se revisó comparándolo con la documentación de API consultada, pero aquí no se compiló ni ejecutó: el entorno de ejecución disponible es Linux y no cuenta con una cadena de herramientas Swift ni con el SDK de macOS. Compílalo y pruébalo en Xcode en un Mac.
Cómo llega el valor al resumen
Cada valor @State del padre proporciona un enlace al envoltorio de su campo. El coordinador escribe mediante ese enlace cuando termina la edición. SwiftUI actualiza entonces el resumen porque lee el estado modificado. Apple documenta tanto las actualizaciones de vistas impulsadas por el estado como el uso de enlaces para que las vistas secundarias modifiquen el estado propiedad de la vista padre. Documentación de State de Apple
makeNSView crea el control editable de AppKit y asigna el coordinador como su delegado. updateNSView gestiona los cambios que fluyen desde SwiftUI hacia ese control. La devolución de llamada del delegado gestiona la dirección inversa. La guía de integración de Apple usa esta misma disposición de coordinador y enlace para alojar vistas de AppKit. Sesión de Apple «Usa SwiftUI con AppKit»
La asignación context.coordinator.parent = self mantiene actualizado el representable almacenado en el coordinador. Un coordinador dura lo mismo que la vista alojada, mientras que los valores del representable pueden cambiar. Apple muestra explícitamente cómo actualizar ese valor almacenado durante updateNSView. Explicación de Apple sobre el ciclo de vida del coordinador
Conserva el borrador durante las actualizaciones de SwiftUI
La protección esencial es field.currentEditor() == nil. Este ejemplo mantiene deliberadamente el texto sin terminar dentro de AppKit, mientras SwiftUI conserva el último valor confirmado. Sin una protección durante la edición, copiar el valor confirmado en el campo durante una actualización de SwiftUI podría reemplazar el borrador sin terminar.
Apple documenta que currentEditor() devuelve el editor del campo del control, o nil si no tiene uno. El editor del campo es un NSTextView compartido entre los controles de la ventana. Por eso, comprobar únicamente si el firstResponder de la ventana es igual al NSTextField es un sustituto poco adecuado de preguntarle al control por su editor. Documentación de currentEditor() de Apple
El comportamiento resultante es una política de diseño explícita:
| Situación | Comportamiento del ejemplo |
|---|---|
| Un campo está inactivo y su enlace cambia | Copiar el enlace en el campo durante la siguiente actualización del representable. |
| Un campo tiene un editor activo | Conservar el búfer de edición. |
| La edición termina con un texto distinto | Escribir el texto terminado en el enlace. |
| La edición termina con el mismo texto | Omitir la asignación de estado redundante. |
Esta política da prioridad al borrador local durante una sesión de edición. Si otro código de la app cambia el mismo enlace mientras la persona está editando, una confirmación posterior puede sobrescribir ese cambio. La demostración no tiene ningún otro escritor. En un editor más grande, decide cómo resolver los cambios simultáneos; por ejemplo, termina la sesión actual antes de cambiar el elemento seleccionado, o conserva valores separados para el borrador y el valor confirmado y ofrece una opción explícita. No descartes texto sin terminar solo para sincronizar un redibujado.
Esta tarea no requiere personalizar el diseño de TextKit. El límite pertinente es la sesión de edición del control y su editor del campo. La guía de arquitectura de texto de Apple explica la relación entre NSTextField, su delegado y el NSTextView compartido como editor del campo; esa guía está archivada, mientras que la referencia actual de currentEditor() confirma el modelo de editor compartido. Guía de Apple «Edición de texto», referencia actual de Apple sobre el editor del campo
Verifica el comportamiento al completar la edición en la app
Sigue esta secuencia para comprobar el ejemplo después de compilarlo. Son comprobaciones esperadas derivadas de la implementación y del comportamiento de edición documentado, no resultados de ejecución observados.
- Sustituye
Blue CardporGreen Card. Mientras escribes, el resumen debería seguir mostrandoCard: Blue Card. - Pulsa Retorno. El resumen debería cambiar a
Card: Green Card. - Vuelve a editar el título de la tarjeta y luego haz clic en el campo
Shelf label. El título terminado de la tarjeta debería aparecer en el resumen. - Edita el valor del estante y luego pulsa Tab. Confirma que el resumen del estante se actualiza y revisa la navegación con el teclado.
- Confirma un valor vacío. El resumen correspondiente debería mostrar su prefijo sin ningún valor; el ejemplo acepta cadenas vacías.
- Termina una edición sin cambiar el texto. La comprobación de igualdad debería evitar otra asignación de estado.
Retorno puede completar la edición sin producir el movimiento del foco que esperas. Apple documenta que, después de su notificación de fin de edición, NSTextField intenta ejecutar su acción y gestionar otras acciones de Retorno, y puede seleccionar el texto. Tab y Mayús-Tab intentan navegar a la vista clave siguiente o anterior. Al comprobar la interfaz, trata la finalización y el destino del foco como comportamientos independientes. Documentación de textDidEndEditing(_:) de Apple
Diagnostica una devolución de llamada que no llega
Primero, verifica que field.delegate siga haciendo referencia al coordinador. La propiedad es débil, por lo que un objeto delegado temporal sin un propietario independiente no seguirá disponible. El coordinador del representable proporciona aquí el ciclo de vida necesario. Documentación de delegate de Apple, explicación de Apple sobre el ciclo de vida del coordinador
A continuación, confirma que la edición haya terminado realmente. Hacer clic en un fondo cualquiera no es una prueba fiable de esta condición. En esta demostración, usa Retorno u otro campo editable y, si el resultado es distinto, inspecciona la transición específica del respondedor.
Si tu app añade validación, comprueba control(_:textShouldEndEditing:). Su valor de retorno documentado determina si la edición puede terminar. Un intento rechazado de salir del campo no debería tratarse como una confirmación correcta. Documentación de control(_:textShouldEndEditing:) de Apple
Inspecciona también las rutas de cancelación. Apple afirma explícitamente que llamar a abortEditing() para descartar ediciones pendientes no invoca controlTextDidEndEditing(_:). Por tanto, una operación de cancelación necesita un comportamiento propio y deliberado para el estado. Documentación de Apple sobre la devolución de llamada de fin de edición
Solicita completar la edición con un comando explícito de la app
Para un futuro comando Done que tenga una referencia a este campo, la ruta compatible del sistema de respondedores consiste en pedirle a su propia ventana que llame a makeFirstResponder(nil). Comprueba el valor booleano devuelto antes de continuar: Apple documenta que el respondedor actual puede negarse a ceder el rol de respondedor, lo que hace que el método devuelva false. Usa la ventana real del campo en vez de dar por sentado que la ventana clave de toda la app pertenece a este editor. Documentación de makeFirstResponder(_:) de Apple
Mantén el acceso a AppKit y las devoluciones de llamada de la interfaz de este puente en el actor principal. La declaración actual del delegado está aislada al actor principal, y Swift documenta que MainActor usa un ejecutor equivalente a la cola principal de despacho. El ejemplo expresa directamente ese aislamiento con @MainActor; no añade una tarea diferida entre la finalización y la escritura en el enlace. Declaración de la devolución de llamada de Apple, documentación de MainActor de Swift
La implementación tiene un único límite claro para confirmar el valor: AppKit termina la edición, el coordinador lee el campo y el enlace actualiza el texto dependiente de SwiftUI. Proteger el borrador durante las actualizaciones del representable hace que ese límite resulte práctico para editar etiquetas cotidianamente, sin necesidad de mecanismos personalizados para el diseño del texto.
Fuentes
- controlTextDidEndEditing(_:) — Documentación para desarrolladores de Apple
- textDidEndEditingNotification — Documentación para desarrolladores de Apple
- sendsActionOnEndEditing — Documentación para desarrolladores de Apple
- onSubmit(of:_:) — Documentación para desarrolladores de Apple
- App — Documentación para desarrolladores de Apple
- NSViewRepresentable — Documentación para desarrolladores de Apple
- State — Documentación para desarrolladores de Apple
- Usa SwiftUI con AppKit — Apple Developer
- currentEditor() — Apple Developer
- Edición de texto — Archivo de documentación de Apple
- textDidEndEditing(_:) — Documentación para desarrolladores de Apple
- delegate — Documentación para desarrolladores de Apple
- control(_:textShouldEndEditing:) — Documentación para desarrolladores de Apple
- makeFirstResponder(_:) — Documentación para desarrolladores de Apple
- MainActor — Documentación para desarrolladores de Apple