Cómo posicionar vistas en espiral usando el protocolo Layout

Libranner Santos
30 abril, 20254min de lectura
SwiftUI ofrece diversas herramientas para organizar vistas. Para estructuras verticales tenemos VStack
, para listas usamos List
, y para superponer vistas están ZStack
y HStack
, entre otras. No obstante, estas vistas pueden limitar la flexibilidad en el posicionamiento. Para una mayor personalización, podemos crear nuestras propias vistas implementando el protocolo Layout
.
Cómo usar el protocolo Layout
El protocolo Layout
nos permite definir vistas personalizadas con un control preciso sobre la disposición de sus subcomponentes. A continuación, aprenderemos a crear una vista que dispone círculos en forma de espiral.
Creando SpiralLayout
Iniciamos con una estructura SpiralLayout
que implementa Layout
. Esta estructura requiere dos métodos: sizeThatFits(proposal:subviews:cache:)
y placeSubviews(in:proposal:subviews:cache:)
:
struct SpiralLayout: Layout {func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {}func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {}}
Primero implementamos sizeThatFits(proposal:subviews:cache:)
, este calcula y retorna el tamaño del contenedor aceptando el espacio propuesto o usando un valor por defecto (CGSize(width: 10, height: 10)
).
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {proposal.replacingUnspecifiedDimensions()}
Luego, implementamos placeSubviews(in:proposal:subviews:cache:)
:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews,cache: inout Void) {// 1let radius = min(bounds.size.width, bounds.size.height) / 2// 2for (index, subview) in subviews.enumerated() {// 3let progress = Double(index) / Double(subviews.count)// 4let currentRadius = radius * (1 - progress)let angle = progress * 4 * .pi// 5let xPos = cos(angle) * currentRadiuslet yPos = sin(angle) * currentRadius// 6let point = CGPoint(x: bounds.midX + xPos, y: bounds.midY + yPos)subview.place(at: point, anchor: .center, proposal: .unspecified)}}
En este código:
- Calcula el radio máximo del espiral tomando la mitad de la dimensión más pequeña de la pantalla.
- Recorre cada subvista dentro de
subviews
. - Para cada vista calculamos el progreso basado en la posición de la vista en el arreglo y el total de suvistas.
- Calcula el ángulo de la subvista actual.
progress
define la fracción de una rotación completa, y al multiplicarlo por4 * .pi
se obtienen hasta 2 rotaciones completas (720°). - Convierte ángulo y radio en coordenadas (x,y).
- Posiciona la vista en la posición calculada con respecto al centro.
El código completo para SpiralLayout
:
import SwiftUIstruct SpiralLayout: Layout {func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {proposal.replacingUnspecifiedDimensions()}func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews,cache: inout Void) {let radius = min(bounds.size.width, bounds.size.height) / 2for (index, subview) in subviews.enumerated() {let progress = Double(index) / Double(subviews.count)// Decrease radius as we go inwardlet currentRadius = radius * (1 - progress)let angle = progress * 4 * .pi // 2 full rotationslet xPos = cos(angle) * currentRadiuslet yPos = sin(angle) * currentRadiuslet point = CGPoint(x: bounds.midX + xPos, y: bounds.midY + yPos)subview.place(at: point, anchor: .center, proposal: .unspecified)}}}
Utilizando SpiralLayout
import SwiftUIstruct ContentView: View {// 1@State private var count = 25var body: some View {VStack(spacing: 50) {// 2SpiralLayout {// 3ForEach(0..<count, id: \.self) { _ in// 4Circle().frame(width: 35).padding(5).foregroundStyle(.green)}}.frame(width: 200, height: 200)// 5Button("Add a circle") {withAnimation {count += 1}}}}}
En este código:
- Creamos una variable de estado
count
que contiene la cantidad de subvistas (círculos) a mostrar. - Utilizamos
SpiralLayout
, la vista que creamos al principio de este tutorial. - Usamos
ForEach
para iterar desde 0 hasta el valor almacenado encount
. - Declaramos una vista
Circle
para cada iteración delForEach
. - El botón incrementa
count
y, al usarwithAnimation
, las vistas aparecen de manera animada.

Si presionamos el Add a circle podemos ver como más círculos son agregados a la espiral:
