wandres.dev
INTEROPERAR UIKIT · los dos mundos

`UIHostingController`: SwiftUI dentro de una app de UIKit

El camino de vuelta del puente es un controlador de vista ordinario cuya vista contiene un árbol declarativo. Esta lección examina las tres cosas que casi siempre se hacen mal al incrustarlo: la contención de controladores, la negociación del tamaño con `sizingOptions`, y el desajuste entre el ciclo de vida imperativo y las devoluciones de llamada de aparición de SwiftUI.

⏱ 20 min

UIHostingController parece la clase más simple de todo el puente: se instancia con una vista y ya se puede empujar en una pila de navegación. Esa simpleza aparente es engañosa, porque el objeto está haciendo tres traducciones simultáneas que nadie declara en su firma. Traduce jerarquía, convirtiendo un árbol de valores en una sola UIView que debe encajar en la contención de controladores de UIKit. Traduce tamaño, convirtiendo el sistema de propuestas y respuestas de SwiftUI en un tamaño intrínseco que Auto Layout pueda resolver. Y traduce tiempo, convirtiendo las devoluciones de llamada de aparición y desaparición de un controlador en los eventos que las vistas declarativas esperan. Cada uno de esos tres frentes tiene su propio conjunto de errores clásicos, y todos ellos se manifiestan igual de mal: la pantalla se ve, pero se comporta raro.

🎯 Al terminar esta lección sabrás
  • Incrustar un controlador anfitrión respetando el protocolo completo de contención de controladores de vista.
  • Elegir la opción de dimensionado adecuada según si la vista es una pantalla completa o un fragmento.
  • Predecir cuándo se disparan las devoluciones de llamada de aparición y por qué no coinciden con las de UIKit.
  • Reinyectar el entorno y evitar los ciclos de retención al actualizar la vista raíz.

Contención, no solo una vista

El error inicial más frecuente es tratar al anfitrión como si fuera una vista: crear el controlador, coger su view, añadirla como subvista y olvidarse del controlador. Eso funciona el tiempo suficiente para que parezca correcto y luego falla en lo que no se ve. El controlador queda huérfano, no recibe las devoluciones de llamada de aparición, no participa en la propagación de rasgos ni de tamaño, y si nadie lo retiene se libera dejando una vista viva sobre una jerarquía muerta.

final class PanelViewController: UIViewController {
    private lazy var anfitrion = UIHostingController(rootView: ResumenView(modelo: modelo))

    override func viewDidLoad() {
        super.viewDidLoad()
        addChild(anfitrion)
        anfitrion.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(anfitrion.view)
        NSLayoutConstraint.activate([
            anfitrion.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            anfitrion.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            anfitrion.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            anfitrion.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        anfitrion.didMove(toParent: self)
    }
}

Las tres llamadas del protocolo no son ceremonia: addChild establece la relación de paternidad antes de tocar vistas, la inserción coloca la vista donde corresponde y didMove avisa al hijo de que la operación terminó. Al retirar el fragmento, el orden se invierte y empieza por willMove(toParent: nil). Saltarse cualquiera de los pasos produce controladores que no reciben eventos de aparición o que se quedan retenidos indefinidamente.

Para pantallas completas dentro de una pila de navegación, la incrustación no hace falta: el anfitrión es el controlador que se empuja. Ahí conviene recordar que la barra de navegación y la de pestañas siguen siendo de UIKit, y que lo que SwiftUI declara con navigationTitle o toolbar viaja hasta ellas porque el anfitrión hace de puente, pero no todo lo hace. Cuando algo del toolbar no aparece, la solución suele estar del lado de UIKit —configurar el elemento de navegación del propio anfitrión— y no del lado declarativo.

El tamaño y las opciones de dimensionado

Un anfitrión que ocupa toda la pantalla no tiene problema de medida: sus restricciones lo fijan a los bordes y SwiftUI reparte el espacio dentro. El problema aparece cuando la vista declarativa es un fragmento que debe medirse a sí mismo: una tarjeta dentro de una tabla, una cabecera de sección, una fila de una UICollectionView. En ese caso hace falta que el tamaño que SwiftUI calcula se publique como tamaño intrínseco de la vista del anfitrión.

anfitrion.sizingOptions = [.intrinsicContentSize]

Con esa opción, la vista del anfitrión recalcula su tamaño intrínseco cada vez que el contenido cambia y lo notifica a Auto Layout, de modo que una pila de restricciones vertical se ajusta sola. La variante con el tamaño preferido del contenido es la que corresponde cuando el anfitrión se presenta como hoja o como ventana emergente, porque el sistema lee esa propiedad para decidir cuánto ocupa la presentación.

🖼️

Pantalla completa

El anfitrión es el controlador que se empuja o se presenta. Sin opciones de dimensionado: manda el contenedor.

🧱

Fragmento incrustado

Contención completa más tamaño intrínseco. La vista declarativa se mide sola y Auto Layout la respeta.

📋

Celda de lista

Usa la configuración de contenido con SwiftUI en lugar de un anfitrión por celda: hay reutilización y no hay contención que gestionar.

El caso de las celdas merece una advertencia propia porque es donde más caro sale equivocarse. Crear un anfitrión por celda y añadirlo como hijo del controlador de la tabla significa acumular controladores hijos a medida que se desplaza, con un coste de memoria y de tiempo que se nota enseguida. Desde que existe la configuración de contenido basada en SwiftUI, ese es el camino correcto: la celda declara su contenido con una vista declarativa, el sistema gestiona la reutilización y no hay ningún controlador que retirar.

⚠️
El área segura se aplica dos veces

Un anfitrión anclado a las guías de área segura del padre, cuya vista declarativa además respeta su propia área segura, aplica el mismo margen dos veces y deja un hueco que nadie sabe de dónde sale. Decide de qué lado vive la responsabilidad: o anclas a los bordes del padre y dejas que SwiftUI gestione el área segura, o anclas a las guías seguras y neutralizas el ajuste interno con safeAreaRegions vacío. Mezclar los dos criterios en pantallas distintas de la misma app garantiza discusiones estériles en cada revisión de código.

El ciclo de vida no coincide

En UIKit, el ciclo de vida pertenece al controlador y es explícito: la vista se carga, aparece, aparece del todo, va a desaparecer, desaparece. En SwiftUI, las devoluciones de llamada de aparición pertenecen a cada vista y se disparan cuando esa vista entra o sale del árbol renderizado, que no es lo mismo. El anfitrión conecta ambos mundos, pero la conexión no es una biyección y de ahí salen sorpresas concretas.

sequenceDiagram
participant N as UINavigationController
participant H as UIHostingController
participant S as Vista de SwiftUI
N->>H: viewWillAppear
H->>S: onAppear
N->>H: viewDidAppear
N->>H: viewWillDisappear
H->>S: onDisappear
Note over H,S: Con una hoja encima el controlador no desaparece

La sorpresa más común es la del contenido tapado: al presentar una hoja sobre el anfitrión, el controlador de debajo no recibe el aviso de desaparición porque sigue en pantalla detrás, y la vista declarativa tampoco. Si tu vista para un temporizador o una suscripción al desaparecer, seguirá corriendo. La segunda sorpresa es la contraria: dentro de una pila declarativa, una vista puede dispararse antes de estar visible por razones de precarga, de modo que la aparición no es una garantía de visibilidad sino de inclusión en el árbol.

De ahí una regla práctica que ahorra mucho tiempo: usa las devoluciones de aparición para el ciclo de vida de la vista, no para el de la pantalla. Iniciar una carga de datos con el modificador de tarea asíncrona es correcto, porque su cancelación está atada a la vida de la vista. Registrar una visita de analítica en la aparición es frágil, porque se disparará más veces de las que crees. Y si de verdad necesitas la semántica de pantalla, la tienes disponible del lado de UIKit: sobrescribe las devoluciones en una subclase del anfitrión, que es donde esa semántica existe de verdad.

final class AnfitrionMedido<Contenido: View>: UIHostingController<Contenido> {
    override func viewDidAppear(_ animado: Bool) {
        super.viewDidAppear(animado)
        Analitica.registrarPantalla("resumen")   // semantica de pantalla, no de vista
    }
}

Queda el frente del entorno, que es el que más desconcierta al llegar de una app puramente declarativa. El entorno de SwiftUI se propaga por el árbol de vistas, y cada anfitrión inicia un árbol nuevo: nada que hayas inyectado en otro anfitrión llega hasta aquí. Cada vez que cruzas hacia SwiftUI tienes que volver a inyectar el modelo, el contexto de datos, el tema y cualquier valor de entorno propio. Los rasgos del sistema —modo oscuro, tamaño de texto dinámico, dirección de lectura— sí viajan solos, porque el anfitrión los traduce desde la colección de rasgos de UIKit.

💡
Actualizar la vista raíz sin crear un ciclo

rootView es una propiedad mutable: para reflejar un cambio venido de UIKit, basta con asignar una vista nueva. El peligro está en lo que capturas al construirla. Si la vista raíz guarda un cierre que llama a un método del anfitrión, y el anfitrión retiene la vista raíz, tienes un ciclo perfecto que ninguna herramienta te señalará. Captura débilmente el controlador, o mejor, haz que la vista hable con un modelo y que sea el controlador quien observe ese modelo.

El sentido contrario: envolver un controlador

Durante una migración el tráfico cruza la frontera en ambas direcciones, y el gemelo del anfitrión es UIViewControllerRepresentable. Su contrato es idéntico al de las vistas envueltas —construir una vez, actualizar de forma idempotente, coordinar los eventos de vuelta— pero se aplica a un controlador entero, que es la unidad correcta cuando lo que queda de UIKit es una pantalla completa con su propia lógica.

struct SelectorDeDocumento: UIViewControllerRepresentable {
    var alElegir: (URL) -> Void

    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let controlador = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
        controlador.delegate = context.coordinator
        return controlador
    }

    func updateUIViewController(_ c: UIDocumentPickerViewController, context: Context) {
        context.coordinator.alElegir = alElegir
    }
}

Hay una regla que ahorra muchos problemas con este envoltorio: el controlador envuelto no debe navegar por su cuenta. Si dentro de la vista representable alguien empuja otro controlador o presenta una hoja, esa pantalla queda fuera del modelo de navegación declarativo y ninguna de las dos partes sabe cómo descartarla. El controlador envuelto informa hacia fuera a través del coordinador, y quien decide qué pasa después es la vista declarativa que lo contiene, exactamente igual que con el enrutador del lado imperativo.

Cada anfitrión es una frontera de propagación, y las fronteras se pagan

Vale la pena mirar el anfitrión no como una clase sino como lo que realmente instala en tu app: una frontera. Dentro de ella rige un régimen de propagación —el entorno desciende por el árbol, la invalidación sube por las dependencias leídas, la medida se negocia proponiendo y respondiendo—; fuera rige otro —los rasgos descienden por la jerarquía de controladores, la disposición se resuelve como sistema de restricciones, el ciclo de vida se anuncia con mensajes explícitos—. El anfitrión traduce en ambos sentidos, pero ninguna traducción es total, y todo lo que no cruza se convierte en trabajo manual que alguien tendrá que recordar: reinyectar el modelo, redeclarar el tema, resincronizar el estado de navegación, reconciliar dos ideas del área segura. El corolario arquitectónico es contundente y contraintuitivo: el coste de mezclar los dos mundos no crece con la cantidad de código de cada lado, sino con el número de fronteras. Una app con una sola frontera bien elegida —un anfitrión por pantalla, o mejor, un anfitrión por rama entera del árbol de navegación— puede convivir años sin dolor. Una app con cuarenta anfitriones diminutos repartidos por celdas, cabeceras y accesorios paga cuarenta veces la reinyección, cuarenta veces el desajuste del ciclo de vida y cuarenta oportunidades de que dos regímenes de medida se contradigan. Por eso la pregunta correcta al empezar una migración nunca es cuánto SwiftUI vamos a escribir, sino dónde vamos a dibujar el borde y cuántos bordes estamos dispuestos a mantener.

📝
Lo esencial

El anfitrión es un controlador y exige contención completa: addChild, insertar la vista, didMove, y el orden inverso al retirar. Para fragmentos, publica el tamaño con la opción de tamaño intrínseco; para celdas, usa la configuración de contenido en lugar de un anfitrión por celda. Las devoluciones de aparición de SwiftUI son de vista, no de pantalla, y no se disparan al quedar tapadas por una hoja. El entorno no cruza la frontera: reinyéctalo en cada anfitrión. Y cuantos menos anfitriones tenga tu app, menos costará mantenerla.

⚔️ Una frontera bien dibujada
  1. Incrusta una vista declarativa como fragmento con contención completa y comprueba, con registros en las devoluciones de aparición, qué ocurre si omites didMove.
  2. Activa el tamaño intrínseco y verifica que una pila de restricciones se ajusta al crecer el contenido; después desactívalo y describe el colapso.
  3. Presenta una hoja sobre el anfitrión y registra qué devoluciones se disparan en cada capa; ajusta una suscripción que no debía seguir viva.
  4. Inyecta un modelo en el entorno del anfitrión y comprueba que una segunda pantalla anfitriona no lo ve; resuélvelo sin duplicar la creación del modelo.
  5. Cuenta cuántas fronteras tiene una pantalla real de tu app y propón una reorganización que reduzca ese número al menos a la mitad.