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

La negociación del tamaño: quién propone, quién decide, quién coloca

El layout de SwiftUI no resuelve ecuaciones: negocia. El padre propone un tamaño, el hijo elige el suyo con total soberanía, y el padre solo puede decidir dónde ponerlo. Esta lección reconstruye las tres fases del algoritmo, explica qué significan las propuestas especiales de cero, infinito y sin especificar, y muestra por qué esta conversación local sustituye al solucionador global de restricciones de Auto Layout.

⏱ 18 min

Casi todo el desconcierto que produce el layout de SwiftUI viene de asumir que funciona como el sistema que sustituyó. En Auto Layout tú declarabas restricciones y un solucionador global buscaba una asignación de números que las satisficiera todas a la vez; si no existía, el motor rompía la restricción más barata y te lo contaba en la consola. SwiftUI no hace nada parecido. No hay solucionador, no hay restricciones y no hay conflictos. Hay una conversación estrictamente local entre cada vista y su hijo, que ocurre una sola vez por fotograma, en la que cada participante tiene un papel fijo y ninguno puede invadir el del otro. El padre propone un tamaño, el hijo elige el suyo, el padre lo coloca. Toda esta parte de la guía es el desarrollo de esa única frase.

🎯 Al terminar esta lección sabrás
  • Enunciar las tres fases del algoritmo de layout y decir quién tiene la última palabra en cada una.
  • Interpretar una propuesta como una sugerencia con dos dimensiones opcionales, no como una imposición.
  • Predecir el tamaño resultante de Text, Image, Color y Spacer ante las propuestas de cero, infinito y sin especificar.
  • Explicar por qué el algoritmo recorre el árbol una sola vez y qué consecuencias tiene eso para el rendimiento.

Las tres fases

El sistema arranca desde la raíz. La ventana, o más exactamente el área segura que el sistema le concede a la escena, propone un tamaño a la vista raíz. A partir de ahí la conversación se repite, idéntica, en cada nivel del árbol:

  1. El padre propone. Entrega a su hijo un tamaño propuesto, que en la API pública es un ProposedViewSize con dos campos, width y height, ambos de tipo CGFloat?. Es una sugerencia. Nada obliga al hijo a respetarla.
  2. El hijo responde con su tamaño. Consulta la propuesta, decide y devuelve un tamaño concreto. Esa respuesta es definitiva: el padre no puede recortarla, estirarla ni negociarla otra vez.
  3. El padre coloca. Con el tamaño ya cerrado, el padre elige un origen dentro de su propio espacio y ahí queda el hijo. Es lo único que el padre controla de verdad.

Estas tres fases no son una metáfora didáctica: desde iOS 16 son API pública, y las dos últimas tienen nombre propio en el protocolo que escribirás en la lección cinco de este mismo nivel.

// La cara publica de las fases dos y tres
func sizeThatFits(proposal: ProposedViewSize,
                  subviews: Subviews,
                  cache: inout Cache) -> CGSize

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

Merece la pena leer las firmas antes de entender nada más: la primera recibe una propuesta y devuelve un tamaño; la segunda recibe un rectángulo ya concedido y no devuelve nada, porque su trabajo es colocar. Toda la asimetría del sistema está codificada ahí.

flowchart TB
raiz[La ventana propone a la vista raiz] --> prop[Fase 1 el padre propone un tamano]
prop --> hijo[Fase 2 el hijo elige su propio tamano]
hijo --> resp[La respuesta es definitiva]
resp --> coloca[Fase 3 el padre elige el origen]
coloca --> abajo[Se repite en cada nivel hacia abajo]
style raiz fill:#f5c2e7,color:#11111b
style hijo fill:#89b4fa,color:#11111b
style resp fill:#a6e3a1,color:#11111b

La asimetría es la clave y conviene decirla en voz alta: el hijo manda sobre su tamaño, el padre manda sobre su posición. Un HStack no puede encoger a un Text que ha decidido medir trescientos puntos; lo único que puede hacer es proponerle menos y confiar en que el Text decida partirse en dos líneas. Si el hijo insiste en su tamaño, el hijo se desborda, y SwiftUI lo dibujará fuera de los límites del padre sin protestar. No hay error, no hay aviso: hay un dibujo que se sale.

La propuesta y sus casos límite

Que las dos dimensiones sean opcionales no es un detalle de implementación. Un nil en una dimensión significa no tengo preferencia en ese eje, dime tú qué tamaño querrías. Y hay tres propuestas con significado convenido que el sistema usa constantemente por dentro:

// Las tres propuestas canonicas
ProposedViewSize.zero          // dame tu tamano minimo
ProposedViewSize.infinity      // dame tu tamano maximo
ProposedViewSize.unspecified   // dame tu tamano ideal

Sondear una vista con .zero y luego con .infinity devuelve su rango de flexibilidad, y ese par de números es el material con el que trabajan los contenedores. La propuesta .unspecified pide el tamaño ideal, que es lo que ocurre dentro de un ScrollView en el eje del scroll, donde no existe un límite que proponer, y también lo que usa fixedSize, como veremos en la lección tres.

Cuando escribas tú un contenedor tendrás que decidir qué hacer con esos nil, y la biblioteca te da un atajo con una política razonable por omisión:

let propuesta = ProposedViewSize(width: nil, height: 400)
let concreta = propuesta.replacingUnspecifiedDimensions()
// width pasa a valer 10, height se conserva en 400

El diez que aparece de la nada es el mismo número arbitrario que devuelven Color y Spacer ante una propuesta sin especificar. No tiene ningún significado geométrico: existe para que una vista máximamente flexible interrogada sin contexto no colapse a un rectángulo de área cero e invisible.

📝

Text negocia

Acepta la anchura propuesta y calcula la altura que necesita para ese ancho, partiendo líneas. Ante .zero devuelve el ancho de su palabra más larga truncada; nunca miente sobre lo que necesita para dibujarse.

🖼️

Image ignora

Una imagen sin resizable devuelve siempre su tamaño intrínseco en puntos, sea cual sea la propuesta. Es el ejemplo más limpio de hijo soberano, y la causa habitual de los desbordamientos.

🟪

Color y Spacer ceden

Son máximamente flexibles: devuelven exactamente lo que se les proponga. Ante .unspecified devuelven diez puntos, un valor arbitrario que existe solo para que un preview no colapse a cero.

Este pequeño catálogo ya explica el error más común del principiante. Escribir un Color.red dentro de un VStack sin más da una banda que ocupa todo el ancho disponible, porque el color acepta la propuesta entera; escribir un Image gigantesca en el mismo sitio da un dibujo que rebasa la pantalla, porque la imagen no negocia. Las dos vistas están en el mismo contenedor, reciben la misma propuesta, y se comportan de forma opuesta. La diferencia no está en el padre: está en la política de cada hijo.

Leer un árbol como una conversación

Los modificadores no modifican la vista: la envuelven. Cuando escribes .padding(20) sobre un Text, no estás cambiando el Text, estás creando una vista nueva cuyo hijo es ese Text, y esa vista nueva participa en la negociación como un eslabón más de la cadena. Verlo así permite razonar cualquier apilamiento de modificadores como un diálogo lineal:

struct Tarjeta: View {
    var body: some View {
        Text("Un titular razonablemente largo")
            .padding(20)
            .background(.thinMaterial)
    }
}

El árbol real que se construye no es una vista con dos adornos, sino tres vistas anidadas, y escribirlo explícitamente vuelve obvia la conversación que viene después:

// El arbol equivalente, sin la sintaxis de modificadores
Background(
    Padding(20,
        Text("Un titular razonablemente largo")),
    .thinMaterial)

La conversación completa, si el padre propone trescientos por doscientos, es esta: el background propone trescientos por doscientos a su hijo, el padding. El padding resta veinte por cada lado y propone doscientos sesenta por ciento sesenta al Text. El Text parte el texto en las líneas que hagan falta para caber en doscientos sesenta de ancho y devuelve, pongamos, doscientos cuarenta por cuarenta. El padding suma sus márgenes y devuelve doscientos ochenta por ochenta. El background devuelve exactamente el tamaño de su hijo, doscientos ochenta por ochenta, porque un fondo nunca cambia el tamaño de lo que decora: se limita a proponerle a la capa de fondo el tamaño ya decidido.

💡
La pregunta que desatasca

Cuando una vista aparezca con un tamaño que no esperabas, no mires la vista: recorre la cadena de modificadores de fuera adentro preguntando en cada eslabón qué propone hacia dentro, y luego de dentro afuera preguntando qué devuelve. El punto exacto donde el número deja de tener sentido es el eslabón culpable.

⚠️
El orden no es decorativo

.padding(20).background(.red) y .background(.red).padding(20) producen dibujos distintos, porque la cadena de envoltorios es distinta y por tanto la conversación es otra. Cada modificador se inserta como padre de todo lo que tiene encima suyo en el código.

Un caso aparte, porque rompe la intuición de todo el mundo la primera vez, es GeometryReader. No es una vista que mida a su hijo: es una vista máximamente flexible que acepta la propuesta entera y luego te entrega esas medidas para que construyas el contenido. Por eso colapsa cualquier layout donde lo metas sin pensar, y por eso no sirve para averiguar cuánto mide una vista, sino cuánto espacio le ofrecieron a él.

Una sola pasada

Reconstruyamos el coste. El algoritmo desciende proponiendo y asciende devolviendo tamaños. Cada vista habla exactamente una vez con cada uno de sus hijos, salvo los contenedores que necesitan sondear la flexibilidad y hacen dos o tres llamadas adicionales por hijo. No hay iteración hasta la convergencia, no hay backtracking, no hay reintento. El layout de un árbol de n vistas es, salvo por esos sondeos constantes, lineal en n.

De ese diseño se sigue una obligación que casi nunca se enuncia y que conviene tener presente desde el primer día: responder al tamaño propuesto tiene que ser una función pura. El sistema puede preguntarte varias veces con propuestas distintas antes de decidirse, puede preguntarte y luego descartar el resultado, y puede hacerlo en un orden que tú no controlas. Una vista que guarde estado al ser medida, o que produzca respuestas distintas ante la misma propuesta, introduce en un algoritmo determinista una dependencia del orden de evaluación, y el síntoma clásico es un layout que parpadea entre dos configuraciones sin estabilizarse jamás.

Por qué Apple abandonó el solucionador

Auto Layout está construido sobre Cassowary, un algoritmo de resolución de restricciones lineales con jerarquías de prioridad publicado por Badros y Borning en 1997, que a su vez es una variante incremental del método símplex. La idea era intelectualmente hermosa: describes relaciones entre vistas —esta a la derecha de aquella, ambas del mismo ancho, este margen de al menos ocho— y un motor busca la solución óptima del sistema. El problema es que un solucionador de restricciones es una máquina global. Añadir una restricción en un rincón de la pantalla puede cambiar los números en el rincón opuesto, el coste crece de forma superlineal con el número de restricciones, y cuando el sistema es insatisfacible el motor no puede hacer otra cosa que romper la restricción de menor prioridad y volcar en la consola un bloque de texto que ningún desarrollador ha leído entero jamás. Peor aún para un framework declarativo: un sistema global es incompatible con la composición, porque el resultado de meter una vista en un contenedor depende de todo lo demás que hubiera dentro. SwiftUI hizo la apuesta contraria y eligió la localidad como principio rector: nadie ve más allá de sus hijos inmediatos, la negociación se resuelve con una llamada de ida y otra de vuelta, y por construcción no puede existir un conflicto porque nunca hay dos partes decidiendo el mismo número. El precio de esa decisión es real y lo pagarás durante todo este nivel: no puedes expresar directamente relaciones entre vistas que no sean parientes —alinear la etiqueta de un formulario con la de otra sección exige el mecanismo de guías de alineación de la lección cuatro—, y no puedes pedir que dos hermanos midan lo mismo sin un rodeo. A cambio obtienes un algoritmo que cabe en la cabeza, que compone sin sorpresas, que corre en tiempo lineal sesenta o ciento veinte veces por segundo, y en el que la pregunta por qué esta vista mide esto siempre tiene una respuesta local y comprobable. Es la misma clase de decisión que llevó a los sistemas de tipos modernos a preferir la inferencia local y bidireccional sobre la unificación global: renunciar a poder expresivo a cambio de predictibilidad y de diagnósticos que un ser humano pueda leer.

📝
Lo esencial de esta lección

El layout es una negociación de tres fases que se repite en cada nivel del árbol: el padre propone un tamaño, el hijo elige el suyo con la última palabra, y el padre solo decide dónde colocarlo. La propuesta tiene dos dimensiones opcionales y tres valores canónicos: cero pide el mínimo, infinito pide el máximo, sin especificar pide el ideal. Los modificadores son envoltorios que participan en la conversación como eslabones. El recorrido es único y descendente, sin solucionador global ni conflictos posibles.

⚔️ Instrumenta la conversación
  1. Escribe un modificador de depuración que use background con un GeometryReader y muestre el tamaño real de cualquier vista sobre ella misma.
  2. Aplícalo a un Text largo, a una Image sin resizable y a un Color, todos dentro del mismo VStack, y anota los tres tamaños.
  3. Repite el experimento metiendo las tres vistas en un ScrollView vertical y explica cuál cambia y por qué.
  4. Encadena padding, background y padding en dos órdenes distintos y dibuja a mano el árbol de envoltorios de cada uno antes de ejecutarlo.
  5. Predice por escrito el tamaño de Text("hola").frame(width: 10) y comprueba si el texto se desborda o se trunca.