Posts

Sheets laterales en SwiftUI: presentaciones adaptativas para iOS 27

Arturo Rivas Arias

Los sheet llevan años siendo una de las herramientas más habituales de SwiftUI para presentar contenido temporal. En el iPhone cuando está en vertical, el patrón funciona especialmente bien: la vista aparece desde la parte inferior, puede adoptar diferentes alturas mediante detents y mantiene clara la jerarquía entre el contenido principal y el modal.

El problema aparece cuando dejamos de pensar en una pantalla de iPhone con dimensiones prácticamente fijas. Con iOS 27, las aplicaciones de iPhone deben estar preparadas para ejecutarse en entornos redimensionables, incluyendo iPhone Mirroring y diferentes tamaños disponibles al ejecutarse en un iPad. En ese contexto, una presentación que siempre crece desde la parte inferior deja de ser necesariamente la mejor solución. En una ventana ancha y relativamente baja, un panel lateral puede aprovechar mucho mejor el espacio disponible.

De ahí surge un patrón especialmente interesante: mantener la presentación inferior cuando el espacio horizontal es reducido y transformarla en un panel alineado a leading o trailing cuando existe anchura suficiente. No se trata simplemente de hacer un sheet más estrecho. El cambio afecta al eje de presentación, a la interacción con el contenido situado detrás y a la forma en la que pensamos la adaptabilidad de la interfaz.

Lo que ofrece realmente sheet

SwiftUI sigue utilizando el modificador sheet como punto de entrada para este tipo de presentaciones:

struct LibraryView: View {
    @State private var showingDetails = false

    var body: some View {
        Button("Mostrar detalles") {
            showingDetails = true
        }
        .sheet(isPresented: $showingDetails) {
            BookDetailsView()
        }
    }
}

A partir de ahí podemos controlar aspectos como los detents, el indicador de arrastre, el fondo, el radio de las esquinas o el tamaño de la presentación.

BookDetailsView()
    .presentationDetents([.medium, .large])
    .presentationDragIndicator(.visible)
    .presentationCornerRadius(28)

Otra API importante es presentationSizing(_:). SwiftUI dispone de tamaños predefinidos como .form, .page o .fitted, además de la posibilidad de crear un PresentationSizing personalizado. Esto permite decidir qué tamaño propone el sistema al contenido de una presentación.

BookDetailsView()
    .presentationSizing(
        .page
            .fitted(horizontal: true, vertical: false)
    )

Pero el tamaño y la posición son dos problemas distintos. Reducir la anchura de una sheet no significa convertirla automáticamente en un panel lateral. La API estándar continúa delegando en el sistema la geometría final de la presentación.

iOS 27 cambia el contexto

Uno de los cambios que más impacto puede tener en interfaces existentes es que una aplicación ya no debería basar su diseño en la suposición de que «iPhone» equivale a un conjunto reducido de tamaños de pantalla. Una misma escena puede cambiar de dimensiones durante su ejecución y, por tanto, el layout debe reaccionar al espacio realmente disponible.

Esto hace menos recomendable decidir el diseño mediante comprobaciones como UIDevice.current.userInterfaceIdiom o utilizando directamente el tamaño global de UIScreen. Para una presentación lateral resulta mucho más robusto observar el tamaño que SwiftUI está proponiendo al contenedor y cambiar de estrategia en función de esa geometría.

struct AdaptivePanelContainer<Content: View>: View {
    @ViewBuilder var content: () -> Content

    var body: some View {
        ViewThatFits(in: .horizontal) {
            wideLayout
            compactLayout
        }
    }

    private var wideLayout: some View {
        HStack {
            Spacer(minLength: 420)
            content()
                .frame(width: 360)
        }
    }

    private var compactLayout: some View {
        content()
            .frame(maxWidth: .infinity)
    }
}

ViewThatFits puede ser suficiente en algunos componentes, aunque para una presentación completa normalmente necesitamos algo más de control sobre la geometría, la animación y la interacción con el fondo.

De bottom sheet a panel lateral

Podemos construir un contenedor que seleccione automáticamente entre dos representaciones. En espacios compactos utiliza una presentación inferior; cuando la ventana supera cierto ancho, muestra el contenido como un panel situado en el lateral.

El ejemplo siguiente utiliza una pantalla de edición de fotografías. El panel contiene herramientas de ajuste y no tiene relación con el contenido del artículo original, pero permite ver claramente por qué este patrón puede resultar útil.

struct PhotoEditorView: View {
    @State private var showingTools = false

    var body: some View {
        GeometryReader { proxy in
            let usesSidePanel = proxy.size.width >= 700

            ZStack {
                PhotoCanvas()

                if showingTools {
                    if usesSidePanel {
                        sidePanel
                            .transition(.move(edge: .trailing))
                    } else {
                        bottomPanel
                            .transition(.move(edge: .bottom))
                    }
                }
            }
            .animation(.snappy, value: showingTools)
            .toolbar {
                Button("Ajustes", systemImage: "slider.horizontal.3") {
                    showingTools.toggle()
                }
            }
        }
    }

    private var sidePanel: some View {
        HStack(spacing: 0) {
            Spacer()

            EditingToolsView()
                .frame(width: 360)
                .frame(maxHeight: .infinity)
                .background(.regularMaterial)
                .clipShape(
                    .rect(
                        topLeadingRadius: 24,
                        bottomLeadingRadius: 24
                    )
                )
                .shadow(radius: 20)
        }
    }

    private var bottomPanel: some View {
        VStack(spacing: 0) {
            Spacer()

            EditingToolsView()
                .frame(maxWidth: .infinity)
                .frame(height: 320)
                .background(.regularMaterial)
                .clipShape(
                    .rect(
                        topLeadingRadius: 24,
                        topTrailingRadius: 24
                    )
                )
                .shadow(radius: 20)
        }
    }
}

La idea importante no es el valor concreto de 700 puntos, sino tomar la decisión a partir del espacio disponible. En una aplicación real, ese umbral debería corresponderse con las necesidades del contenido principal y del propio panel.

No confundas tamaño de dispositivo con espacio disponible

Una tentación habitual sería escribir algo parecido a esto:

if UIDevice.current.userInterfaceIdiom == .pad {
    showSidePanel()
} else {
    showBottomSheet()
}

Este enfoque era cuestionable antes de iOS 27 y lo es todavía más en una interfaz redimensionable. Un iPad puede ofrecer una región estrecha a la aplicación y una ventana de una app de iPhone puede disponer de más espacio horizontal del esperado.

Una estrategia basada en geometría expresa mejor la intención:

let prefersSidePresentation = availableWidth >= 700

La interfaz deja de preguntar «¿en qué dispositivo estoy?» y pasa a preguntar «¿tengo espacio suficiente para esta presentación?». Es una diferencia que puede ser pequeña en el código, pero fundamental para construir layouts realmente adaptativos.

leading y trailing en lugar de izquierda y derecha

Si el panel forma parte de una interfaz que va a traducirse a otros idiomas, conviene evitar asumir que trailing equivale siempre al lado derecho. SwiftUI adapta leading y trailing automáticamente al sentido de escritura del idioma.

Podemos leer la dirección del layout desde el entorno:

struct SidePanel<Content: View>: View {
    @Environment(\.layoutDirection) private var layoutDirection

    let alignment: HorizontalAlignment
    @ViewBuilder var content: () -> Content

    var body: some View {
        HStack(spacing: 0) {
            if alignment == .trailing {
                Spacer()
            }

            content()

            if alignment == .leading {
                Spacer()
            }
        }
    }
}

En la mayoría de los casos ni siquiera necesitamos consultar layoutDirection manualmente: utilizar conceptos semánticos como leading y trailing permite que SwiftUI haga el trabajo por nosotros.

Una presentación desde un lateral no tiene por qué ser modal

Esta es probablemente la diferencia conceptual más interesante. Los sheet tradicionales bloquean normalmente la interacción con el contenido situado detrás. Sin embargo, muchos paneles laterales funcionan mejor como una segunda capa de interacción que convive con el contenido principal.

Piensa en un editor, una aplicación de mapas, un inspector de propiedades o una herramienta de productividad. En estos casos puede ser útil seguir interactuando con la vista principal mientras el panel permanece abierto.

Con un sheet estándar existe presentationBackgroundInteraction(_:), que permite habilitar la interacción con la vista situada detrás en determinados escenarios:

DetailView()
    .presentationDetents([.height(220), .medium, .large])
    .presentationBackgroundInteraction(
        .enabled(upThrough: .height(220))
    )

Para un panel lateral completamente personalizado, esa decisión pasa a ser nuestra. Podemos utilizar un overlay transparente que capture los toques para obtener comportamiento modal, o dejar libre el contenido principal para crear una presentación no modal.

ZStack {
    mainContent

    if isPresented {
        Color.clear
            .contentShape(Rectangle())
            .onTapGesture {
                isPresented = false
            }

        panel
    }
}

Si eliminamos esa capa transparente, el usuario podrá seguir interactuando con el contenido situado detrás.

Extraer el patrón a un modificador reutilizable

Cuando este comportamiento aparece en varias pantallas, tiene sentido convertirlo en una abstracción. Un ViewModifier puede encapsular el breakpoint, la alineación, la animación y el contenido de la presentación.

struct AdaptivePanelModifier<Panel: View>: ViewModifier {
    @Binding var isPresented: Bool
    let breakpoint: CGFloat
    @ViewBuilder let panel: () -> Panel

    func body(content: Content) -> some View {
        GeometryReader { proxy in
            let usesSidePanel = proxy.size.width >= breakpoint

            ZStack {
                content

                if isPresented {
                    if usesSidePanel {
                        HStack(spacing: 0) {
                            Spacer()

                            panel()
                                .frame(width: 360)
                                .frame(maxHeight: .infinity)
                                .transition(.move(edge: .trailing))
                        }
                    } else {
                        VStack(spacing: 0) {
                            Spacer()

                            panel()
                                .frame(maxWidth: .infinity)
                                .frame(height: 320)
                                .transition(.move(edge: .bottom))
                        }
                    }
                }
            }
            .animation(.snappy, value: isPresented)
        }
    }
}

extension View {
    func adaptivePanel<Panel: View>(
        isPresented: Binding<Bool>,
        breakpoint: CGFloat = 700,
        @ViewBuilder panel: @escaping () -> Panel
    ) -> some View {
        modifier(
            AdaptivePanelModifier(
                isPresented: isPresented,
                breakpoint: breakpoint,
                panel: panel
            )
        )
    }
}

La pantalla que lo consume queda mucho más declarativa:

struct DashboardView: View {
    @State private var showingFilters = false

    var body: some View {
        DashboardContent()
            .adaptivePanel(isPresented: $showingFilters) {
                FilterPanel()
                    .padding()
                    .background(.regularMaterial)
            }
            .toolbar {
                Button("Filtros", systemImage: "line.3.horizontal.decrease") {
                    showingFilters.toggle()
                }
            }
    }
}

Adaptar también el propio contenido

Cambiar únicamente el lugar desde el que aparece el panel no siempre es suficiente. Un panel inferior de 320 puntos de altura y otro lateral de 360 puntos de ancho tienen geometrías muy diferentes. El contenido interno debería poder aprovecharlas.

struct EditingToolsView: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    var body: some View {
        if horizontalSizeClass == .regular {
            ScrollView {
                verticalTools
            }
        } else {
            ScrollView(.horizontal) {
                horizontalTools
            }
        }
    }

    private var verticalTools: some View {
        VStack(spacing: 24) {
            ExposureControl()
            ContrastControl()
            SaturationControl()
        }
        .padding()
    }

    private var horizontalTools: some View {
        HStack(spacing: 24) {
            ExposureControl()
            ContrastControl()
            SaturationControl()
        }
        .padding()
    }
}

Conviene recordar que las size classes y el ancho exacto no resuelven exactamente el mismo problema. Las primeras son una clasificación representativa del entorno; el tamaño local permite tomar decisiones más precisas. En componentes complejos puede tener sentido utilizar ambos.

¿Cuándo seguir utilizando un sheet normal?

Las presentaciones laterales no sustituyen a los sheet. Para tareas claramente modales y lineales —crear un elemento, confirmar una acción, completar un formulario breve o mostrar información que debe atenderse antes de continuar— la presentación del sistema continúa siendo la opción más consistente y accesible.

Un panel lateral resulta especialmente interesante cuando el contenido presentado complementa a la pantalla principal en lugar de reemplazar temporalmente su contexto: inspectores, filtros, propiedades, navegación secundaria, herramientas de edición o información de detalle que conviene consultar mientras se sigue trabajando con la vista principal.

El patrón detrás de iOS 27

Más que una nueva forma visual de mostrar una vista, las presentaciones laterales encajan con una evolución más amplia de las interfaces de Apple: dejar de diseñar para un dispositivo concreto y empezar a diseñar para el espacio que una escena tiene disponible en cada momento.

SwiftUI ya ofrece las piezas necesarias para hacer que una presentación cambie de tamaño, se adapte a distintas clases de tamaño y controle la interacción con el contenido situado detrás. Sin embargo, cuando queremos cambiar también el eje de la presentación —inferior en espacios estrechos y lateral en espacios anchos— sigue siendo útil modelar explícitamente ese comportamiento mediante composición y layout.

iOS 27 refuerza precisamente esta idea. Una interfaz verdaderamente adaptativa no debería limitarse a escalar sus vistas: también puede cambiar la forma en la que presenta herramientas y contenido secundario. Convertir una bottom sheet en un panel lateral cuando aparece espacio adicional es un buen ejemplo de cómo aprovechar esa flexibilidad sin duplicar pantallas ni mantener dos jerarquías completamente diferentes.