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

Alineación: la guía es un número, no una idea

Alinear en SwiftUI no es elegir izquierda o centro: es acordar un número. Cada vista responde con una distancia para cada alineación, el contenedor pone esos números en la misma línea, y tú puedes sobrescribirlos. Esta lección reconstruye el mecanismo, enseña a definir una alineación propia con AlignmentID y explica cómo alinear vistas que viven en contenedores distintos.

⏱ 19 min

La palabra alineación arrastra una intuición gráfica —pegar cosas a un lado— que resulta ser un caso particular de algo bastante más general y más potente. En SwiftUI, una alineación es una pregunta que el contenedor le hace a cada hijo, y la respuesta es siempre un número: la distancia, medida desde el borde inicial de esa vista y a lo largo de un eje, del punto que la vista considera su punto de anclaje para esa alineación. El contenedor recoge todos esos números y desplaza a los hijos hasta que los puntos correspondientes quedan sobre la misma recta. Que ese número sea calculable, y sobre todo que tú puedas calcularlo, convierte un mecanismo aparentemente decorativo en el único camino que ofrece el framework para relacionar geométricamente vistas que no son hermanas.

🎯 Al terminar esta lección sabrás
  • Definir una alineación como una función que devuelve una distancia sobre un eje, y no como una posición nominal.
  • Sobrescribir la guía de una vista concreta con alignmentGuide y prever el desplazamiento que produce.
  • Declarar una alineación propia con AlignmentID y entender qué hace su valor por omisión.
  • Alinear elementos situados en contenedores distintos mediante la propagación de una alineación personalizada.

Una guía es un número

Los tipos HorizontalAlignment y VerticalAlignment no son enumeraciones cerradas: son estructuras que envuelven un identificador, y cada valor conocido lleva asociada una función que, dada la geometría de una vista, devuelve una distancia. Las implementaciones por omisión son las que esperarías: la guía .leading vale cero, la .center vale la mitad del ancho, la .trailing vale el ancho entero; en vertical, .top vale cero y .bottom vale el alto. Las dos interesantes son .firstTextBaseline y .lastTextBaseline, que devuelven la distancia hasta la línea base tipográfica y por eso alinean textos de tamaños distintos por donde se apoyan las letras y no por su caja.

VStack(alignment: .leading) {
    Text("Nombre")
    Text("Un valor bastante mas largo")
}

Lo que ocurre aquí es literal: el VStack pide a cada hijo su guía .leading, los dos responden cero, y el contenedor los coloca de modo que ambos ceros caigan sobre la misma vertical. La alineación solo se percibe cuando hay espacio sobrante en el eje transversal; si todos los hijos midieran lo mismo, cualquier alineación daría el mismo dibujo.

Los contenedores que superponen, y también el frame, necesitan resolver los dos ejes a la vez, y por eso reciben un Alignment completo que empareja una guía horizontal con una vertical:

ZStack(alignment: .bottomTrailing) {
    imagen
    insignia
}

Color.clear.frame(width: 200, height: 120)
    .overlay(alignment: .topLeading) { etiqueta }

La lectura es la misma en los dos casos: se pregunta a cada participante por sus dos guías y se desplaza hasta que ambos pares de números coincidan. Que el mecanismo sea idéntico para superponer, para apilar y para alinear dentro de un frame es lo que permite razonar sobre todos ellos con una sola regla.

💡
Dos alineaciones distintas, dos sitios distintos

El parámetro de un VStack es una HorizontalAlignment, porque el eje transversal de una columna es el horizontal. El de un ZStack o un frame es un Alignment completo, con componente horizontal y vertical. Equivocarse de tipo es un error de compilación, y es el sistema avisándote de que has confundido el eje.

Sobrescribir la guía de una vista

El modificador alignmentGuide recibe la alineación cuya respuesta quieres cambiar y una clausura que devuelve el número nuevo. La clausura recibe un ViewDimensions, que te da el width, el height y, por subíndice, el valor de cualquier otra guía de esa misma vista, de modo que puedes expresar la nueva respuesta en función de las existentes.

HStack(alignment: .top) {
    Rectangle().frame(width: 40, height: 40)
    Rectangle()
        .frame(width: 40, height: 80)
        .alignmentGuide(.top) { d in d[.bottom] }
}

El segundo rectángulo responde ahora que su punto superior está a ochenta puntos de su borde superior. El HStack se lo cree, porque no tiene forma de comprobarlo, y lo desplaza hacia arriba hasta que ese punto coincida con la línea de alineación. El efecto visual es que el rectángulo alto sube y sobresale del contenedor. Aquí está la primera lección importante del mecanismo: la guía no valida nada. Es un número que la vista declara sobre sí misma, y declarar un número absurdo produce un desplazamiento absurdo, no un error.

La segunda lección es más sutil. Dentro de la clausura puedes consultar d[explicit: .top], que devuelve un opcional con el valor únicamente si alguien lo estableció explícitamente, distinguiéndolo del valor por omisión. Esa distinción entre guía explícita y guía calculada es la que hace posible el mecanismo de la última sección.

flowchart TB
cont[El contenedor pide la guia a cada hijo] --> h1[Hijo uno responde su valor por omision]
cont --> h2[Hijo dos responde un valor sobrescrito]
h1 --> linea[El contenedor traza una recta comun]
h2 --> linea
linea --> desp[Desplaza cada hijo hasta que su punto cae en la recta]
style cont fill:#f5c2e7,color:#11111b
style h2 fill:#f9e2af,color:#11111b
style desp fill:#a6e3a1,color:#11111b

Definir una alineación propia

Una alineación nueva es un tipo que conforma a AlignmentID y una constante estática que lo envuelve. El único requisito del protocolo es defaultValue, que responde por todas las vistas que no hayan sobrescrito la guía:

extension VerticalAlignment {
    private enum CentroDelIcono: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            d[VerticalAlignment.center]
        }
    }
    static let centroDelIcono = VerticalAlignment(CentroDelIcono.self)
}

El valor por omisión debe elegirse con cuidado, porque es la respuesta que darán todas las vistas del contenedor que no participen conscientemente en tu alineación. Delegar en una guía estándar, como aquí en el centro, suele ser la elección sensata: garantiza que introducir una alineación propia no descoloque nada de lo que ya estaba.

📐

defaultValue

La respuesta para quien no dice nada. Casi siempre conviene expresarla en función de una guía existente en lugar de un número absoluto, para que se adapte al tamaño real de la vista.

🧮

combineExplicit

El método opcional que decide qué guía expone un contenedor cuando varios de sus hijos la fijaron explícitamente. Por omisión toma el menor de los valores presentes, y ese detalle explica el comportamiento entre contenedores.

🪞

ViewDimensions

El objeto que recibe la clausura. Da ancho, alto, cualquier guía por subíndice, y con el subíndice explicit distingue lo declarado de lo calculado.

Alinear entre contenedores distintos

Aquí es donde el mecanismo deja de ser cosmético. Recuerda la limitación estructural del sistema: nadie ve más allá de sus hijos inmediatos, así que no existe forma directa de pedir que una etiqueta de una fila quede alineada con una etiqueta de otra fila. Las alineaciones personalizadas son la vía de escape, y funcionan por una razón concreta: un contenedor expone hacia arriba las guías que sus hijos hayan fijado explícitamente, combinándolas con combineExplicit. Es decir, una alineación declarada en el nieto viaja hacia el abuelo.

struct Ficha: View {
    var body: some View {
        VStack(alignment: .centroDelIcono) {
            HStack {
                Text("Un rotulo muy largo")
                Image(systemName: "star")
                    .alignmentGuide(.centroDelIcono) { d in d[VerticalAlignment.center] }
            }
            HStack {
                Text("Corto")
                Image(systemName: "heart")
                    .alignmentGuide(.centroDelIcono) { d in d[VerticalAlignment.center] }
            }
        }
    }
}

Cada HStack contiene un icono que fija la guía personalizada. Al no tener ningún otro hijo que la declare, cada fila expone hacia arriba el valor de su icono, medido en su propio sistema de coordenadas. El VStack externo, que alinea por esa misma guía, desplaza cada fila horizontalmente hasta que los dos iconos quedan sobre la misma vertical, aunque los rótulos que los preceden midan cosas muy distintas. Ninguna vista ha sabido nada de la otra: la información viajó como un número, hacia arriba, en la respuesta de cada fila.

⚠️
El eje de la guía y el eje del desplazamiento

Una guía horizontal declarada dentro de un HStack no mueve nada dentro de esa fila, porque el eje transversal de una fila es el vertical. Solo tiene efecto cuando un contenedor que alinee por ese eje —el VStack externo del ejemplo— la consulte. Es el error más frecuente al empezar: declarar la guía en el sitio correcto y esperar el efecto en el nivel equivocado.

Un canal de información en un sistema deliberadamente ciego

Conviene ver este mecanismo por lo que arquitectónicamente es. El sistema de layout se construyó sobre un principio de localidad estricta: la información baja como propuesta y sube como tamaño, y ninguna vista tiene acceso a nada que no sea su propio hijo. Ese principio compra composicionalidad y tiempo lineal, pero prohíbe de raíz una familia entera de requisitos de diseño perfectamente legítimos, porque alinear cosas que no son hermanas es exactamente lo que hace legible un formulario, una tabla o una lista de ajustes. La respuesta de SwiftUI no fue abrir una puerta trasera al estilo de las restricciones globales, sino añadir un segundo canal de información que respeta la misma disciplina del primero: valores que suben, se combinan al pasar por cada nivel según una regla que declara el propio tipo, y quedan disponibles para quien los pida más arriba. Quien conozca la programación funcional reconocerá aquí un pliegue sobre el árbol, con defaultValue haciendo de elemento neutro y combineExplicit de operación asociativa; quien venga del desarrollo web reconocerá que es la contrapartida ascendente del contexto, que solo sabe descender. Esa simetría no es casual: es el patrón general con el que los sistemas de interfaz declarativos resuelven la tensión entre localidad y coordinación, y SwiftUI lo instancia dos veces, en las guías de alineación y en las claves de preferencia, con la misma forma exacta. La consecuencia práctica merece meditarse. Cuando te encuentres deseando que una vista sepa dónde está otra, tienes dos caminos y son de calidad muy distinta: medir posiciones absolutas con GeometryReader y coordenadas globales, que funciona pero introduce una dependencia oculta, un ciclo potencial de lectura y escritura y un coste de recomposición; o declarar una alineación propia, que expresa la relación como un dato que viaja por el mismo canal que el layout, dentro de la misma pasada y sin ningún riesgo de realimentación. El segundo camino casi siempre es el correcto, y es también el más difícil de descubrir, porque exige haber entendido que una alineación nunca fue una posición: era un número que cada vista declara sobre sí misma.

📝
Lo esencial de esta lección

Una alineación es una función que devuelve una distancia sobre un eje. El contenedor consulta esa guía en cada hijo y los desplaza hasta que los valores caen sobre la misma recta. alignmentGuide sobrescribe la respuesta de una vista sin ninguna validación. Una alineación propia se declara con AlignmentID, cuyo defaultValue responde por quien no participa y cuyo combineExplicit decide qué expone un contenedor hacia arriba. Esa propagación ascendente es lo que permite alinear vistas que viven en contenedores distintos.

⚔️ Construye una alineación que atraviese niveles
  1. Alinea dos textos de tamaños muy distintos por firstTextBaseline y luego por center, y mide la diferencia en puntos.
  2. Sobrescribe la guía .leading de una vista devolviendo la mitad de su ancho y explica el desplazamiento resultante.
  3. Declara una alineación horizontal propia y úsala para que los dos puntos de un separador queden en la misma columna dentro de dos filas distintas.
  4. Cambia el defaultValue de tu alineación a un valor absoluto y observa qué le ocurre a las vistas que no la declaran.
  5. Fija la misma guía personalizada en dos hijos del mismo contenedor y deduce experimentalmente qué hace combineExplicit por omisión.