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:
sizeThatFits- Calcula cuánto espacio necesita el layoutplaceSubviews- Posiciona cada view hija dentro de los límites
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:
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
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:
- Empieza en la posición (0, 0)
- Para cada view, verifica si cabe en la línea actual
- Si no cabe y no estamos al inicio de una línea, salta a la siguiente línea
- Registra la posición de la view y actualiza la posición actual
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:
lineHeight = max(lineHeight, size.height)Cuando saltamos, usamos esto para posicionar la siguiente fila correctamente.
Uso Básico
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:
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:
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.
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
| Enfoque | Pros | Contras |
|---|---|---|
Protocolo Layout | Nativo, performante, API limpia | Solo iOS 16+ |
GeometryReader | Funciona en iOS más antiguos | Causa pasadas de layout, más difícil de implementar |
LazyVGrid | Nativo, carga lazy | Las columnas son fijas, no es flow verdadero |
| Librerías de terceros | Llenas de funciones | Dependencia externa |
Conclusión
El protocolo Layout hace que construir layouts personalizados en SwiftUI sea sencillo. Nuestro FlowLayout:
- Ajusta contenido naturalmente - Como CSS
flex-wrap: wrap - Maneja alturas variables - Cada fila se adapta a su elemento más alto
- Soporta espaciado personalizado - Tanto horizontal como vertical
- 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.

