Skip to content
GitHub Twitter

SwiftUI Layouts - Creando un Menú Flotante Radial

Una de las grandes fortalezas de SwiftUI es la capacidad de crear componentes personalizados de manera sencilla. Entre las funcionalidades más potentes y menos exploradas están los Layouts personalizados. Los layouts en SwiftUI permiten organizar las vistas de maneras flexibles, más allá de los stack convencionales, como VStack o HStack. En este artículo, te guiaré a través de cómo crear un menú radial flotante, utilizando un layout personalizado.

¿Por qué usar un Layout personalizado?

SwiftUI facilita muchas tareas de diseño, pero cuando los requisitos son más específicos, como en el caso de menús circulares o distribuciones especiales, los Layouts personalizados son la mejor opción. No solo te dan un control total sobre cómo se colocan las vistas, sino que además permiten realizar animaciones y transiciones complejas, ajustándose a las necesidades de la UI.

Un ejemplo claro de este enfoque es el menú radial, donde los botones se disponen en círculo alrededor de un botón central. Vamos a ver cómo puedes usar un layout personalizado para construir este menú de una forma eficiente y visualmente atractiva.

Ejemplo layout ejemplo gif

Descifrando el Layout

El concepto de layouts en SwiftUI se asemeja mucho al manejo que se hacía en UIKit con los CollectionViewLayout. En UIKit, un UICollectionViewLayout permitía definir cómo se organizarían las celdas en una colección, ya sea en una cuadrícula, una lista o una disposición más personalizada. En SwiftUI, el Layout cumple una función similar: se encarga de calcular las posiciones y dimensiones de cada vista contenida en un layout de manera más flexible.

Los layouts en SwiftUI permiten diseñar cómo se distribuyen las vistas en función de sus tamaños, espaciados, y la geometría del contenedor. Esto es útil cuando los stacks (como HStack y VStack) no son suficientes, ya que esos simplemente alinean las vistas en filas o columnas. En un layout personalizado, puedes controlar cada aspecto de la disposición de las vistas.

Un layout personalizado en SwiftUI se basa en dos funciones clave:

  • sizeThatFits: Determina el tamaño total que ocupará el layout en función de sus sub-vistas.
  • placeSubviews: Calcula las posiciones exactas donde cada vista debe ser colocada, con un control preciso sobre el ángulo, las coordenadas y la distribución.

Esto es esencial cuando trabajamos con diseños que no son lineales, como el menú radial que veremos más adelante. Aquí, necesitaremos colocar los botones alrededor de un círculo, algo que no se puede lograr con un VStack o HStack convencional.

La diferencia fundamental con UIKit radica en que SwiftUI permite crear Layouts reactivos que se adaptan de forma declarativa y eficiente a cambios en la vista o el estado. En lugar de actualizar manualmente el layout cuando cambian las dimensiones, como se hacía en UIKit, SwiftUI se encarga de recalcular las posiciones automáticamente en función de las reglas que defines en el layout.


Ejemplo Práctico

Vamos a utilizar un ejemplo práctico paso a paso para definir nuestro menú radial.

Paso 1: Definir los Ángulos del Menú Radial

Lo primero que necesitamos es definir cómo se distribuirán las vistas (botones en nuestro caso) alrededor del botón central. Usaremos un enum para configurar distintos ángulos: círculo completo, medio círculo a la izquierda o medio círculo a la derecha, o incluso una opción personalizada.

enum RadialLayoutAngle {
    case fullCircle
    case trailingCircle
    case leadingCircle
    case custom(degrees: Double, anchor: UnitPoint)
    
    var totalDegrees: Double {
        switch self {
        case .fullCircle:
            360
        case .trailingCircle:
            180
        case .leadingCircle:
            -180
        case .custom(let degrees, _):
            degrees
        }
    }
    
    var anchor: UnitPoint {
        switch self {
        case .fullCircle:
                .center
        case .leadingCircle:
                .trailing
        case .trailingCircle:
                .leading
        case .custom(_, let anchor):
                anchor
        }
    }
}

Este enum nos da flexibilidad para configurar la disposición angular de los botones, permitiendo personalizar su distribución con precisión.


Paso 2: Crear el Layout Radial

El siguiente paso es definir el layout que organizará los botones en forma de círculo. Aquí es donde empieza a brillar la capacidad de SwiftUI para crear layouts personalizados. El layout radial que construiremos distribuirá las vistas a lo largo del perímetro de un círculo, basándose en el número de elementos que queramos mostrar y el ángulo definido anteriormente.

struct RadialLayout: Layout {
    var angleConfig: RadialLayoutAngle
    var angleOffset: Double // Offset inicial, por defecto será 0

    init(angleConfig: RadialLayoutAngle = .fullCircle, angleOffset: Double = 0) {
        self.angleConfig = angleConfig
        self.angleOffset = angleOffset
    }
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let radius = min(bounds.size.width, bounds.size.height) / 2
        let totalAngle = angleConfig.totalDegrees
        let angleStep = totalAngle / Double(max(subviews.count - 1, 1))
        
        for (index, subview) in subviews.enumerated() {
            let adjustedIndex = Double(index)
            let angle = Angle.degrees(angleStep * adjustedIndex + angleOffset).radians
            
            let viewSize = subview.sizeThatFits(.unspecified)
            let xPos = cos(angle - .pi / 2) * (radius - viewSize.width / 2)
            let yPos = sin(angle - .pi / 2) * (radius - viewSize.height / 2)
            
            let point = CGPoint(x: bounds.midX + xPos, y: bounds.midY + yPos)
            subview.place(at: point,
                          anchor: angleConfig.anchor,
                          proposal: .unspecified)
        }
    }
}

Este layout utiliza las coordenadas polares (ángulo y radio) para calcular la posición de cada vista en el menú radial. Además, la configuración de ángulos permite crear menús con diversas formas y distribuciones.

Ventajas del Layout personalizado:

  • Control total: Permite disponer las vistas de manera precisa, algo que los stacks convencionales no pueden hacer.
  • Adaptabilidad: Los layouts personalizados pueden ajustar la disposición de los elementos en función del número de vistas, el tamaño del contenedor y otras variables.
  • Animaciones: Facilita la implementación de transiciones complejas y animaciones fluidas al abrir o cerrar el menú.

Paso 3: Implementar los Botones del Menú

Una vez que tenemos el layout, necesitamos definir los botones que se dispondrán dentro del menú. Aquí usamos una vista sencilla llamada MenuButton para representar cada acción del menú.

struct MenuButton: View {
    let title: String
    let icon: String
    let action: () -> Void
    var body: some View {
        Button(title,
               systemImage: icon,
               action: action)
        .padding()
        .background(.clear)
        .border(.black)
        .cornerRadius(8)
        .foregroundStyle(.black)
    }
}

Los botones se organizan de acuerdo con el layout radial, permitiendo que se posicionen a lo largo del círculo de forma natural y flexible. El estilo y el diseño de los botones pueden ajustarse fácilmente para adaptarse a la estética de la aplicación.


Paso 4: Crear el Menú Flotante Radial

Finalmente, vamos a integrar todo en una vista principal que controle la lógica de abrir y cerrar el menú. Al pulsar el botón central, el menú radial se despliega mostrando los botones alrededor del centro, y cuando volvemos a pulsarlo, se cierra con una animación.

struct ContentView: View {
    @State var showMenu = false
    @State var alertMessage: String?
    
    var radialLayoutAngle: RadialLayoutAngle = .leadingCircle
    var angleOffset: Double = 0
    
    var body: some View {
        ZStack {
            Button(action: {
                withAnimation {
                    showMenu.toggle()
                }
            }) {
                ZStack {
                    Circle()
                        .fill(showMenu ? Color.white : Color.black)
                        .frame(width: 100, height: 100)
                    Text(showMenu ? "Cerrar" : "Abrir")
                        .foregroundColor(showMenu ? .black : .white)
                        .font(.system(size: 30))
                        .minimumScaleFactor(0.5)
                        .lineLimit(1)
                        .padding(10)
                }
            }
            if showMenu {
                    RadialLayout(angleConfig: radialLayoutAngle, angleOffset: angleOffset) {
                        MenuButton(title: "Crear",
                                   icon: "plus") {
                            alertMessage = "Crear"
                        }
                        
                        MenuButton(title: "Buscar",
                                   icon: "magnifyingglass") {
                            alertMessage = "Buscar"
                        }
                        
                        MenuButton(title: "Borrar",
                                   icon: "trash") {
                            alertMessage = "Borrar"
                        }
                    }
                    .frame(width: 300, height: 300)
                    .transition(.scale)
            }
        }
        .alert(alertMessage ?? "",
               isPresented: .constant(alertMessage != nil)) {
            Button("Ok") {
                alertMessage = nil
            }
        }
    }
}

Este código es la base para un menú radial interactivo y animado. Puedes ajustar las transiciones y los ángulos de los botones para que se adapten a tu diseño específico.


Beneficios de Usar Layouts Personalizados en SwiftUI

El uso de layouts personalizados en SwiftUI ofrece una flexibilidad enorme para crear interfaces únicas. Aunque SwiftUI proporciona stacks, grids y otros layouts predefinidos, la capacidad de definir tu propio layout permite diseñar experiencias de usuario verdaderamente personalizadas.

  • Mayor control: Los layouts personalizados ofrecen un nivel de control sobre la disposición de las vistas que no es posible con los layouts convencionales.
  • Eficiencia: Al controlar directamente cómo se colocan las vistas, puedes optimizar el rendimiento y crear interfaces más fluidas y reactivas.
  • Creatividad: Te permite crear diseños creativos y únicos que destacarán en cualquier aplicación.

Con esta base, puedes no solo crear menús radiales, sino cualquier otro tipo de disposición personalizada que se te ocurra, desde galerías circulares hasta interfaces de selección radial.


Código Final

Intentaré mantener todos los ejemplos en el repositorio. Aquí dejo el enlace a este ejemplo completo. Github