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

Prioridades y flexibilidad: cómo se reparte el espacio entre hermanos

Un stack no divide el espacio a partes iguales: sondea a cada hijo para medir su rango de flexibilidad, los ordena de menos flexible a más flexible y los sirve en ese turno. Esta lección reconstruye el algoritmo de reparto, explica qué altera exactamente layoutPriority, y muestra por qué fixedSize convierte una vista negociadora en una vista inflexible.

⏱ 18 min

Dos textos en la misma fila, uno largo y otro corto, y el largo aparece truncado con puntos suspensivos mientras al corto le sobra sitio. Es la escena que todo el mundo encuentra en su primera semana y la que suele resolverse por prueba y error, poniendo anchos fijos hasta que la cosa cuadra. Lo que ocurre debajo no tiene nada de arbitrario: un stack ejecuta un algoritmo de reparto perfectamente determinista, que empieza midiendo cuánto puede estirarse o encogerse cada hijo, sigue ordenándolos por esa capacidad de adaptación y termina sirviéndolos por turnos, restando de la bolsa común lo que cada uno se lleva. Quien conoce ese algoritmo no necesita anchos fijos: necesita cambiar el turno, y para eso hay exactamente dos herramientas.

🎯 Al terminar esta lección sabrás
  • Describir el sondeo con propuesta de cero y de infinito con el que un contenedor mide la flexibilidad de un hijo.
  • Reproducir el orden de reparto de un stack y calcular a mano la propuesta que recibe cada hermano.
  • Usar layoutPriority entendiendo que altera el turno y no el tamaño.
  • Explicar qué propone fixedSize a su hijo y en qué se convierte la vista resultante.

El reparto: de menos flexible a más flexible

Antes de proponer nada en serio, un HStack interroga a cada hijo dos veces. Le propone .zero para averiguar su tamaño mínimo, y le propone .infinity para averiguar el máximo. La diferencia entre ambos números es la flexibilidad de ese hijo en ese eje: cuánto margen tiene para adaptarse. Un Spacer va de cero a infinito y es máximamente flexible; una Image sin resizable devuelve lo mismo en ambos sondeos y su flexibilidad es cero; un Text va del ancho de su palabra más larga al ancho de todo su contenido en una línea.

Con esos rangos en la mano, el stack ejecuta un reparto codicioso:

  1. Resta del ancho disponible el espaciado que le corresponde entre elementos.
  2. Ordena a los hijos de menos flexible a más flexible.
  3. Al primero de la cola le propone la bolsa restante dividida entre el número de hijos que aún no han sido servidos.
  4. El hijo responde con su tamaño real, que puede ser menor que lo ofrecido. Lo que devuelve se resta de la bolsa y el hijo sale de la cola.
  5. Repite con el siguiente, recalculando siempre la división con la bolsa y el recuento actualizados.
flowchart TB
bolsa[Ancho disponible menos el espaciado] --> sondeo[Sondear cada hijo con cero y con infinito]
sondeo --> orden[Ordenar de menos flexible a mas flexible]
orden --> turno[Proponer bolsa dividida entre los pendientes]
turno --> resp[El hijo devuelve su tamano real]
resp --> resta[Restar de la bolsa y quitar de la cola]
resta --> turno
resta --> fin[Cuando la cola queda vacia se colocan todos]
style bolsa fill:#f5c2e7,color:#11111b
style orden fill:#89b4fa,color:#11111b
style fin fill:#a6e3a1,color:#11111b

Ese sondeo no es una abstracción inaccesible: es exactamente lo que puedes hacer tú desde el protocolo de la lección cinco, y verlo escrito ayuda a fijar la idea.

// Lo que hace un contenedor antes de repartir nada
let minimo = subview.sizeThatFits(.zero).width
let maximo = subview.sizeThatFits(.infinity).width
let flexibilidad = maximo - minimo

El orden es lo que hay que grabar, porque no es caprichoso. Servir primero a quien menos puede adaptarse es la única política que no desperdicia espacio: una vista rígida va a coger lo que necesita sea cual sea la propuesta, así que preguntarle pronto permite que las flexibles absorban después lo que quede. El orden inverso repartiría a partes iguales entre todos y luego descubriría que a las rígidas les sobra o les falta, obligando a una segunda pasada. Recuerda que en este sistema no hay segundas pasadas.

Ahora el caso de los dos textos se explica solo. Ambos tienen flexibilidad parecida, ninguno es claramente más rígido, así que el stack los sirve en orden de aparición dividiendo la bolsa en dos mitades. El texto corto pide menos de su mitad y devuelve lo suyo; el largo recibe la mitad más lo que sobró y aun así no le llega, y hace lo único que un Text sabe hacer en esa situación: truncar.

Las prioridades cambian el turno

layoutPriority acepta un Double que vale cero por omisión en todas las vistas. Su efecto es tajante: el stack agrupa a los hijos por prioridad y sirve los grupos de mayor a menor, ofreciendo al grupo de más prioridad toda la bolsa antes de que los demás vean nada. Solo dentro de cada grupo se aplica el reparto por flexibilidad de la sección anterior.

HStack {
    Text("Un titular largo que no deberia truncarse jamas")
        .layoutPriority(1)
    Text("secundario")
}

Aquí el titular se sirve solo, con la bolsa entera a su disposición, y devuelve lo que necesite. Lo que quede pasa al segundo texto, que se apañará o truncará. Fíjate en lo que no ha ocurrido: nadie ha fijado un ancho, nadie ha calculado proporciones, y el resultado sigue adaptándose al Dynamic Type, a la rotación y a la traducción al alemán. Cambiar el turno es una intervención mucho más barata y mucho más robusta que imponer números.

⚠️
La prioridad no reparte proporciones

Un valor de dos no da el doble de espacio que un valor de uno. Solo cuenta el orden relativo: cualquier número mayor sirve antes, y las magnitudes concretas son irrelevantes salvo para desempatar. Si necesitas repartos proporcionales, lo que buscas es un Layout propio, no una prioridad.

Los valores negativos son legales y a veces son la expresión más honesta de la intención. Marcar una vista como sacrificable dice algo distinto que marcar a otra como preferente, aunque el orden resultante sea el mismo, y quien lea el código dentro de seis meses agradecerá la diferencia:

HStack {
    Text(titulo)
    Text(subtitulo).layoutPriority(-1)   // este es el que puede ceder
    Image(systemName: "chevron.right")
}

fixedSize: renunciar a negociar

fixedSize hace una sola cosa, y de ella se derivan todos sus usos y todas sus trampas: propone a su hijo nil en el eje indicado, es decir, le pide su tamaño ideal, y después informa hacia arriba de ese tamaño ideal ignorando por completo lo que el padre hubiera propuesto. El resultado es una vista de flexibilidad cero: sondeada con .zero y con .infinity devuelve el mismo número las dos veces.

HStack {
    Text("Un titular largo que no deberia truncarse jamas")
        .fixedSize(horizontal: true, vertical: false)
    Text("secundario")
}

Fíjate en que los dos ejes se controlan por separado, y eso importa más de lo que parece. El uso más frecuente del modificador en apps reales es exactamente el contrario del ejemplo: fijar el eje vertical de un texto dentro de una columna estrecha, para que pueda ocupar todas las líneas que necesite en lugar de comprimirse a una sola.

VStack {
    Text(descripcionLarga)
        .fixedSize(horizontal: false, vertical: true)
    boton
}

Aquí el ancho se sigue negociando con normalidad, y solo la altura se pide como ideal. La variante sin argumentos, fixedSize(), fija los dos ejes a la vez y es la que más desbordamientos provoca, porque renuncia a negociar en un eje donde casi nunca hacía falta.

Este código también evita el truncamiento, pero por un camino distinto al de la prioridad, y la diferencia importa. Con layoutPriority el titular sigue negociando: si de verdad no cabe, se partirá o truncará antes que desbordar. Con fixedSize el titular ha dejado de negociar: si no cabe, se desborda fuera del stack y se dibuja encima de sus hermanos. Uno pide preferencia, el otro renuncia al proceso.

🔓

Flexible del todo

Spacer, Color, Rectangle: van de cero a infinito. Absorben cualquier sobrante y son las últimas de la cola. Colocar dos en una fila reparte el hueco a partes iguales entre ellas.

🔀

Flexible con rango

Text y los stacks: tienen un mínimo real y un máximo real. Son las vistas donde el turno decide el resultado, y por tanto donde layoutPriority se nota.

🔒

Rígida

Image sin resizable, cualquier cosa bajo un frame rígido, y todo lo envuelto en fixedSize. Se sirven primero y siempre devuelven el mismo número.

Diagnosticar un reparto

Antes de tocar nada conviene saber qué tres preguntas se están respondiendo, y en qué orden: cuánto puede encoger cada hijo, en qué turno se le sirve, y cuánto quedaba en la bolsa cuando le llegó. Un reparto insatisfactorio siempre se explica por una de las tres, y cada una tiene su propia herramienta.

La herramienta de diagnóstico es el sondeo hecho a mano. Envuelve el hijo sospechoso en un contenedor que le proponga .zero y otro que le proponga .infinity, mira los dos números, y compáralos con el ancho que realmente recibió. Si el mínimo ya es mayor que su parte de la bolsa, ninguna prioridad lo va a salvar y el problema está en el contenido. Si el mínimo cabe pero el resultado está truncado, el problema es de turno y layoutPriority lo resuelve. Si el resultado ignora la propuesta y desborda, hay un fixedSize o un frame rígido escondido en la cadena.

💡
Empieza siempre por el turno

Ante un reparto que no te gusta, prueba en este orden: primero layoutPriority, que es reversible y no rompe la adaptabilidad; luego fixedSize, que la rompe a cambio de garantías; y solo al final un tamaño explícito, que la rompe del todo. La mayoría de los layouts que acaban llenos de números fijos se estropearon en el primer paso.

Un algoritmo codicioso donde cabía uno óptimo

Merece la pena reconocer qué clase de problema es este. Repartir una cantidad escasa entre varios consumidores con rangos de aceptación distintos, buscando una asignación que respete todos los rangos y minimice el desperdicio, es un problema de programación lineal, y existen algoritmos que lo resuelven de forma óptima. Los gestores de layout tradicionales han tirado por ahí una y otra vez: las cajas de TeX de Knuth resuelven la justificación de un párrafo con programación dinámica global sobre todas las líneas a la vez; Auto Layout resuelve el reparto con símplex incremental; incluso el algoritmo de flexbox de la web hace varias pasadas de distribución y redistribución con las famosas fracciones de crecimiento y encogimiento. SwiftUI eligió a conciencia lo contrario: un algoritmo codicioso, de una sola pasada, que ordena por un heurístico —la flexibilidad— y sirve por turnos sin volver nunca atrás. Es demostrablemente subóptimo, y puedes construir casos donde deja hueco sin usar mientras una vista se trunca. La pregunta interesante no es por qué aceptaron ese defecto, sino qué compraron con él. Compraron tres cosas. La primera es composicionalidad: un reparto codicioso local sigue siendo local, y un subárbol se comporta igual esté donde esté, cosa que una optimización global no puede garantizar. La segunda es un presupuesto de tiempo predecible, que en una interfaz que debe cerrar el fotograma en ocho milisegundos a ciento veinte hercios no es un lujo sino un requisito duro. La tercera, y quizá la más importante para quien escribe la app, es la explicabilidad: cuando el reparto sale mal, puedes reconstruir la secuencia exacta de propuestas y respuestas con un lápiz, señalar el turno concreto donde se torció y corregirlo moviendo a un hijo de grupo. Un solucionador óptimo te habría dado un resultado mejor y ninguna forma humana de entenderlo. La disciplina que se sigue de aquí es que en SwiftUI no se corrige un layout ajustando números hasta que encaje, sino reordenando el turno: la prioridad y la flexibilidad son las dos únicas perillas del algoritmo, y ambas actúan sobre el orden, no sobre la magnitud.

📝
Lo esencial de esta lección

Un stack sondea a cada hijo con propuestas de cero y de infinito para medir su flexibilidad, ordena de menos a más flexible y va sirviendo por turnos la bolsa restante dividida entre los pendientes. layoutPriority agrupa a los hijos y hace que los grupos de mayor valor se sirvan antes, alterando el turno y no el tamaño. fixedSize propone nil a su hijo para obtener su tamaño ideal y devuelve ese tamaño ignorando la propuesta, convirtiendo la vista en rígida y capaz de desbordar.

⚔️ Reproduce el reparto a mano
  1. Pon en una fila un Spacer, un Text corto y una Image sin resizable; escribe el orden de servicio antes de ejecutar y verifícalo midiendo cada uno.
  2. Calcula sobre el papel la propuesta que recibe el tercer hijo de una fila de cuatro con un ancho conocido y un espaciado conocido.
  3. Resuelve un truncamiento con layoutPriority y después con fixedSize, y provoca deliberadamente el desbordamiento que solo produce el segundo.
  4. Da prioridades uno y dos a dos textos y comprueba empíricamente que el resultado es idéntico a darles cero y uno.
  5. Construye un caso donde el reparto codicioso deje espacio sin usar mientras una vista se trunca, y explica en qué turno se perdió.