El `Coordinator`: devolver eventos al mundo declarativo
El puente de ida —configurar una vista de UIKit desde un valor— es la mitad fácil. La vuelta, convertir delegados, acciones y notificaciones en cambios de estado, exige un objeto con identidad estable que SwiftUI no te da en ninguna otra parte. Esta lección examina qué es exactamente el `Coordinator`, por qué su referencia al envoltorio caduca y cómo se cortan los bucles de eco.
UIKit comunica hacia fuera con delegados, con el par objetivo y acción, con notificaciones y con cierres. Los tres primeros mecanismos exigen algo que en SwiftUI no existe: un objeto de referencia que viva más que un instante y al que se pueda apuntar. Una struct que se recrea en cada evaluación del body no puede ser el delegado de nada, porque para cuando llegue el mensaje ya no existirá la instancia que se registró. El Coordinator resuelve esa carencia con precisión quirúrgica: es el único objeto con identidad estable que tú controlas dentro de un envoltorio, vive exactamente lo que vive la vista de UIKit y su trabajo consiste en recibir eventos imperativos y traducirlos a escrituras sobre el estado declarativo. Todo lo que sale mal en la vuelta del puente sale mal por confundir la vida de ese objeto con la vida de la struct que lo creó.
- Explicar por qué el envoltorio no puede ser delegado y qué garantías de vida ofrece el
Coordinator. - Escribir el patrón completo con
makeCoordinator, delegados, objetivo y acción, y cierres. - Detectar y corregir la referencia caducada al envoltorio, que es el fallo silencioso más común.
- Cortar los bucles de eco entre una escritura programática y la notificación que provoca.
Un objeto con identidad dentro de un valor
SwiftUI llama a makeCoordinator una sola vez, antes de makeUIView, y conserva el objeto resultante mientras la identidad de la vista siga existiendo. Ese objeto llega después en el context de todos los métodos y en el parámetro de dismantleUIView. Es, literalmente, la caja de estado imperativo que el sistema te presta.
struct CampoDeBusqueda: UIViewRepresentable {
@Binding var texto: String
var alEnviar: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UISearchBar {
let barra = UISearchBar()
barra.delegate = context.coordinator
return barra
}
func updateUIView(_ barra: UISearchBar, context: Context) {
context.coordinator.envoltorio = self
if barra.text != texto { barra.text = texto }
}
final class Coordinator: NSObject, UISearchBarDelegate {
var envoltorio: CampoDeBusqueda
init(_ envoltorio: CampoDeBusqueda) { self.envoltorio = envoltorio }
func searchBar(_ barra: UISearchBar, textDidChange nuevo: String) {
envoltorio.texto = nuevo
}
func searchBarSearchButtonClicked(_ barra: UISearchBar) {
envoltorio.alEnviar(barra.text ?? "")
}
}
}
Hay dos detalles de propiedad de memoria que conviene tener claros desde el principio. El primero: SwiftUI retiene el coordinador, así que tú no necesitas guardarlo en ninguna parte. El segundo: casi todas las propiedades de delegado en UIKit son débiles, de modo que si el coordinador no estuviera retenido por el sistema, el delegado se evaporaría y los mensajes dejarían de llegar sin ningún error visible. Esa combinación —el sistema retiene, la vista apunta débilmente— es exactamente la correcta y no hay que tocarla.
La referencia que caduca
La línea más importante del ejemplo anterior es la que reasigna el envoltorio en cada actualización, y es también la que más se omite. El razonamiento erróneo es tentador: el coordinador ya recibió el envoltorio en su inicializador, así que ya tiene lo que necesita. Pero el envoltorio es una struct, se copió por valor en aquel momento y esa copia quedó congelada con los valores del primer instante. Si el Binding proviene de otra rama del árbol, si un cierre captura una versión antigua del modelo o si una propiedad simple cambió después, el coordinador seguirá operando sobre datos de hace diez segundos.
Este fallo no produce ningún aviso. Produce comportamientos absurdos y difíciles de reproducir: una acción que navega a la pantalla anterior a la que estás viendo, un filtro que usa el término de búsqueda previo, un botón que envía siempre el primer elemento de la lista. Si al depurar ves valores correctos en el body y valores viejos dentro del coordinador, no busques más: falta la reasignación en updateUIView.
Existe una alternativa a reasignar el envoltorio entero, y en equipos grandes suele ser preferible: que el coordinador no conozca la struct sino solo las piezas que necesita, declaradas como cierres. Reduce el acoplamiento, hace obvio en la firma qué eventos salen del envoltorio y facilita la prueba del coordinador por separado. El precio es que hay que actualizar esos cierres en cada llamada a updateUIView, exactamente por la misma razón que antes.
final class Coordinator: NSObject, UITextViewDelegate {
var alCambiarTexto: (String) -> Void = { _ in }
var alTerminarEdicion: () -> Void = { }
var actualizacionProgramatica = false
func textViewDidChange(_ vista: UITextView) {
guard !actualizacionProgramatica else { return }
alCambiarTexto(vista.text)
}
}
Vida estable
Se crea una vez por identidad y muere con ella. Es el sitio correcto para suscripciones, temporizadores y banderas.
Traductor de eventos
Recibe delegados, acciones y notificaciones, y las convierte en escrituras sobre Binding o en llamadas a cierres.
Referencia fresca
Todo lo que venga del envoltorio caduca. Se reasigna en cada updateUIView o se pasa como cierre actualizado.
Corta el eco
Una bandera de escritura programática evita que la sincronización de ida provoque un evento de vuelta.
El eco y cómo se corta
El ciclo natural del puente es simple: el estado cambia, updateUIView lo escribe en la vista de UIKit, la vista notifica al delegado que su contenido cambió y el delegado escribe en el estado. Ese último paso cierra un círculo que no debería existir, porque el cambio no vino de la persona usuaria sino de tu propia sincronización. En el mejor caso se desperdicia una invalidación; en el peor, el cursor de escritura salta, la selección se pierde o se establece una oscilación entre dos valores que se pisan.
La solución canónica tiene dos capas y conviene aplicar ambas. La primera es comparar antes de asignar, que ya evita la mayoría de los ecos porque no se escribe si el valor no cambió. La segunda es marcar la ventana programática con una bandera en el coordinador, porque hay controles que notifican incluso cuando se les asigna un valor idéntico, y otros que notifican efectos derivados —cambio de selección, cambio de desplazamiento— que ninguna comparación de texto puede prever.
func updateUIView(_ vista: UITextView, context: Context) {
context.coordinator.alCambiarTexto = { texto = $0 }
guard vista.text != texto else { return }
context.coordinator.actualizacionProgramatica = true
let seleccion = vista.selectedRange
vista.text = texto
vista.selectedRange = seleccion
context.coordinator.actualizacionProgramatica = false
}
flowchart LR A[Estado de SwiftUI] -->|updateUIView| B[Vista de UIKit] B -->|delegado| C[Coordinator] C -->|escribe en el binding| A C -.->|bandera programatica| D[Evento descartado] style C fill:#89b4fa,color:#11111b style D fill:#f38ba8,color:#11111b
Hay una tercera fuente de eventos que se suele olvidar y que el coordinador gestiona igual de bien: las notificaciones y las suscripciones. Si tu vista envuelta necesita reaccionar al teclado, a un cambio de orientación o a un flujo de datos, el observador se registra en el inicializador del coordinador o en makeUIView, se guarda ahí mismo y se cancela en dismantleUIView. Guardar suscripciones en el envoltorio no funciona, porque el envoltorio muere en cuanto termina la evaluación del body; guardarlas en la vista de UIKit funciona pero mezcla responsabilidades. El coordinador es el sitio.
Cuándo el coordinador sobra
No todo envoltorio necesita coordinador, y añadirlo por costumbre engorda el código sin motivo. La regla es directa: si el flujo de información es de una sola dirección —del valor de SwiftUI hacia la configuración de UIKit— y la vista envuelta no comunica nada hacia fuera, no hay nada que coordinar. Un envoltorio de UIActivityIndicatorView, de una vista de blur o de un UIProgressView es puro camino de ida.
El nombre invita a confusión: este Coordinator no tiene nada que ver con el patrón de coordinación de navegación de UIKit. Su alcance es una sola vista envuelta y su vida es la de esa vista. En cuanto empieces a meter dentro lógica de negocio, decisiones de navegación o llamadas de red, habrás construido un controlador escondido en un archivo que nadie mira. Traduce eventos y nada más: lo que pase después es responsabilidad del modelo que recibe la llamada.
Un árbol declarativo describe el mundo en un instante: dado este estado, esta pantalla. No hay antes ni después, no hay quién lo hizo ni por qué; solo un valor y su representación. Ese es su poder y también su ceguera, porque los sistemas interactivos reales no están hechos de instantes sino de sucesiones, y hay información que solo existe en la transición: que este cambio de texto lo escribió la persona y aquel lo escribió tu propia sincronización, que este desplazamiento fue un gesto y aquel fue una llamada programática, que la selección de ahora es continuación de la de antes y no una nueva. El Coordinator es el único sitio del envoltorio donde esa información temporal cabe, y por eso todo lo que acaba viviendo dentro tiene la misma forma: banderas que dicen quién causó qué, referencias frescas a un valor que se recrea, suscripciones que abarcan más de un fotograma. Cuando escribes ahí una bandera de actualización programática no estás poniendo un parche sucio en un diseño limpio; estás representando explícitamente una distinción —causa humana frente a causa del sistema— que el modelo declarativo borró por construcción y que la interfaz necesita para comportarse bien. Reconocer eso cambia la actitud con la que se escriben estos objetos: no son residuo del pasado imperativo que algún día desaparecerá, son el reconocimiento de que entre dos estados válidos hay un camino, y de que a veces el camino importa tanto como el destino.
El Coordinator existe porque los delegados necesitan un objeto y el envoltorio es un valor efímero. SwiftUI lo crea una vez, lo retiene y te lo entrega en el context. Toda referencia que guarde al envoltorio caduca, así que se reasigna en cada updateUIView o se sustituye por cierres actualizados. Corta el eco comparando antes de asignar y marcando las escrituras programáticas con una bandera. Suscripciones y observadores viven ahí y se cancelan en dismantleUIView. Si el flujo es de una sola dirección, no lo escribas.
- Envuelve un control de UIKit con delegado y conecta un
Bindingbidireccional; provoca deliberadamente el eco y descríbelo con precisión. - Añade la bandera de escritura programática y verifica que el evento se descarta sin romper la escritura real de la persona usuaria.
- Elimina la reasignación del envoltorio en
updateUIViewy encuentra un caso donde el coordinador use un valor caducado; después arréglalo con cierres. - Mueve una suscripción a notificaciones del teclado al coordinador y cancélala en
dismantleUIView; comprueba con un contador que no se acumulan observadores al reconstruir la identidad. - Escribe una prueba unitaria del coordinador sin renderizar ninguna vista: invócalo como si fuera UIKit y afirma qué cierres se llamaron.