Programación en Swift y SwiftUI para iOS Developers

Cómo mostrar un Bottom Sheet en SwiftUI

En el desarrollo moderno de iOS, la Bottom Sheet (o hoja inferior) se ha convertido en uno de los componentes de interfaz de usuario más omnipresentes y versátiles. Desde Apple Maps hasta la aplicación de Bolsa, este patrón permite presentar información complementaria sin perder el contexto de la vista principal.

SwiftUI ha evolucionado drásticamente en la forma en que maneja estas vistas. Lo que antes requiríba controladores de UIKit (UISheetPresentationController), ahora son modificadores nativos, declarativos y potentes.

En este artículo de 2000 palabras, exploraremos todo lo que necesitas saber para implementar, personalizar y gestionar Bottom Sheets en tus aplicaciones profesionales.


1. Los Fundamentos: El Modificador .sheet

En su forma más básica, una hoja en SwiftUI es una vista modal que aparece desde la parte inferior de la pantalla y cubre (parcialmente o totalmente) la vista actual. La forma principal de presentarla es utilizando el modificador .sheet.

El enfoque booleano (isPresented)

La forma más sencilla de mostrar una hoja es vinculando su presentación a una variable de estado booleana (Bool). Cuando esta variable cambia a true, la hoja aparece. Cuando cambia a false (o el usuario la desliza hacia abajo), la hoja desaparece.

import SwiftUI

struct BasicSheetView: View {
    // 1. Estado para controlar la visibilidad
    @State private var showSheet = false

    var body: some View {
        VStack {
            Image(systemName: "doc.text.fill")
                .font(.system(size: 60))
                .foregroundStyle(.blue)
            
            Text("Ejemplo Básico")
                .font(.title)
                .padding()

            Button("Mostrar Hoja") {
                // 2. Activamos el estado
                showSheet = true
            }
            .buttonStyle(.borderedProminent)
        }
        // 3. El modificador se adjunta a la vista principal
        .sheet(isPresented: $showSheet) {
            // 4. El contenido de la hoja
            SheetContentView()
        }
    }
}

struct SheetContentView: View {
    var body: some View {
        ZStack {
            Color.gray.opacity(0.1).ignoresSafeArea()
            VStack {
                Text("¡Hola desde la Bottom Sheet!")
                    .font(.headline)
                Text("Desliza hacia abajo para cerrar")
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Ciclo de Vida y Comportamiento

Es importante entender qué sucede “bajo el capó”:

  1. Lazy Loading: El contenido dentro de .sheet no se inicializa hasta que la hoja se presenta realmente. Esto es excelente para el rendimiento.
  2. Contexto: En iPhone, la hoja es una “tarjeta” que se apila visualmente. En iPad, por defecto, se presenta como una hoja flotante central (form sheet) a menos que se especifique lo contrario.
  3. Binding Bidireccional: Cuando el usuario cierra la hoja deslizando el dedo, SwiftUI automáticamente establece tu variable showSheet de nuevo a false.

2. Manejo de Datos: .sheet(item:)

El error más común de los principiantes es intentar usar isPresented cuando necesitan pasar datos dinámicos a la hoja (por ejemplo, mostrar el detalle de un elemento seleccionado de una lista).

Si intentas actualizar una variable de datos y luego cambiar el booleano, a veces puedes encontrarte con condiciones de carrera o con que la hoja muestre datos antiguos. Para esto, SwiftUI ofrece la variante .sheet(item:).

Requisito: Protocolo Identifiable

Para usar esta variante, el objeto que quieres pasar debe conformar el protocolo Identifiable. Esto permite a SwiftUI saber exactamente qué instancia única ha cambiado para renderizar la vista correcta.

struct User: Identifiable {
    let id = UUID()
    let name: String
    let bio: String
}

struct UserListView: View {
    // Datos de ejemplo
    let users = [
        User(name: "Ana", bio: "Ingeniera de Software"),
        User(name: "Carlos", bio: "Diseñador UX"),
        User(name: "Sofia", bio: "Product Manager")
    ]
    
    // Estado opcional: Si es nil, la hoja está oculta. Si tiene valor, se muestra.
    @State private var selectedUser: User?

    var body: some View {
        List(users) { user in
            Button {
                selectedUser = user
            } label: {
                HStack {
                    Text(user.name)
                    Spacer()
                    Image(systemName: "info.circle")
                }
            }
        }
        .sheet(item: $selectedUser) { user in
            // SwiftUI nos entrega el usuario de forma segura (unwrapped)
            UserDetailView(user: user)
        }
    }
}

struct UserDetailView: View {
    let user: User
    var body: some View {
        VStack(spacing: 20) {
            Text(user.name).font(.largeTitle).bold()
            Text(user.bio)
        }
        .presentationDetents([.medium]) // Veremos esto más adelante
    }
}

Ventaja clave: Al usar item: $selectedUser, SwiftUI garantiza que la vista UserDetailView se crea con los datos frescos del usuario seleccionado. Cuando la hoja se cierra, selectedUser vuelve a ser nil automáticamente.


3. La Revolución de iOS 16: Presentation Detents

Antes de iOS 16, crear una hoja que solo ocupara la mitad de la pantalla (como en Apple Maps) era una pesadilla que requería envoltorios de UIKit. Ahora, SwiftUI lo hace nativo y extremadamente sencillo con .presentationDetents.

Un “detent” es una altura de parada específica para la hoja.

Tamaños Estándar

Podemos controlar qué tan alto sube la hoja.

.sheet(isPresented: $showSettings) {
    SettingsView()
        .presentationDetents([.medium, .large])
}
  • .medium: La hoja ocupa aproximadamente la mitad de la pantalla.
  • .large: La hoja ocupa toda la pantalla (el comportamiento estándar antiguo).

Al pasar un array [.medium, .large], le damos al usuario la capacidad de arrastrar la hoja entre la mitad y la pantalla completa. Aparecerá una pequeña barra de arrastre (grabber) automáticamente.

Tamaños Personalizados (Fraction y Height)

A veces .medium es demasiado grande o demasiado pequeño. Podemos ser muy específicos:

.sheet(isPresented: $showInfo) {
    InfoView()
        .presentationDetents([
            .fraction(0.2), // Ocupa el 20% de la pantalla
            .height(200),   // Altura fija de 200 puntos
            .medium,
            .large
        ])
}
  • .fraction(Double): Útil para reproductores de música mini o barras de información rápida.
  • .height(CGFloat): Útil cuando sabes exactamente cuánto espacio ocupa tu contenido (por ejemplo, un selector de fecha o un menú de 3 opciones).

Control Programático del Detent

¿Qué pasa si quieres que la hoja comience en tamaño medium, pero se expanda a large si el usuario toca un botón “Ver más”? Necesitamos vincular la selección del detent a una variable de estado (Binding).

struct DynamicSheet: View {
    @State private var showSheet = false
    // Definimos el detent inicial
    @State private var currentDetent = PresentationDetent.medium

    var body: some View {
        Button("Abrir") { showSheet = true }
        .sheet(isPresented: $showSheet) {
            VStack {
                Text("Contenido Importante")
                
                Button("Expandir a Pantalla Completa") {
                    // Animamos el cambio de tamaño
                    withAnimation {
                        currentDetent = .large
                    }
                }
            }
            .presentationDetents(
                [.medium, .large],
                selection: $currentDetent // Vinculación
            )
        }
    }
}

4. Personalización Visual y de Comportamiento

Una vez que dominas el tamaño, el siguiente paso es hacer que la hoja se sienta parte integral de tu diseño.

El Indicador de Arrastre (Drag Indicator)

Por defecto, si tienes múltiples detents, SwiftUI muestra una pequeña barra gris en la parte superior. Puedes controlar su visibilidad explícitamente.

.presentationDragIndicator(.visible) // Siempre visible
// o
.presentationDragIndicator(.hidden)  // Siempre oculto

Es buena práctica dejarlo .visible si la hoja es redimensionable, para dar una pista visual al usuario (Affordance).

Radio de Esquina (Corner Radius)

Desde iOS 16.4, podemos modificar qué tan redondas son las esquinas superiores de la hoja.

.presentationCornerRadius(30)

Esto es útil si tu aplicación tiene un lenguaje de diseño muy redondeado o, por el contrario, muy cuadrado.

Fondos y Materiales

¿Quieres que tu hoja sea translúcida? ¿O que tenga un color corporativo específico? El modificador .presentationBackgroundes la clave.

.sheet(isPresented: $show) {
    ContentView()
        // Fondo con material translúcido (efecto vidrio)
        .presentationBackground(.ultraThinMaterial)
        
        // O un color sólido/gradiente
        // .presentationBackground(.yellow.opacity(0.8))
        
        // O incluso una vista compleja
        // .presentationBackground {
        //     Image("texture").resizable()
        // }
}

Interacción con el Fondo (Background Interaction)

Una de las características más potentes introducidas recientemente es la capacidad de interactuar con la vista trasera mientras la hoja está abierta (especialmente útil si la hoja es pequeña, .fraction(0.2)).

Por defecto, la vista trasera se oscurece y no es interactiva. Podemos cambiar esto:

.presentationBackgroundInteraction(.enabled(upThrough: .medium))

Con este código, si la hoja está en posición .medium (o menor), el usuario puede seguir haciendo scroll o tocando botones en la vista que está detrás de la hoja. Es el comportamiento típico de aplicaciones de mapas (puedes mover el mapa mientras ves la información del lugar abajo).


5. Gestión Avanzada del Cierre (Dismissal)

Saber cómo abrir una hoja es fácil; saber cómo y cuándo cerrarla es lo que define una buena UX.

Cerrar Programáticamente

A menudo, la hoja contiene un formulario y un botón “Guardar”. Al tocar “Guardar”, queremos cerrar la hoja automáticamente. Para ello usamos la variable de entorno dismiss.

struct EditProfileView: View {
    // 1. Accedemos al entorno
    @Environment(\.dismiss) var dismiss

    var body: some View {
        VStack {
            Text("Editar Perfil")
            
            Button("Guardar y Cerrar") {
                saveData()
                // 2. Ejecutamos la acción de cierre
                dismiss()
            }
        }
    }
    
    func saveData() { /* lógica */ }
}

Prevenir el Cierre Accidental

Si el usuario está llenando un formulario largo y accidentalmente desliza la hoja hacia abajo, perderá todo su trabajo. Para evitar esto, usamos .interactiveDismissDisabled().

Este modificador deshabilita el gesto de deslizar para cerrar.

struct FormSheet: View {
    @State private var hasUnsavedChanges = false
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Form {
            TextField("Nombre", text: .constant(""))
                .onChange(of: "val", perform: { _ in hasUnsavedChanges = true })
        }
        // Deshabilita el gesto SI hay cambios sin guardar
        .interactiveDismissDisabled(hasUnsavedChanges)
        .toolbar {
            if hasUnsavedChanges {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancelar") {
                        // Aquí podríamos mostrar una alerta de confirmación antes
                        dismiss()
                    }
                }
            }
        }
    }
}

6. Arquitectura: Bottom Sheets y MVVM

En aplicaciones grandes, no queremos llenar nuestra View de lógica de estado. Es preferible mover la lógica de presentación al ViewModel.

Patrón Recomendado

El ViewModel debe controlar el estado de qué se presenta, y la Vista simplemente reacciona.

class ProductListViewModel: ObservableObject {
    @Published var selectedProduct: Product? // El trigger
    
    func didSelect(product: Product) {
        self.selectedProduct = product
    }
}

struct ProductListView: View {
    @StateObject private var viewModel = ProductListViewModel()
    
    var body: some View {
        List(viewModel.products) { product in
            Button(product.name) {
                viewModel.didSelect(product: product)
            }
        }
        // La vista observa al ViewModel
        .sheet(item: $viewModel.selectedProduct) { product in
            ProductDetailView(product: product)
        }
    }
}

Este enfoque desacopla la lógica. El ViewModel no sabe cómo se muestra el producto (si es una hoja, un push navigation o un fullScreenCover), solo sabe que un producto ha sido seleccionado.


7. Diferencias con .fullScreenCover

Es vital mencionar al hermano mayor de la .sheet.fullScreenCover.

La sintaxis es idéntica:

.fullScreenCover(isPresented: $show) { ... }

Diferencias Clave:

  1. Espacio: Ocupa el 100% de la pantalla, cubriendo incluso la barra de estado superior.
  2. Gesto: NO se puede cerrar deslizando hacia abajo. Debes proporcionar obligatoriamente un botón para cerrar que llame a dismiss().
  3. Uso: Se usa para flujos inmersivos donde el usuario debe completar una tarea antes de volver (ej. Login, Cámara, Onboarding, Edición de Video).

8. Solución de Problemas Comunes

Incluso los expertos se tropiezan con ciertos comportamientos de las hojas en SwiftUI. Aquí los más frecuentes:

A. El error del “Loop”

Problema: Poner el modificador .sheet dentro de un ForEach o ListSíntoma: Múltiples hojas intentando abrirse, uso excesivo de memoria o comportamiento errático. Solución: Mueve el .sheet fuera del bucle, adjuntándolo al contenedor principal (como la List o el VStack), y usa .sheet(item:).

B. Fondo gris en Listas

Problema: Al abrir una hoja con un NavigationView o List dentro, el fondo se ve gris en lugar de blanco/negro. Causa:SwiftUI aplica estilos de fondo por defecto en hojas modales. Solución: Usa .presentationBackground o cambia el estilo de la lista .listStyle(.plain).

C. Teclado y Detents

Problema: Cuando el teclado aparece en una hoja .medium, la hoja se empuja hacia arriba de forma extraña o tapa el campo de texto. Solución: SwiftUI maneja esto automáticamente en iOS 16+, expandiendo la hoja a .large si es necesario, o desplazando el contenido (scroll) dentro de la hoja .medium. Asegúrate de usar ScrollView dentro de tu hoja.


9. Resumen y Mejores Prácticas

Para cerrar, aquí tienes una lista de verificación (checklist) para cuando implementes tu próxima Bottom Sheet:

  1. Propósito: ¿Es información secundaria? Usa .sheet. ¿Es un flujo bloqueante? Usa .fullScreenCover.
  2. Contexto: ¿Necesita el usuario ver la pantalla de atrás? Usa presentationDetents con .medium o .fraction.
  3. Datos: ¿La hoja depende de un objeto de una lista? Usa .sheet(item:) en lugar de isPresented.
  4. iPad: Recuerda probar en iPad. Si quieres que se vea igual que en iPhone, puede que necesites ajustar el contenedor, aunque el comportamiento nativo de “Form Sheet” suele ser el correcto en iPadOS.
  5. Cierre: Proporciona siempre una forma clara de salir, especialmente si desactivas el gesto de deslizar o usas pantalla completa.

La Bottom Sheet es una herramienta poderosa en tu arsenal de SwiftUI. Con el control preciso sobre los “detents” y la apariencia introducidos en las últimas versiones de iOS, ahora tienes la capacidad de crear interfaces de usuario fluidas, modernas y altamente funcionales con muy pocas líneas de código.

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Cómo ocultar el teclado en SwiftUI

Next Article

Cómo seleccionar múltiples items en un Picker en SwiftUI

Related Posts