wandres.dev
EL SISTEMA DE LAYOUT · cómo mide SwiftUI

El protocolo Layout: escribir tu propio contenedor

Desde iOS 16 el algoritmo de layout dejó de ser privado: el protocolo Layout te da el mismo contrato que usan HStack y VStack, con sizeThatFits para responder al padre y placeSubviews para colocar a los hijos. Esta lección construye un contenedor de flujo completo, explica el papel de la caché y del origen de los bounds, y delimita cuándo un Layout propio es la respuesta correcta.

⏱ 20 min

Durante los tres primeros años de SwiftUI, el conjunto de contenedores era el que Apple hubiera decidido escribir, y quien necesitara otra cosa —una nube de etiquetas que salta de línea, una rueda de elementos, un reparto en proporciones exactas— tenía que fabricarla midiendo con GeometryReader y empujando vistas con offset, es decir, saliéndose del sistema y renunciando a todas sus garantías. El protocolo Layout cerró esa brecha de la forma más limpia posible: no añadió contenedores nuevos, publicó el contrato. Los dos métodos que vas a implementar son exactamente los dos que implementa HStack, reciben los mismos argumentos y los llama el mismo motor. No estás simulando un contenedor: estás escribiendo uno, con los mismos derechos que los de la biblioteca estándar.

🎯 Al terminar esta lección sabrás
  • Enunciar el contrato de Layout y relacionar cada método con una de las tres fases de la negociación.
  • Consultar y colocar hijos a través de la interfaz LayoutSubview sin salir del sistema de layout.
  • Implementar un contenedor de flujo completo que reparta sus hijos en filas sucesivas.
  • Justificar el uso de la caché y reconocer cuándo un Layout propio es preferible a las alternativas.

El contrato: dos métodos

Layout exige dos métodos, y su correspondencia con la lección primera es exacta. sizeThatFits es la fase dos vista desde dentro: recibe la propuesta del padre y devuelve el tamaño que tu contenedor elige. placeSubviews es la fase tres: recibe el rectángulo que finalmente te concedieron y coloca ahí a cada hijo. El motor llama siempre al primero antes que al segundo, y puede llamar al primero varias veces con propuestas distintas antes de decidirse.

protocol Layout {
    func sizeThatFits(proposal: ProposedViewSize,
                      subviews: Subviews,
                      cache: inout Cache) -> CGSize

    func placeSubviews(in bounds: CGRect,
                       proposal: ProposedViewSize,
                       subviews: Subviews,
                       cache: inout Cache)
}

El parámetro subviews es una colección de LayoutSubview, y cada elemento es una vista con la que puedes hablar exactamente como habla un padre: preguntarle sizeThatFits con la propuesta que quieras, consultar su priority —la que fijó layoutPriority—, pedirle el espaciado que prefiere frente a sus vecinas y, en la fase de colocación, llamar a place indicando punto, ancla y propuesta. Nada más. No puedes leer su contenido, no puedes modificarla y no puedes obligarla a medir lo que tú quieras: sigues sujeto a la soberanía del hijo.

⚠️
El origen de los bounds no es cero

El rectángulo que recibe placeSubviews puede estar situado en cualquier punto del espacio de coordenadas del padre. Colocar en CGPoint(x: 0, y: 0) funciona por casualidad mientras tu contenedor esté arriba a la izquierda y falla en cuanto lo metas en cualquier otro sitio. Toda posición debe partir de bounds.minX y bounds.minY.

Un contenedor de flujo

El ejemplo canónico es el que la biblioteca no trae: una fila que, cuando se queda sin ancho, continúa en la línea siguiente. La estructura del cálculo es común a los dos métodos, así que conviene extraerla a una función que reparta los índices en filas.

struct FlujoHorizontal: Layout {
    var espaciado: CGFloat = 8

    struct Fila {
        var elementos: [Int] = []
        var alto: CGFloat = 0
    }

    func repartir(ancho: CGFloat, subviews: Subviews) -> [Fila] {
        var filas: [Fila] = []
        var actual = Fila()
        var x: CGFloat = 0

        for indice in subviews.indices {
            let medida = subviews[indice].sizeThatFits(.unspecified)
            if x + medida.width > ancho, !actual.elementos.isEmpty {
                filas.append(actual)
                actual = Fila()
                x = 0
            }
            actual.elementos.append(indice)
            actual.alto = max(actual.alto, medida.height)
            x += medida.width + espaciado
        }
        if !actual.elementos.isEmpty { filas.append(actual) }
        return filas
    }
}

Con eso, los dos métodos del protocolo quedan casi triviales. El primero acota la propuesta —replacingUnspecifiedDimensions sustituye los nil por valores razonables, imprescindible porque dentro de un ScrollView la propuesta llega sin una de sus dimensiones— y suma las alturas de las filas:

extension FlujoHorizontal {
    func sizeThatFits(proposal: ProposedViewSize,
                      subviews: Subviews,
                      cache: inout ()) -> CGSize {
        let ancho = proposal.replacingUnspecifiedDimensions().width
        let filas = repartir(ancho: ancho, subviews: subviews)
        let separaciones = espaciado * CGFloat(max(filas.count - 1, 0))
        let alto = filas.reduce(0) { $0 + $1.alto } + separaciones
        return CGSize(width: ancho, height: alto)
    }

    func placeSubviews(in bounds: CGRect,
                       proposal: ProposedViewSize,
                       subviews: Subviews,
                       cache: inout ()) {
        var y = bounds.minY
        for fila in repartir(ancho: bounds.width, subviews: subviews) {
            var x = bounds.minX
            for indice in fila.elementos {
                let medida = subviews[indice].sizeThatFits(.unspecified)
                subviews[indice].place(at: CGPoint(x: x, y: y),
                                       anchor: .topLeading,
                                       proposal: ProposedViewSize(medida))
                x += medida.width + espaciado
            }
            y += fila.alto + espaciado
        }
    }
}

Usarlo no requiere nada más, porque el protocolo aporta una implementación de callAsFunction con constructor de vistas incorporado. La sintaxis es la de cualquier contenedor de la biblioteca:

FlujoHorizontal(espaciado: 6) {
    ForEach(etiquetas, id: \.self) { texto in
        Text(texto).padding(6).background(.quaternary, in: Capsule())
    }
}
flowchart TB
padre[El padre propone un tamano] --> stf[Llamada a sizeThatFits]
stf --> sonda[El layout sondea a cada hijo]
sonda --> devuelve[Devuelve el tamano del contenedor]
devuelve --> bounds[El padre concede un rectangulo]
bounds --> place[Llamada a placeSubviews]
place --> coloca[place con punto ancla y propuesta por hijo]
style padre fill:#f5c2e7,color:#11111b
style stf fill:#89b4fa,color:#11111b
style coloca fill:#a6e3a1,color:#11111b

La caché y el sondeo repetido

Habrás notado que repartir se ejecuta dos veces y que sondea a todos los hijos en cada pasada. En un flujo de veinte etiquetas es irrelevante; en uno de quinientas, dentro de un scroll que recalcula a cada fotograma, no lo es. Para eso existe el tipo asociado Cache y los métodos makeCache y updateCache: un espacio de trabajo que el motor conserva entre la llamada de medida y la de colocación, y que solo se invalida cuando cambia el conjunto de hijos. La disciplina es guardar en él lo caro —las medidas individuales, el reparto en filas— y nunca lo que dependa del rectángulo final, porque ese llega después.

📦

Subviews

Una colección de proxies. Consulta sizeThatFits, dimensions, priority y spacing; coloca con place. Es toda la superficie de contacto con los hijos, y es deliberadamente estrecha.

🧭

explicitAlignment

El método opcional con el que tu contenedor expone guías hacia arriba. Implementarlo es lo que permite que un Layout propio participe en las alineaciones entre contenedores de la lección anterior.

🔁

AnyLayout

Borra el tipo concreto y permite intercambiar un layout por otro dentro de una animación, conservando la identidad de los hijos. Pasar de fila a columna deja de ser un corte y se convierte en una transición continua.

Cuándo merece la pena

Un Layout propio es la respuesta correcta cuando la regla de colocación que necesitas no se puede expresar como composición de stacks, prioridades y alineaciones: repartos proporcionales exactos, disposiciones radiales, flujos con salto de línea, rejillas cuyo número de columnas depende de la medida real del contenido. Es la respuesta incorrecta cuando lo único que quieres es alinear cosas —eso es la lección cuatro— o cuando lo que buscas es un tamaño que ya te daría un frame flexible.

Y hay una tercera opción que conviene descartar explícitamente: medir con GeometryReader y desplazar con offset. Ese camino funciona en la demo y falla en producción, porque la medida llega un ciclo tarde, porque escribir estado desde una lectura geométrica introduce ciclos de actualización, y sobre todo porque las vistas colocadas con offset mienten al sistema sobre dónde están: la animación, el enfoque de accesibilidad y las transiciones siguen creyendo que están en su posición original. Un Layout no tiene ninguno de esos problemas porque no es un parche sobre el sistema: es el sistema.

💡
Prueba tu layout con las tres propuestas

Antes de darlo por bueno, mete tu contenedor en un ScrollView vertical, en uno horizontal y en un frame diminuto. Las tres situaciones envían propuestas con nil, con infinito y con un valor imposible, y son exactamente las que revelan que olvidaste replacingUnspecifiedDimensions o que asumiste un origen en cero.

Publicar el contrato en vez de ampliar la biblioteca

La decisión de diseño que hay detrás de Layout es más interesante que el protocolo mismo, y tiene un nombre en la literatura de lenguajes: cerrar la brecha entre lo primitivo y lo definible por el usuario. En la primera versión de SwiftUI, HStack era mágico en el sentido técnico del término: hacía algo que ningún código escrito por ti podía hacer, porque el algoritmo que ejecutaba no estaba expuesto en ninguna API. Esa asimetría es una deuda de diseño que los frameworks pagan siempre en la misma moneda, la de la extensión por imitación: los usuarios reconstruyen aproximaciones frágiles con las herramientas que sí tienen —aquí, medir y desplazar— y el resultado es un ecosistema de soluciones que se rompen en cada actualización porque dependen de detalles que nadie prometió. Apple podría haber respondido añadiendo contenedores: un FlowStack, un RadialStack, uno más cada año, en la carrera perdida de anticipar todos los diseños posibles. Eligió en cambio publicar el contrato, y con ello reclasificar sus propios contenedores de primitivas a instancias: HStackLayout y VStackLayout existen y conforman al mismo protocolo que escribes tú. Es el mismo movimiento que hizo Swift al reescribir sus operadores como funciones ordinarias declarables por el usuario, o al convertir el bucle for en azúcar sobre un protocolo que cualquiera puede implementar. El principio general se conoce desde los años setenta y Guy Steele lo formuló como que un lenguaje debe diseñarse para crecer: en vez de proveer las características que los usuarios necesitan, provee los medios con los que los usuarios se las construyen, y usa esos mismos medios para construir las tuyas. La prueba de que la promesa se cumplió es concreta y verificable: nada de lo que hace HStack requiere información privilegiada, y el flujo horizontal de esta lección compite en igualdad de condiciones con él —misma pasada, mismo coste lineal, misma participación en animaciones y en alineaciones. Cuando un framework alcanza ese punto, deja de ser un catálogo y empieza a ser un lenguaje.

📝
Lo esencial de esta lección

Layout publica el contrato de los contenedores: sizeThatFits responde al padre con el tamaño elegido y placeSubviews coloca a los hijos dentro del rectángulo concedido, siempre partiendo del origen de los bounds. Los hijos se manipulan a través de LayoutSubview, que permite sondear, consultar prioridad y colocar, pero nunca imponer. La caché evita repetir sondeos entre ambas llamadas. Un Layout propio es la alternativa correcta a medir con GeometryReader y desplazar con offset, porque participa en el sistema en vez de esquivarlo.

⚔️ Escribe y estresa tu contenedor
  1. Implementa el flujo horizontal completo y comprueba que respeta el espaciado tanto entre elementos como entre filas.
  2. Rompe el contenedor a propósito colocando en cero absoluto en vez de en el origen de los bounds, y encuentra el contexto donde el fallo se hace visible.
  3. Añade un tipo Cache que guarde las medidas individuales y mide la diferencia con un centenar de elementos.
  4. Haz que tu layout respete priority, sirviendo primero a los hijos de mayor prioridad cuando una fila no llegue.
  5. Alterna con AnyLayout entre tu flujo y un VStackLayout dentro de un withAnimation y observa qué conserva la identidad de las vistas.