Skip to content

Creando un Flow Layout en SwiftUI: Views que Ajustan como CSS Flexbox

Introducción

Una de las funcionalidades de layout más solicitadas en SwiftUI es el flow layout—un contenedor que organiza elementos horizontalmente y los mueve a la siguiente línea cuando no caben. Si has usado CSS Flexbox con flex-wrap: wrap, sabes exactamente de qué hablamos.

SwiftUI no proporciona un flow layout nativo, pero desde iOS 16, el protocolo Layout nos permite construir uno. En este artículo, crearemos un FlowLayout listo para producción, perfecto para:

  • Nubes de tags - Mostrando tags de ancho variable
  • Colecciones de chips - Chips de filtro, chips de sugerencias
  • Badges de habilidades - Mostrando listas de skills o categorías
  • Cualquier contenido que ajusta - Donde los elementos tienen anchos diferentes

Prerrequisitos

  • iOS 16+ / macOS 13+ (requerido para el protocolo Layout)
  • Conocimiento básico de views en SwiftUI
  • Xcode 14 o posterior

Entendiendo el Protocolo Layout

El protocolo Layout requiere dos métodos:

  1. sizeThatFits - Calcula cuánto espacio necesita el layout
  2. placeSubviews - Posiciona cada view hija dentro de los límites
swift
protocol Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) -> CGSize

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

El insight clave es que calculamos el layout una vez y usamos la misma lógica tanto para dimensionamiento como para posicionamiento.

La Implementación del FlowLayout

Aquí está una implementación completa, lista para producción:

swift
import SwiftUI

/// Un layout que organiza sus hijos en un flujo horizontal,
/// saltando a la siguiente línea cuando es necesario.
struct FlowLayout: Layout {
    var horizontalSpacing: CGFloat = 8
    var verticalSpacing: CGFloat = 8

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets

        for (index, subview) in subviews.enumerated() {
            subview.place(
                at: CGPoint(
                    x: bounds.minX + offsets[index].x,
                    y: bounds.minY + offsets[index].y
                ),
                proposal: ProposedViewSize(sizes[index])
            )
        }
    }

    private func layout(
        sizes: [CGSize],
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        var offsets: [CGPoint] = []
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        var lineHeight: CGFloat = 0
        var maxWidth: CGFloat = 0

        for size in sizes {
            // Verifica si necesitamos saltar a la siguiente línea
            if currentX + size.width > containerWidth, currentX > 0 {
                currentX = 0
                currentY += lineHeight + verticalSpacing
                lineHeight = 0
            }

            offsets.append(CGPoint(x: currentX, y: currentY))
            lineHeight = max(lineHeight, size.height)
            currentX += size.width + horizontalSpacing
            maxWidth = max(maxWidth, currentX - horizontalSpacing)
        }

        let totalHeight = currentY + lineHeight
        return (offsets, CGSize(width: maxWidth, height: totalHeight))
    }
}

Cómo Funciona

Desglosemos el algoritmo:

1. Midiendo las Subviews

swift
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

Pedimos a cada subview su tamaño ideal usando .unspecified, que le dice a la view "dame tu tamaño natural sin restricciones."

2. El Algoritmo de Layout

La lógica principal en la función layout:

  1. Empieza en la posición (0, 0)
  2. Para cada view, verifica si cabe en la línea actual
  3. Si no cabe y no estamos al inicio de una línea, salta a la siguiente línea
  4. Registra la posición de la view y actualiza la posición actual
swift
if currentX + size.width > containerWidth, currentX > 0 {
    // Salta a la siguiente línea
    currentX = 0
    currentY += lineHeight + verticalSpacing
    lineHeight = 0
}

¿Por qué verificar currentX > 0?

Esto previene un bucle infinito cuando una sola view es más ancha que el contenedor. Si el primer elemento de una línea es demasiado ancho, lo colocamos de todas formas en lugar de saltar infinitamente.

3. Rastreando la Altura de la Línea

Cada línea puede tener elementos de diferentes alturas. Rastreamos la altura máxima de la línea actual:

swift
lineHeight = max(lineHeight, size.height)

Cuando saltamos, usamos esto para posicionar la siguiente fila correctamente.

Uso Básico

swift
struct TagCloudView: View {
    let tags = ["Swift", "SwiftUI", "Desarrollo iOS", "Xcode",
                "UIKit", "Combine", "Async/Await", "Core Data"]

    var body: some View {
        FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) {
            ForEach(tags, id: \.self) { tag in
                Text(tag)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 8)
                    .background(Capsule().fill(Color.blue.opacity(0.2)))
            }
        }
        .padding()
    }
}

Ejemplo Real: Chips de Sugerencias

Aquí hay un ejemplo más completo mostrando chips interactivos:

swift
struct SuggestionChip: View {
    let title: String
    let isSelected: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 6) {
                Text(title)
                    .font(.subheadline)

                if isSelected {
                    Image(systemName: "checkmark")
                        .font(.system(size: 12, weight: .bold))
                        .foregroundStyle(.blue)
                }
            }
            .padding(.horizontal, 14)
            .padding(.vertical, 8)
            .background(chipBackground)
        }
        .buttonStyle(.plain)
    }

    private var chipBackground: some View {
        Capsule()
            .fill(isSelected ? Color.blue.opacity(0.15) : Color.gray.opacity(0.1))
            .overlay(
                Capsule()
                    .strokeBorder(
                        isSelected ? Color.blue : Color.clear,
                        lineWidth: 1.5
                    )
            )
    }
}

struct SuggestionPickerView: View {
    @State private var selected: Set<String> = []

    let suggestions = [
        "Rutina matutina ☀️",
        "Ejercicio 💪",
        "Leer un libro 📚",
        "Tomar agua 💧",
        "Meditación 🧘",
        "Llamar a un amigo 📞",
        "Aprender algo nuevo 🧠"
    ]

    var body: some View {
        FlowLayout(horizontalSpacing: 8, verticalSpacing: 10) {
            ForEach(suggestions, id: \.self) { suggestion in
                SuggestionChip(
                    title: suggestion,
                    isSelected: selected.contains(suggestion),
                    onTap: {
                        withAnimation(.spring(response: 0.3)) {
                            if selected.contains(suggestion) {
                                selected.remove(suggestion)
                            } else {
                                selected.insert(suggestion)
                            }
                        }
                    }
                )
            }
        }
        .padding()
    }
}

Agregando Soporte de Alineación

Puede que quieras centrar o alinear a la derecha tu flow layout. Aquí hay una versión extendida:

swift
struct AlignedFlowLayout: Layout {
    enum HorizontalAlignment {
        case leading, center, trailing
    }

    var horizontalSpacing: CGFloat = 8
    var verticalSpacing: CGFloat = 8
    var alignment: HorizontalAlignment = .leading

    // ... implementación de sizeThatFits y placeSubviews ...

    private func layout(
        sizes: [CGSize],
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        // Primera pasada: calcular líneas
        var lines: [[Int]] = [[]]
        var lineWidths: [CGFloat] = [0]
        var currentX: CGFloat = 0

        for (index, size) in sizes.enumerated() {
            if currentX + size.width > containerWidth, currentX > 0 {
                lines.append([])
                lineWidths.append(0)
                currentX = 0
            }
            lines[lines.count - 1].append(index)
            lineWidths[lineWidths.count - 1] = currentX + size.width
            currentX += size.width + horizontalSpacing
        }

        // Segunda pasada: calcular offsets con alineación
        var offsets = [CGPoint](repeating: .zero, count: sizes.count)
        var currentY: CGFloat = 0

        for (lineIndex, line) in lines.enumerated() {
            let lineWidth = lineWidths[lineIndex]
            let lineOffset: CGFloat = switch alignment {
                case .leading: 0
                case .center: (containerWidth - lineWidth) / 2
                case .trailing: containerWidth - lineWidth
            }

            var currentX = lineOffset
            var lineHeight: CGFloat = 0

            for index in line {
                offsets[index] = CGPoint(x: currentX, y: currentY)
                lineHeight = max(lineHeight, sizes[index].height)
                currentX += sizes[index].width + horizontalSpacing
            }

            currentY += lineHeight + verticalSpacing
        }

        return (offsets, CGSize(width: containerWidth, height: currentY - verticalSpacing))
    }
}

Consideraciones de Rendimiento

Calculando el Layout Dos Veces

Nota que calculamos tamaños tanto en sizeThatFits como en placeSubviews. Para la mayoría de los casos esto está bien, pero si tienes cientos de elementos, considera usar el parámetro cache para almacenar valores calculados.

swift
struct FlowLayout: Layout {
    struct CacheData {
        var sizes: [CGSize] = []
    }

    func makeCache(subviews: Subviews) -> CacheData {
        CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) -> CGSize {
        layout(sizes: cache.sizes, containerWidth: proposal.width ?? .infinity).size
    }

    // ... el resto de la implementación usa cache.sizes
}

Comparación con Otros Enfoques

EnfoqueProsContras
Protocolo LayoutNativo, performante, API limpiaSolo iOS 16+
GeometryReaderFunciona en iOS más antiguosCausa pasadas de layout, más difícil de implementar
LazyVGridNativo, carga lazyLas columnas son fijas, no es flow verdadero
Librerías de tercerosLlenas de funcionesDependencia externa

Conclusión

El protocolo Layout hace que construir layouts personalizados en SwiftUI sea sencillo. Nuestro FlowLayout:

  1. Ajusta contenido naturalmente - Como CSS flex-wrap: wrap
  2. Maneja alturas variables - Cada fila se adapta a su elemento más alto
  3. Soporta espaciado personalizado - Tanto horizontal como vertical
  4. Está listo para producción - Maneja casos extremos como elementos muy grandes

La implementación completa tiene menos de 60 líneas de código y cero dependencias. Úsalo para nubes de tags, chips de filtro, badges de habilidades, o cualquier UI que necesite comportamiento de ajuste flexible.