Las costuras: gestos que chocan y navegación mixta
Los problemas reales de tener dos frameworks a la vez no aparecen en el código de cada mundo sino en la juntura: dos sistemas de reconocimiento de gestos compitiendo por el mismo toque, dos pilas de navegación con dos ideas del gesto de retroceso, dos mecanismos de área segura y de evitación del teclado sumándose. Esta lección cataloga las costuras, explica su mecánica y da una defensa concreta para cada una.
Cuando una app mezcla UIKit y SwiftUI, los fallos no se distribuyen de forma uniforme: se acumulan en la juntura, y lo hacen con una regularidad que permite catalogarlos. La razón es que ambos marcos resuelven los mismos cuatro problemas —quién recibe un toque, quién posee la ruta de navegación, cuánto espacio hay disponible, cuándo empieza y termina una vista— con mecanismos distintos que, por separado, son correctos y completos. Al superponerlos no se suman capacidades, se superponen dos autoridades sobre el mismo recurso, y ninguna de las dos sabe que la otra existe. Diagnosticar una costura consiste siempre en la misma pregunta: qué recurso están reclamando los dos sistemas a la vez, y cuál de ellos debe ganar. Con esa pregunta en la mano, los síntomas dejan de ser misterios y se convierten en casos conocidos.
- Explicar cómo compiten los reconocedores de gestos de UIKit con los gestos declarativos y cómo arbitrar.
- Evitar las barras duplicadas y la pérdida del gesto de retroceso en pilas de navegación mixtas.
- Diagnosticar los conflictos de área segura, teclado y ciclos de disposición entre los dos mundos.
- Adoptar un método de diagnóstico que empiece por identificar el recurso en disputa.
Gestos: dos árbitros para un mismo toque
UIKit resuelve los toques con una jerarquía de reconocedores que negocian entre sí mediante un protocolo explícito: pueden fallar en favor de otro, exigir que otro falle antes, o convivir reconociendo a la vez si el delegado lo autoriza. SwiftUI tiene su propio sistema, con composición de gestos y prioridades declaradas en el árbol. Cuando una vista envuelta vive dentro de un contenedor declarativo, los dos sistemas ven el mismo toque y ninguno consulta al otro.
El síntoma más habitual es el desplazamiento anidado: una vista envuelta que desplaza horizontalmente dentro de un contenedor declarativo que desplaza en vertical, y el resultado es que uno de los dos se queda con todos los toques diagonales. El segundo síntoma más habitual es el toque que se come una pulsación larga, o el arrastre que impide que se active un botón que está debajo.
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gesto: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otro: UIGestureRecognizer
) -> Bool {
true // deja pasar el gesto del contenedor declarativo
}
}
// Del lado declarativo: el arrastre convive con el desplazamiento de la lista
List(elementos) { elemento in
FilaView(elemento: elemento)
.simultaneousGesture(
DragGesture(minimumDistance: 20)
.onChanged { desplazamiento = $0.translation.width }
)
}
Autorizar el reconocimiento simultáneo desde el delegado es la palanca más directa y resuelve buena parte de los casos, pero hay que usarla con criterio: si los dos gestos actúan sobre el mismo eje, reconocer a la vez produce movimientos duplicados en lugar de un reparto limpio. Del lado declarativo existen dos herramientas equivalentes: elevar un gesto por encima de los que hay debajo, y declarar explícitamente que dos gestos ocurren a la vez. Y para el caso concreto del arrastre dentro de una lista, marcar un gesto declarativo como simultáneo con el desplazamiento es lo que evita que la lista se quede con el movimiento.
El gesto de deslizar desde el borde para volver atrás es un reconocedor de la pila de navegación de UIKit, y se desactiva solo en cuanto ocultas la barra de navegación, porque el sistema asume que sin barra no hay a dónde volver. Al empujar un anfitrión que oculta la barra desde SwiftUI, el gesto muere y nadie avisa. La corrección clásica es volver a habilitar ese reconocedor y asumir su delegado en el controlador contenedor. Es una línea de código y una de las quejas de usabilidad más frecuentes en apps mixtas.
Navegación mixta: quién posee la ruta
La segunda costura es la más visible para quien usa la app y la más cara de arreglar tarde. Empujar dentro de una pila de UIKit un anfitrión que a su vez declara su propia pila de navegación produce el clásico problema de las dos barras, dos títulos y dos botones de retroceso. La causa es que ambas pilas creen ser la dueña del contexto de navegación.
La regla que resuelve casi todos los casos es de propiedad única: cada rama del árbol tiene un solo dueño de la navegación. Si la rama está gobernada por UIKit, las vistas declarativas de esa rama son hojas sin pila propia y navegan llamando al enrutador inyectado. Si la rama está gobernada por SwiftUI, los controladores de UIKit de esa rama se presentan envueltos en una vista representable de controlador, sin pila propia.
flowchart TD
A[Toque o intencion de navegar] --> B{Quien posee la rama}
B -->|UIKit| C[La vista declarativa llama al enrutador]
B -->|SwiftUI| D[La ruta se anade al camino declarativo]
C --> E[Push del anfitrion sin pila propia]
D --> F[El controlador se envuelve como vista]
E --> G[Una sola barra y un solo retroceso]
F --> G
style G fill:#a6e3a1,color:#11111bLos modales siguen la misma lógica de propiedad y fallan de la misma manera. Una hoja presentada desde el mundo declarativo se descarta cambiando el valor booleano que la sostiene; una presentada desde UIKit se descarta llamando al controlador que la presentó. Cuando un controlador presentado imperativamente intenta cerrarse desde una vista declarativa que lleva dentro, o al revés, el resultado va desde el descarte que no ocurre hasta la pantalla que se cierra dos veces. La regla es breve: quien presenta, descarta, y la información de que hay que cerrar viaja hacia esa parte como un evento, nunca como una llamada directa.
Hay un caso intermedio que merece atención porque aparece en casi todas las migraciones: el estado de navegación duplicado. La pila declarativa guarda su camino como un valor observable, y la pila de UIKit guarda el suyo como un arreglo de controladores. Si ambas existen a la vez y alguien las sincroniza a mano, se acaba en el mismo bucle de eco que ya conoces del coordinador: el arreglo cambia, se escribe el camino, el camino notifica y se vuelve a empujar. La única salida sana es que una de las dos representaciones sea la fuente de verdad y la otra no exista.
Un dueño por rama
Nunca dos pilas anidadas. La rama es de un mundo o del otro, y el otro aporta hojas.
Retroceso vivo
Al ocultar la barra, rehabilita el gesto de borde y asume su delegado. Es la queja de usabilidad número uno.
Una sola verdad
O el camino declarativo o el arreglo de controladores. Sincronizar los dos a mano garantiza bucles.
Modales con dueño
Quien presenta, descarta. Mezclar presentaciones declarativas e imperativas deja hojas huérfanas.
Espacio, teclado y ciclos de medida
La tercera costura es geométrica y se manifiesta como márgenes que nadie pidió. El área segura existe en ambos mundos: el controlador la expone como guías de disposición y como inserciones adicionales, y la vista declarativa la respeta por su cuenta salvo que se le diga lo contrario. Anclar el anfitrión a las guías seguras del padre y dejar que además el contenido declarativo aplique las suyas produce el doble margen del que ya se habló, y su primo cercano es el contrario: una vista que debía llegar hasta el borde inferior y se queda a la altura del indicador de inicio.
El teclado es el mismo problema con un actor más. SwiftUI evita el teclado automáticamente desplazando el contenido, y muchas pantallas de UIKit lo evitan a mano con un observador de notificaciones que ajusta las inserciones de un desplazamiento. Cuando un campo declarativo vive dentro de una pantalla que ya tenía ese observador, ambos mecanismos actúan sobre la misma vista y el contenido salta el doble de lo necesario. La defensa es elegir: o se desactiva la evitación automática ignorando el área segura del teclado en el lado declarativo, o se retira el observador de UIKit en esa pantalla.
// Frontera limpia: el anfitrion llega a los bordes y SwiftUI decide
anfitrion.view.topAnchor.constraint(equalTo: view.topAnchor)
anfitrion.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
anfitrion.safeAreaRegions = [] // el padre ya aplico el area segura
Queda el fallo más desconcertante de esta familia: los ciclos de medida. Ocurren cuando el tamaño intrínseco publicado por un anfitrión depende de una disposición que a su vez depende de ese tamaño. Auto Layout invalida, SwiftUI recalcula, publica un tamaño nuevo, Auto Layout vuelve a invalidar. En consola aparecen avisos de ciclo en el grafo de atributos y en pantalla se ve un parpadeo o un consumo de procesador constante sin que nada se mueva. Casi siempre se corrige rompiendo la dependencia circular: fijar una de las dos dimensiones desde el contenedor y dejar que solo la otra se negocie.
Apariencia, tiempo y las trampas menores
Queda una cuarta familia de costuras que no rompe nada de forma espectacular pero desgasta a diario. La primera es la apariencia global: el mecanismo de personalización por proxy de UIKit, que fija de una vez el aspecto de todas las barras y todos los botones de la app, no alcanza a las vistas declarativas. El resultado es una app donde la mitad de las pantallas obedecen a la configuración global y la otra mitad no, y la solución no es duplicar valores sino tener el sistema de diseño en un solo sitio y aplicarlo desde ambos lados con la misma fuente de datos.
La segunda trampa es la de los rasgos frente al entorno. Modo oscuro, tamaño de texto dinámico y dirección de lectura viajan del contenedor de UIKit al anfitrión porque este los traduce, pero el camino inverso no existe: un cambio que hagas en el entorno declarativo no modifica la colección de rasgos del controlador padre. Si una pantalla mixta debe forzar un esquema de color, hay que declararlo en los dos lados, y conviene hacerlo en un único punto de la capa de adaptación para que nadie tenga que recordarlo.
// Un solo punto donde la frontera se configura entera
extension UIHostingController {
static func frontera<C: View>(_ contenido: C, modelo: AppModel) -> UIHostingController<some View> {
let vista = contenido
.environment(modelo)
.tint(Tema.acento)
return UIHostingController(rootView: vista)
}
}
La tercera es el rendimiento, y aquí la costura no se ve, se mide. Cada anfitrión tiene un coste de creación y mantiene su propio grafo de dependencias; repartirlos por celdas, cabeceras y accesorios multiplica ese coste en el peor momento posible, que es el desplazamiento. Cuando una lista mixta se siente pesada, la primera hipótesis no debe ser el contenido declarativo sino el número de fronteras por fila.
La cuarta afecta a las pruebas de interfaz y suele descubrirse tarde: los identificadores de accesibilidad que los guiones de prueba buscan se declaran de forma distinta en cada mundo, y una vista declarativa que agrupa sus hijos puede hacer desaparecer del árbol el elemento que la prueba esperaba encontrar. Migrar una pantalla sin revisar sus identificadores rompe una suite que nadie tocó y cuya causa parece inexplicable.
Ante cualquier síntoma raro en una app mixta, antes de tocar código responde por escrito: qué recurso se están disputando los dos sistemas —el toque, la ruta, el espacio o el tiempo—; quién debería ganar según la regla de propiedad única que hayas fijado; y qué mecanismo concreto cede la autoridad al ganador. Casi todas las correcciones de costura son una sola línea, y lo caro no es escribirla sino identificarla. Un catálogo interno con estas tres respuestas por cada caso resuelto vale más que cualquier documentación general.
Es tentador leer todo lo anterior como una lista de asperezas que Apple limará con los años, y esa lectura es reconfortante y falsa. Los conflictos que has visto no vienen de una implementación incompleta sino de que ambos marcos responden de forma distinta a preguntas que no admiten dos respuestas simultáneas. Qué es una vista: en un mundo, un objeto con identidad, dirección de memoria y estado propio, que existe hasta que alguien lo suelta; en el otro, un valor efímero cuya identidad es una posición en un árbol y cuyo estado vive fuera de él. Qué es navegar: en un mundo, una operación imperativa sobre un arreglo de controladores; en el otro, una mutación de un dato que el sistema traduce en pantallas. Qué es el tiempo: en un mundo, una secuencia de mensajes anunciados explícitamente; en el otro, la inclusión o exclusión de un nodo en el árbol renderizado. Ninguna capa de compatibilidad puede reconciliar estas diferencias, porque no son detalles sino los axiomas de cada diseño, y una traducción entre axiomas incompatibles siempre pierde información. De ahí sale la única estrategia realmente sostenible, que además es una regla de arquitectura general y no una particularidad de iOS: haz las fronteras pocas, explícitas y gruesas. Pocas, porque cada una es un punto de pérdida. Explícitas, porque una frontera oculta es un fallo que aparecerá dentro de dos años sin que nadie recuerde por qué. Y gruesas, en el sentido de que crucen unidades completas de significado —una pantalla, una rama entera de navegación— y no fragmentos diminutos, porque cuanto mayor es la unidad que cruza, menor es la proporción de información que la traducción deja por el camino.
Las costuras aparecen donde los dos marcos reclaman el mismo recurso. En los gestos, arbitra con el reconocimiento simultáneo desde el delegado o con la simultaneidad declarativa, y rehabilita el gesto de borde cuando ocultes la barra. En navegación, un solo dueño por rama y una sola fuente de verdad para la ruta. En geometría, decide de qué lado se aplica el área segura y elige un solo mecanismo de evitación del teclado. Y ante un síntoma nuevo, identifica primero el recurso en disputa: la corrección suele ser una línea.
- Coloca una vista envuelta que desplace horizontalmente dentro de un contenedor declarativo vertical y documenta qué gesto gana; después arbitra para que ambos funcionen.
- Empuja un anfitrión que oculta la barra de navegación, comprueba que el gesto de retroceso desaparece y devuélvelo sin reintroducir la barra.
- Anida deliberadamente dos pilas de navegación, describe con exactitud qué elementos se duplican y corrígelo aplicando la regla de propiedad única.
- Provoca el doble desplazamiento por teclado combinando un observador de UIKit con la evitación automática, y elige un solo mecanismo justificando cuál.
- Construye un ciclo de medida entre el tamaño intrínseco de un anfitrión y una restricción del padre; rompe la circularidad y anota qué dimensión decidiste fijar y por qué.