Layout avanzado en SwiftUI: Layout protocol, AlignmentGuide, GeometryReader y ViewThatFits

SwiftUI incluye contenedores de layout como VStack, HStack, ZStack y Grid que resuelven la mayoría de casos. Pero cuando necesitas una disposición no estándar —una galería estilo Pinterest, un layout de carta de menú, o una rejilla que se adapta dinámicamente al contenido disponible— necesitas las herramientas de layout avanzado que iOS 16 introdujo.

El protocolo Layout: crear contenedores propios

El protocolo Layout permite crear contenedores de layout completamente personalizados que SwiftUI trata igual que los built-in. Requiere implementar dos métodos:

struct LayoutFlexible: Layout {
    // Calcula el tamaño que necesita este contenedor
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Suma de alturas de las subvistas + padding entre ellas
        let alturas = subviews.map { $0.sizeThatFits(proposal).height }
        let alturaTotal = alturas.reduce(0, +) + CGFloat(subviews.count - 1) * 8
        let anchura = proposal.width ?? 0
        return CGSize(width: anchura, height: alturaTotal)
    }

    // Posiciona cada subvista en el espacio disponible
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        var y = bounds.minY
        for subvista in subviews {
            let tamano = subvista.sizeThatFits(proposal)
            subvista.place(
                at: CGPoint(x: bounds.midX, y: y + tamano.height / 2),
                anchor: .center,
                proposal: proposal
            )
            y += tamano.height + 8
        }
    }
}

Layout waterfall de dos columnas

Un layout waterfall (estilo Pinterest) coloca los elementos en la columna más corta en cada paso:

struct WaterfallLayout: Layout {
    let columnas: Int
    let espaciado: CGFloat

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout [CGFloat]
    ) -> CGSize {
        let anchoColumna = anchoColumna(proposal: proposal)
        cache = calcularAlturas(subviews: subviews, anchoColumna: anchoColumna)
        return CGSize(
            width: proposal.width ?? 0,
            height: cache.max() ?? 0
        )
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout [CGFloat]
    ) {
        let anchoColumna = anchoColumna(proposal: bounds.width)
        var alturasColumnas = Array(repeating: bounds.minY, count: columnas)

        for subvista in subviews {
            let colMinima = alturasColumnas.enumerated().min(by: { $0.1 < $1.1 })!.offset
            let propuesta = ProposedViewSize(width: anchoColumna, height: nil)
            let tamano = subvista.sizeThatFits(propuesta)
            let x = bounds.minX + CGFloat(colMinima) * (anchoColumna + espaciado) + anchoColumna / 2

            subvista.place(
                at: CGPoint(x: x, y: alturasColumnas[colMinima] + tamano.height / 2),
                anchor: .center,
                proposal: propuesta
            )
            alturasColumnas[colMinima] += tamano.height + espaciado
        }
    }

    private func anchoColumna(proposal: ProposedViewSize) -> CGFloat {
        anchoColumna(proposal: proposal.width ?? 0)
    }

    private func anchoColumna(proposal: CGFloat) -> CGFloat {
        (proposal - espaciado * CGFloat(columnas - 1)) / CGFloat(columnas)
    }

    private func calcularAlturas(subviews: Subviews, anchoColumna: CGFloat) -> [CGFloat] {
        var alturas = Array(repeating: CGFloat(0), count: columnas)
        let propuesta = ProposedViewSize(width: anchoColumna, height: nil)

        for subvista in subviews {
            let colMin = alturas.enumerated().min(by: { $0.1 < $1.1 })!.offset
            alturas[colMin] += subvista.sizeThatFits(propuesta).height + espaciado
        }
        return alturas
    }
}

// Uso:
WaterfallLayout(columnas: 2, espaciado: 12) {
    ForEach(fotos) { foto in
        FotoCard(foto: foto)
    }
}

AlignmentGuide: alineación personalizada

AlignmentGuide permite definir puntos de alineación personalizados que los contenedores pueden usar para alinear vistas de forma no estándar:

// Definir una guía de alineación personalizada
extension HorizontalAlignment {
    private enum GuiaCentroIcono: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context[HorizontalAlignment.center]
        }
    }
    static let centroIcono = HorizontalAlignment(GuiaCentroIcono.self)
}

// Usar la guía para alinear un icono con el texto de otro elemento
VStack(alignment: .centroIcono) {
    HStack {
        Image(systemName: "person.fill")
            .alignmentGuide(.centroIcono) { d in d[HorizontalAlignment.center] }
        Text("Usuario")
    }
    HStack {
        Image(systemName: "envelope.fill")
        Text("[email protected]")
            .alignmentGuide(.centroIcono) { d in d[HorizontalAlignment.center] }
    }
}

GeometryReader: cuando necesitas las dimensiones

GeometryReader da acceso al tamaño y posición de la vista en el espacio del contenedor. Se usa a menudo en exceso; el protocolo Layout o ViewThatFits resuelven muchos casos sin necesitarlo:

// Patrón correcto: usar GeometryReader solo para lo que no se puede hacer de otra forma
struct GraficaBarras: View {
    let datos: [Double]

    var body: some View {
        GeometryReader { geometria in
            let maxValor = datos.max() ?? 1
            let anchoBarra = geometria.size.width / CGFloat(datos.count)

            HStack(alignment: .bottom, spacing: 2) {
                ForEach(datos.indices, id: .self) { idx in
                    let altura = (datos[idx] / maxValor) * geometria.size.height
                    RoundedRectangle(cornerRadius: 4)
                        .frame(width: anchoBarra - 4, height: altura)
                        .foregroundStyle(.blue.gradient)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
        }
    }
}

Evita poner GeometryReader como contenedor de layouts donde los stacks o Layout bastan. GeometryReader ocupa todo el espacio disponible, lo que puede romper el comportamiento de tamaño preferido de tus vistas.

ViewThatFits: layout adaptativo sin GeometryReader

ViewThatFits intenta renderizar la primera vista de su lista que cabe en el espacio disponible. Es perfecto para layouts adaptativos sin necesidad de medir manualmente:

struct ControlesAccion: View {
    var body: some View {
        ViewThatFits {
            // Primera opción: botones en horizontal
            HStack {
                BotonPrincipal()
                BotonSecundario()
                BotonTerciario()
            }

            // Segunda opción: los dos primeros en horizontal
            HStack {
                BotonPrincipal()
                BotonSecundario()
            }

            // Última opción: todos en vertical
            VStack {
                BotonPrincipal()
                BotonSecundario()
                BotonTerciario()
            }
        }
    }
}

AnyLayout: cambiar layout en tiempo de ejecución

AnyLayout permite cambiar entre tipos de layout de forma animada, borrando el tipo concreto:

struct GaleriaAdaptativa: View {
    @State private var horizontal = false

    var layout: AnyLayout {
        horizontal
            ? AnyLayout(HStackLayout(spacing: 8))
            : AnyLayout(VStackLayout(spacing: 8))
    }

    var body: some View {
        layout {
            ForEach(elementos) { elemento in
                ElementoView(elemento: elemento)
            }
        }
        .animation(.spring, value: horizontal)
        .onTapGesture { horizontal.toggle() }
    }
}

LazyVGrid con columnas adaptativas

Para grids que se adaptan al ancho disponible, las columnas adaptativas de LazyVGrid siguen siendo la opción más práctica:

struct GaleriaFotos: View {
    let fotos: [Foto]
    @State private var columnasMinimas: CGFloat = 100

    private var columnas: [GridItem] {
        [GridItem(.adaptive(minimum: columnasMinimas, maximum: 200))]
    }

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columnas, spacing: 4) {
                ForEach(fotos) { foto in
                    AsyncImage(url: foto.url) { imagen in
                        imagen.resizable().scaledToFill()
                    } placeholder: {
                        Color.gray.opacity(0.3)
                    }
                    .frame(minWidth: columnasMinimas, minHeight: columnasMinimas)
                    .clipped()
                }
            }
        }
        .toolbar {
            Slider(value: $columnasMinimas, in: 80...200)
                .frame(width: 120)
        }
    }
}

Resumen

El protocolo Layout es la herramienta de última instancia cuando los contenedores estándar no bastan, y permite crear cualquier disposición imaginable con pleno acceso al sistema de layout de SwiftUI. ViewThatFits resuelve layouts adaptativos simples sin medir, AnyLayout permite transiciones animadas entre layouts y AlignmentGuide da control sobre los puntos de alineación entre vistas. Juntos, estos mecanismos cubren desde layouts básicos hasta los más complejos sin necesidad de recurrir a GeometryReader en la mayoría de casos.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP