Enviar acciones desde UIKit
En UIKit un evento del usuario puede llegar por cuatro puertas distintas —target-action, `UIAction`, un método de delegado o una closure de configuración— y todas terminan en el mismo sitio: una llamada a `store.send`. Esta lección recorre las cuatro puertas, fija la disciplina de que el manejador reporta y nunca decide, y establece la distinción decisiva entre los métodos de delegado que *notifican* algo ocurrido, que se traducen a acciones, y los que *preguntan* algo al presente, que se responden leyendo el estado. Cierra con los enlaces bidireccionales de controles mediante `@UIBindable` y con el valor de retorno de `send`.
La mitad ascendente del ciclo es, en UIKit, sorprendentemente más fácil que la descendente. No hace falta ningún mecanismo nuevo: store.send es exactamente el mismo método que usa una vista de SwiftUI, y lo único que hay que resolver es cómo llegar hasta él desde los cuatro sistemas de eventos que UIKit acumuló en cuarenta años de historia. Lo difícil no es técnico sino de disciplina, porque UIKit invita constantemente a colar una decisión en el manejador: comprobar si el campo está vacío antes de enviar, deshabilitar el botón ahí mismo, guardar en una propiedad del controlador lo que el usuario acaba de escribir. Cada una de esas comodidades saca un trozo de lógica del reducer y lo esconde en un lugar donde ningún test lo alcanzará. La regla de este nivel es tan corta como severa: el manejador traduce y no piensa.
- Conectar target-action,
UIActiony closures de configuración constore.sendsin fugar lógica al manejador. - Traducir un protocolo de delegado repartiendo sus métodos entre acciones y lecturas de estado.
- Distinguir los métodos que notifican un hecho pasado de los que interrogan el presente, y tratarlos de forma distinta.
- Usar
@UIBindablepara enlazar controles con el estado y aprovechar el valor devuelto porsend.
Cuatro puertas, un solo destino
El mecanismo histórico es target-action, y su traducción es un método de una línea. El moderno es UIAction, que evita el selector y permite escribir la traducción en el sitio donde se crea el control. Ambos conviven sin ninguna fricción.
private lazy var incrementar = UIButton(
type: .system,
primaryAction: UIAction(title: "Incrementar") { [weak self] _ in
self?.store.send(.incrementarPulsado)
}
)
private lazy var refrescar: UIRefreshControl = {
let control = UIRefreshControl()
control.addTarget(self, action: #selector(refrescarTirado), for: .valueChanged)
return control
}()
@objc private func refrescarTirado() {
store.send(.refrescarTirado)
}
Fíjate en que ninguno de los dos manejadores consulta nada ni decide nada. No comprueba si ya se está refrescando, no deshabilita el botón, no lee el texto de un campo para validar. Reporta un hecho: el usuario tiró para refrescar. Si ya había una petición en vuelo, ese es un problema del reducer, que tiene el estado completo para resolverlo y un test que lo demuestra. El manejador no tiene ni el estado ni el test.
La nomenclatura arrastra la misma disciplina que aprendiste con las acciones de origen: el caso se llama refrescarTirado y no refrescar, porque describe lo que pasó y no lo que hay que hacer. Un manejador que envía .guardar está dando una orden y, con ella, tomando la decisión de guardar. Uno que envía .botonGuardarPulsado está informando, y deja intacta la posibilidad de que el reducer decida no guardar porque el formulario es inválido o porque ya hay un guardado en curso.
Si un manejador contiene un if, un guard que no sea de desempaquetado de self, o una lectura de store para condicionar el envío, hay lógica de negocio en la capa de dibujo. La corrección casi siempre consiste en enviar la acción incondicionalmente y mover la condición al switch del reducer, donde además pasa a estar cubierta por un test.
Delegados: los que cuentan y los que preguntan
Un protocolo de delegado de UIKit mezcla dos clases de métodos que conviene separar con cuidado, porque se traducen de maneras opuestas. Están los que notifican un hecho ya consumado —textFieldDidChangeSelection, didSelectRowAt, scrollViewDidEndDragging— y están los que interrogan al presente para que el framework decida cómo comportarse —numberOfRowsInSection, shouldChangeCharactersIn, canEditRowAt—. Los primeros son acciones. Los segundos, jamás.
extension ListaViewController: UITableViewDelegate, UITableViewDataSource {
// Interrogacion: se responde leyendo el estado, aqui y ahora.
func tableView(_ t: UITableView, numberOfRowsInSection s: Int) -> Int {
store.elementos.count
}
func tableView(_ t: UITableView, canEditRowAt i: IndexPath) -> Bool {
store.elementos[i.row].esBorrable
}
// Notificacion: se traduce a una accion y se olvida.
func tableView(_ t: UITableView, didSelectRowAt i: IndexPath) {
store.send(.filaSeleccionada(id: store.elementos[i.row].id))
}
}
La razón por la que un método de interrogación no puede convertirse en acción es de temporalidad, no de estilo: el framework necesita la respuesta ahora mismo, dentro de la llamada, y una acción es una petición cuyo efecto sobre el estado ocurre después de atravesar el reducer. Devolver un valor derivado del estado actual es, en cambio, perfectamente compatible con la unidireccionalidad, porque leer no altera nada. Esta separación entre preguntar y contar tiene un nombre viejo —separación entre consultas y comandos— y encontrarla incrustada en un protocolo diseñado en los años noventa dice mucho a favor de aquel diseño.
Un matiz importante en la notificación: envía identidad, no posición. Un IndexPath es una coordenada de pantalla que puede haberse desincronizado del estado si llegó una actualización entre el toque y la entrega del evento. Resolver el identificador en el manejador, como hace el ejemplo, y enviarlo dentro de la acción hace que el reducer opere sobre el elemento correcto aunque la lista se haya reordenado.
Enlaces bidireccionales de controles
Los campos de texto, los interruptores y los deslizadores son el caso incómodo, porque llevan estado interno propio que hay que mantener sincronizado en las dos direcciones. Escrito a mano son dos piezas: una lectura dentro de observe y un envío desde un manejador de cambio.
observe { [weak self] in
guard let self else { return }
if campo.text != store.texto { campo.text = store.texto }
interruptor.isOn = store.notificaciones
}
@objc private func textoCambio() {
store.send(.textoCambio(campo.text ?? ""))
}
La comparación antes de asignar no es una optimización sino una necesidad: asignar text a un campo en edición mueve el cursor al final, y sin esa guarda el usuario no puede editar el medio de una palabra. Ese tipo de asperezas es lo que abstraen los enlaces de la capa de UIKit, que permiten declarar @UIBindable sobre el store y atar el control al estado en una línea, siempre que el Action de la feature conforme BindableAction.
@UIBindable var store: StoreOf<Formulario>
override func viewDidLoad() {
super.viewDidLoad()
campo.bind(text: $store.texto)
interruptor.bind(isOn: $store.notificaciones)
}
El enlace no es una puerta trasera al estado: escribe mediante la acción de vinculación, atraviesa el reducer y queda registrado como cualquier otra acción, de modo que un test puede afirmarlo. Lo único que desaparece es la fontanería.
Reporta, no ordenes
El caso nombra el hecho ocurrido. La decisión de qué hacer con él pertenece al reducer, siempre.
Identidad sobre posición
Envía el identificador del elemento, no su índice. Los índices caducan entre el toque y la entrega.
Preguntar es leer
Los métodos de delegado que devuelven un valor se responden con el estado actual, nunca con una acción.
Send devuelve trabajo
send entrega un StoreTask que puedes esperar para saber cuándo terminaron los efectos que disparó.
Ese último punto resuelve un problema muy concreto de UIKit: cerrar el indicador de un UIRefreshControl cuando la recarga acaba. Como el manejador no debe decidir nada, la tentación de terminarlo ahí mismo es errónea; la forma limpia es esperar la tarea que send devuelve, que se completa cuando concluyen todos los efectos originados por esa acción.
@objc private func refrescarTirado() {
Task { [weak self] in
await self?.store.send(.refrescarTirado).finish()
self?.refrescar.endRefreshing()
}
}
flowchart TD T[Target action o UIAction] --> S[store punto send] D[Metodo de delegado que notifica] --> S B[Enlace de control con UIBindable] --> S S --> R[Reducer decide y muta el estado] R --> O[Bloque observe reejecutado] O --> V[Vistas actualizadas] Q[Metodo de delegado que interroga] --> L[Lectura directa del estado] style S fill:#89b4fa,color:#11111b
Vale la pena detenerse en lo poco que hay que cambiar para llevar un evento de UIKit hasta un reducer, porque esa escasez revela algo sobre el diseño original de Cocoa. Target-action nació de una intuición certera: cuando un control es tocado, no debe decidir qué significa ese toque, sino enviar un mensaje a un receptor que sí lo sepa. Eso es, literalmente, la mitad ascendente de un flujo unidireccional, formulada tres décadas antes de que la llamáramos así. Lo que le faltaba era la reificación. En target-action el mensaje es un selector, y un selector es un nombre que solo el sistema de mensajes sabe interpretar: no se puede meter en una lista, no se puede comparar, no se puede serializar para reproducir una sesión, no se puede afirmar en un test, y sobre todo no puede llevar dentro un valor tipado que describa qué ocurrió con precisión. Al convertir el evento en un caso de un enum con valores asociados, TCA no inventa una arquitectura nueva: toma la que ya estaba y le añade la única propiedad que la hacía intratable. De pronto el evento es un dato, y todo lo que se puede hacer con datos se vuelve posible con eventos: guardarlos, imprimirlos, comprobarlos, mandarlos por la red, reproducirlos en orden para reconstruir un fallo del que solo tienes el registro. La segunda mitad del argumento es más sutil y aparece en el reparto de los métodos de delegado. Un protocolo como el de una tabla lleva mezcladas dos operaciones categóricamente distintas —una pregunta sobre el presente y el relato de algo que pasó— y la unidireccionalidad no les impone la misma respuesta porque no tienen la misma naturaleza causal. Preguntar no cambia el mundo, y por tanto puede contestarse en el acto leyendo el estado sin violar ninguna regla. Contar sí lo cambia, y por tanto tiene que entrar por la única puerta que el sistema deja abierta. Confundirlas produce los dos errores simétricos que uno ve en las primeras migraciones: convertir en acción una pregunta, que aboca a inventar un estado espejo para poder responder a tiempo, y responder una notificación leyendo el estado, que deja el hecho sin registrar y por tanto fuera de los tests. La distinción no es de TCA ni de UIKit: es la vieja frontera entre consulta y comando, y reconocerla en cada protocolo que traduzcas convierte una tarea mecánica en una lectura precisa de lo que aquel API quiso decir.
- Elige un
UIViewControllercon al menos dos protocolos de delegado implementados y lista todos sus métodos en dos columnas: notifican o interrogan. - Convierte cada método de la columna de notificación en un caso del
Action, nombrado en pasado y con identidad en vez de índice. - Reescribe cada método de la columna de interrogación como una lectura directa del
State, sin ninguna propiedad espejo en el controlador. - Revisa los manejadores resultantes y elimina cualquier condicional que quede; lleva esa condición al
switchdel reducer y escríbele un test. - Sustituye el par manual de campo de texto por
@UIBindabley comprueba, editando en medio de una palabra, que el cursor ya no salta al final.