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.
