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.
progressdefine la fracción de una rotación completa, y al multiplicarlo por4 * .pise 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
countque contiene la cantidad de subvistas (círculos) a mostrar. - Utilizamos
SpiralLayout, la vista que creamos al principio de este tutorial. - Usamos
ForEachpara iterar desde 0 hasta el valor almacenado encount. - Declaramos una vista
Circlepara cada iteración delForEach. - El botón incrementa
county, 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:
