wandres.dev
INTEROPERAR UIKIT · los dos mundos

`UIViewRepresentable`: envolver UIKit para que hable SwiftUI

Envolver una vista de UIKit no consiste en meterla dentro de un contenedor: consiste en escribir a mano el reconciliador que SwiftUI te regala para sus propias vistas. Esta lección desmonta el contrato del protocolo tiempo a tiempo —construcción, actualización, medida y desmontaje—, explica por qué `updateUIView` debe ser idempotente y qué ocurre exactamente cuando no lo es.

⏱ 19 min

SwiftUI describe la interfaz como un valor y deja que el sistema calcule la diferencia entre el valor de antes y el de ahora. UIKit no funciona así: una UIView es un objeto con identidad, con estado interno mutable y con una vida que empieza cuando alguien la crea y termina cuando alguien la suelta. UIViewRepresentable es el punto exacto donde esos dos modelos se tocan, y su contrato no es un adaptador de conveniencia sino algo mucho más exigente: te nombra responsable del reconciliador. Donde SwiftUI compara dos árboles y decide qué propiedades tocar, tú debes escribir a mano la función que lleva un objeto persistente desde cualquier estado anterior hasta el estado que el valor actual describe. Todo lo que sale mal al envolver UIKit —parpadeos, bucles, cursores que saltan, medidas absurdas— sale mal por no haber entendido esa transferencia de responsabilidad.

🎯 Al terminar esta lección sabrás
  • Reconstruir el contrato completo del protocolo y saber quién llama a cada método y cuándo.
  • Escribir un makeUIView que construya una sola vez y no dependa del estado que cambia.
  • Escribir un updateUIView idempotente y reconocer los bucles de realimentación antes de que aparezcan.
  • Controlar la negociación del tamaño entre el sistema de propuestas de SwiftUI y el Auto Layout de UIKit.

Un contrato de cuatro tiempos

El protocolo pide un tipo asociado y dos métodos obligatorios, pero lo que realmente define son cuatro momentos distintos en la vida del objeto envuelto. El primero es la creación: sucede una vez por cada identidad de vista y produce la instancia de UIKit que vivirá mientras esa identidad exista. El segundo es la actualización: sucede cada vez que SwiftUI invalida la vista, y su trabajo es sincronizar el objeto con los valores actuales. El tercero es la medida: SwiftUI propone un tamaño y necesita saber qué tamaño quiere ocupar el objeto. El cuarto es el desmontaje: la identidad desaparece y hay que soltar observadores, temporizadores y suscripciones.

struct EditorDeTexto: UIViewRepresentable {
    @Binding var texto: String
    var fuente: UIFont

    func makeUIView(context: Context) -> UITextView {
        let vista = UITextView()
        vista.delegate = context.coordinator
        vista.backgroundColor = .clear
        vista.textContainerInset = .init(top: 8, left: 8, bottom: 8, right: 8)
        return vista
    }

    func updateUIView(_ vista: UITextView, context: Context) {
        if vista.text != texto { vista.text = texto }
        if vista.font != fuente { vista.font = fuente }
    }

    static func dismantleUIView(_ vista: UITextView, coordinator: Coordinator) {
        vista.delegate = nil
    }
}

Conviene fijarse en que el envoltorio es una struct y, como cualquier vista de SwiftUI, se recrea a cada evaluación del body del padre. La vista de UIKit, en cambio, sobrevive a todas esas recreaciones. Esta asimetría es la fuente de la mayoría de los malentendidos: el valor que declara la vista es efímero y el objeto que la implementa es persistente. Guardar en la struct cualquier estado que deba durar es un error garantizado, porque esa struct desaparece constantemente.

La identidad de la vista envuelta sigue exactamente las mismas reglas que cualquier otra vista de SwiftUI: si cambia su posición estructural en el árbol, si cambia el id explícito o si una rama condicional la sustituye por otra, el objeto de UIKit se destruye y se construye uno nuevo. Perder el cursor de escritura, la posición de desplazamiento o una animación a medias casi nunca es culpa del envoltorio: es una identidad rota más arriba.

makeUIView: construir lo que no cambia

En makeUIView va todo lo que es cierto durante toda la vida del objeto: la instancia, el delegado, la configuración estructural, el registro de celdas si es una tabla, los reconocedores de gestos, las restricciones internas. Lo que no debe ir es cualquier valor derivado del estado que puede cambiar, porque este método no se volverá a llamar cuando ese estado cambie. Ese es el error de novato más frecuente y el más difícil de diagnosticar, porque la pantalla se ve correcta la primera vez.

🏗️

Solo una vez

Instancia, delegado, gestos, registro de celdas y configuración estructural. Todo lo que la vista sería con cualquier valor de entrada.

🔁

En cada cambio

Texto, color, selección, datos, habilitado o deshabilitado. Todo lo que depende de una propiedad del envoltorio o del entorno.

🧹

Al desaparecer

Observadores, temporizadores, suscripciones y delegados en cero. El desmontaje es estático y recibe el coordinador.

El parámetro context que reciben todos los métodos merece atención: no es un saco de utilidades sino tres cosas concretas. Lleva el coordinator, que es el único objeto con identidad estable que tú controlas y que será el tema de la lección siguiente. Lleva el environment, que te permite leer valores del entorno de SwiftUI —esquema de color, tamaño de texto dinámico, layoutDirection— y trasladarlos a las propiedades equivalentes de UIKit. Y lleva la transaction, que te dice si la actualización en curso forma parte de una animación y con qué curva, lo cual es la única manera honesta de animar un cambio en UIKit al ritmo que SwiftUI espera.

func updateUIView(_ vista: UITextView, context: Context) {
    vista.isEditable = context.environment.isEnabled
    let duracion = context.transaction.animation == nil ? 0 : 0.25
    UIView.animate(withDuration: duracion) {
        vista.alpha = destacado ? 1 : 0.4
    }
}

updateUIView: la idempotencia no es opcional

La única regla que gobierna este método es que debe ser una función del estado actual, no un registro de lo que ha cambiado. SwiftUI no te dice qué propiedad se modificó ni te garantiza cuántas veces llamará: puede llamar una vez, tres veces seguidas con los mismos valores o ninguna si nada de lo que lees ha cambiado. Un updateUIView correcto se puede ejecutar mil veces con la misma entrada y deja el objeto exactamente igual que si se hubiera ejecutado una sola.

De ahí salen dos disciplinas prácticas. La primera es comparar antes de asignar: escribir en una propiedad de UIKit suele disparar efectos secundarios —invalidar el diseño, notificar al delegado, reiniciar la selección— y asignar el mismo valor que ya había no es una operación neutra. La segunda es no escribir nunca en el estado de SwiftUI desde dentro de este método, ni directamente ni a través de un delegado que se dispara por culpa de la asignación que acabas de hacer.

⚠️
El bucle de realimentación y su síntoma

Escribir en un Binding durante updateUIView invalida la vista, lo que provoca otro updateUIView, que vuelve a escribir. El sistema lo detecta y avisa con el mensaje de modificación de estado durante la actualización de la vista, pero la versión sutil no avisa: asignas vista.text y el delegado reacciona notificando un cambio de texto que reescribe el mismo Binding. La app no se cuelga, solo se comporta mal —el cursor salta al final en cada pulsación— y el diagnóstico se te escapa durante días. La defensa es doble: comparar antes de asignar y marcar en el coordinador que la escritura en curso es programática.

flowchart TD
A[El body del padre se evalua] --> B[Se crea una struct nueva del envoltorio]
B --> C{Existe ya la identidad}
C -->|no| D[makeUIView construye el objeto]
C -->|si| E[updateUIView sincroniza el objeto]
D --> F[sizeThatFits negocia el tamano]
E --> F
F --> G[La vista de UIKit se dibuja]
H[La identidad desaparece] --> I[dismantleUIView limpia]
style E fill:#a6e3a1,color:#11111b
style I fill:#f9e2af,color:#11111b

El tamaño: dos sistemas negociando

SwiftUI mide proponiendo: le ofrece a la vista un tamaño y la vista responde con el que quiere ocupar. UIKit mide de otra forma: cada vista tiene un tamaño intrínseco de contenido y unas prioridades de abrazo y de resistencia a la compresión que Auto Layout resuelve como un sistema de restricciones. Cuando envuelves una vista, esos dos mundos tienen que ponerse de acuerdo, y el resultado por defecto casi nunca es el que esperabas.

El comportamiento base es que SwiftUI consulta el tamaño intrínseco del objeto envuelto. Si ese objeto no tiene uno bien definido —una UITextView no lo tiene, una UIScrollView tampoco— la vista tenderá a ocupar todo el espacio propuesto o a colapsar a cero, y ninguna de las dos cosas suele ser lo que quieres. Ahí es donde entra sizeThatFits, que te devuelve el control completo de la negociación: recibes la propuesta como un tamaño con dimensiones opcionales y devuelves el tamaño deseado, o devuelves nil para delegar en el comportamiento por defecto.

func sizeThatFits(
    _ proposal: ProposedViewSize,
    uiView: UITextView,
    context: Context
) -> CGSize? {
    let ancho = proposal.width ?? UIView.noIntrinsicMetric
    let alto = uiView.sizeThatFits(
        CGSize(width: ancho, height: .greatestFiniteMagnitude)
    ).height
    return CGSize(width: ancho, height: alto)
}

Hay un detalle que conviene conocer antes de pelearse con Auto Layout dentro del envoltorio: SwiftUI coloca la vista envuelta usando marcos directos, así que las restricciones que ates a un ancestro exterior no tienen ningún sentido y producen conflictos. Dentro del objeto puedes usar todas las restricciones que quieras, porque ahí sigues en territorio de UIKit; hacia fuera, la única comunicación legítima es el tamaño intrínseco y la respuesta a la propuesta.

Las dimensiones de la propuesta son opcionales y cada valor significa una pregunta distinta: un valor concreto pregunta cuánto ocuparías con este espacio, nil pregunta cuál es tu tamaño ideal sin restricción, cero pregunta cuál es tu tamaño mínimo y el infinito pregunta cuál es tu tamaño máximo. Responder a las cuatro preguntas de forma coherente es lo que hace que tu vista envuelta se comporte como una vista nativa dentro de una pila, una lista o un Layout propio.

💡
Antes de escribir el envoltorio, comprueba que hace falta

Cada año, la superficie de SwiftUI absorbe casos que antes exigían bajar a UIKit: TextField con axis vertical y TextEditor cubren buena parte de lo que se resolvía envolviendo UITextView; ScrollView con scrollPosition y scrollTargetBehavior cubre gran parte de lo que exigía UIScrollView. Un envoltorio es deuda permanente: te obliga a mantener dos modelos mentales, rompe las vistas previas con más facilidad y te deja fuera de las mejoras del sistema. Escríbelo cuando el hueco sea real y documenta en el propio archivo qué API nativa lo dejaría obsoleto.

Escribir el envoltorio es escribir el reconciliador que nunca ves

Hay una razón profunda por la que este protocolo se siente incómodo la primera vez, y no es su verbosidad. Al conformarlo estás implementando a mano, para una sola vista, exactamente el mismo algoritmo que SwiftUI ejecuta miles de veces por segundo para todo el resto del árbol: tomar una descripción declarativa, compararla con el estado real de un objeto persistente y emitir el conjunto mínimo de mutaciones que lleva del segundo a la primera. makeUIView es la inserción de un nodo, updateUIView es la actualización de sus atributos, dismantleUIView es su eliminación, y la identidad de la vista es la clave que decide cuál de las tres ocurre. Eso significa que UIViewRepresentable no es una puerta trasera hacia lo imperativo, sino una ventana hacia lo que el marco hace por ti: mientras escribes las comparaciones campo a campo para evitar efectos secundarios, estás viviendo el trabajo que el sistema hace en silencio con cada Text y cada Color de tu app. Y de esa lectura sale la consecuencia práctica más útil: si te cuesta escribir un updateUIView idempotente, no es que el envoltorio sea difícil, es que el objeto que estás envolviendo tiene estado que no está representado en ningún valor de tu modelo. La incomodidad no es del puente, es un diagnóstico sobre tu propio diseño de estado, y suele señalar la propiedad que deberías haber elevado hace tres pantallas.

📝
Lo esencial

El envoltorio es una struct efímera; la vista de UIKit es un objeto persistente. makeUIView construye una vez lo que no depende del estado; updateUIView sincroniza y debe ser idempotente, comparando antes de asignar y sin escribir jamás en el estado de SwiftUI; sizeThatFits traduce entre propuestas y tamaño intrínseco; dismantleUIView limpia. El context te da coordinador, entorno y transacción, y esta última es la única forma correcta de animar al ritmo de SwiftUI.

⚔️ Un envoltorio que resiste mil actualizaciones
  1. Envuelve una UITextView con enlace bidireccional al texto y comprueba, con un contador en updateUIView, cuántas veces se llama al escribir una sola letra.
  2. Quita las comparaciones previas a la asignación y describe con precisión qué le pasa al cursor y por qué.
  3. Implementa sizeThatFits para que la vista crezca en alto con el contenido dentro de un Form, y responde correctamente a los cuatro tipos de propuesta.
  4. Lee dynamicTypeSize y colorScheme desde el entorno del context y aplícalos a la vista envuelta sin usar ninguna propiedad del envoltorio.
  5. Envuelve la vista en una rama condicional que cambie su identidad y documenta qué se pierde en cada reconstrucción; después arregla la identidad sin tocar el envoltorio.