Programación en Swift y SwiftUI para iOS Developers

Swipe to Dismiss en SwiftUI

Como iOS Developer, sabes que la experiencia de usuario (UX) lo es todo. Las aplicaciones más exitosas en el ecosistema de Apple no solo funcionan bien, sino que se sienten naturales, fluidas e intuitivas. Uno de los gestos más arraigados en la memoria muscular de los usuarios de iPhone y iPad es el de deslizar hacia abajo para cerrar una pantalla o modal.

En este tutorial vamos a sumergirnos en el mundo de la programación Swift para aprender cómo implementar un swipe to dismiss en SwiftUI. Exploraremos desde las soluciones nativas más sencillas hasta la creación de un sistema de gestos totalmente personalizado mediante DragGesture, asegurándonos de que nuestro código en Swift sea elegante, escalable y funcione en proyectos multiplataforma (iOS, macOS y watchOS) utilizando Xcode.


1. El Paradigma del “Swipe to Dismiss” en el Ecosistema Apple

Antes de escribir una sola línea de código en Xcode, es crucial entender cómo y cuándo aplicar este patrón de diseño.

En iOS, el swipe to dismiss en SwiftUI es el estándar de oro para las hojas modales (sheets). Cuando un usuario abre un detalle, una configuración o un formulario rápido, espera poder cerrarlo arrastrándolo hacia la parte inferior de la pantalla. En watchOS, los deslizamientos son fundamentales debido al tamaño reducido de la pantalla. Sin embargo, en macOS, el paradigma cambia drásticamente: los usuarios de Mac interactúan con un cursor (o trackpad) y esperan botones de cierre (X) o el uso de la tecla Escape.

Como un iOS Developer moderno, tu objetivo al usar SwiftUI es escribir código que se adapte inteligentemente a cada plataforma sin duplicar esfuerzos.


2. La Solución Nativa: Usando .sheet en SwiftUI

Si estás desarrollando una aplicación estándar y necesitas presentar una vista que el usuario pueda descartar deslizando, SwiftUI hace el trabajo pesado por ti. Desde sus primeras versiones, el modificador .sheet incluye este comportamiento de forma predeterminada en iOS.

Abre Xcode, crea un nuevo proyecto multiplataforma y echa un vistazo a esta implementación básica:

import SwiftUI

struct NativeSwipeToDismissView: View {
    @State private var showSheet = false

    var body: some View {
        VStack {
            Button("Mostrar Modal Nativo") {
                showSheet = true
            }
            .buttonStyle(.borderedProminent)
            .padding()
        }
        .sheet(isPresented: $showSheet) {
            DetailView()
                // A partir de iOS 16, podemos añadir un indicador visual
                .presentationDragIndicator(.visible)
        }
    }
}

struct DetailView: View {
    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationView {
            ZStack {
                Color.blue.opacity(0.1).ignoresSafeArea()
                
                Text("Desliza hacia abajo para cerrar")
                    .font(.headline)
            }
            .navigationTitle("Detalle")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Cerrar") {
                        dismiss()
                    }
                }
            }
        }
    }
}

Ventajas del método nativo:

  • Cero esfuerzo: El gesto de deslizar funciona perfectamente fuera de la caja en iOS.
  • Accesibilidad: Soporta VoiceOver y otros servicios de accesibilidad de Apple automáticamente.
  • Integración: El modificador .presentationDragIndicator(.visible) (introducido en actualizaciones recientes de Swift) añade la pequeña “píldora” superior que indica al usuario que la vista es arrastrable.

3. El Reto: Vistas Personalizadas y Full Screen Covers

El problema para un iOS Developer surge cuando el diseño exige algo que el .sheet nativo no puede proporcionar. Por ejemplo:

  1. Un popup flotante en el centro de la pantalla.
  2. Un .fullScreenCover que, por defecto, no incluye el gesto de deslizar para cerrar.
  3. Transiciones visuales altamente personalizadas.

Aquí es donde debemos aplicar la programación Swift avanzada y construir nuestro propio swipe to dismiss en SwiftUI utilizando un DragGesture.


4. Construyendo un Swipe to Dismiss Personalizado con DragGesture

Para lograr un arrastre fluido, necesitamos seguir tres pasos lógicos:

  1. Rastrear cuánto ha desplazado el usuario su dedo por la pantalla (offset).
  2. Mover la vista en la pantalla en tiempo real según ese desplazamiento.
  3. Decidir, al soltar el dedo, si el desplazamiento fue suficiente para cerrar la vista o si la vista debe regresar a su posición original (efecto rebote).

Vamos a crear una vista modal personalizada:

import SwiftUI

struct CustomSwipeToDismissView: View {
    @State private var showCustomModal = false

    var body: some View {
        ZStack {
            // Contenido Principal
            VStack {
                Button("Mostrar Modal Personalizado") {
                    withAnimation(.spring()) {
                        showCustomModal = true
                    }
                }
                .buttonStyle(.borderedProminent)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            
            // Capa del Modal Personalizado
            if showCustomModal {
                Color.black.opacity(0.4)
                    .ignoresSafeArea()
                    .onTapGesture {
                        closeModal()
                    }
                
                CustomModalContent(isPresented: $showCustomModal)
                    .transition(.move(edge: .bottom))
            }
        }
    }
    
    private func closeModal() {
        withAnimation(.spring()) {
            showCustomModal = false
        }
    }
}

La Lógica del Gesto de Arrastre

Ahora, vamos a implementar CustomModalContent donde ocurre la magia del swipe to dismiss en SwiftUI.

struct CustomModalContent: View {
    @Binding var isPresented: Bool
    
    // 1. Estado para almacenar el desplazamiento actual
    @State private var dragOffset: CGSize = .zero
    
    var body: some View {
        VStack {
            Capsule()
                .frame(width: 40, height: 6)
                .foregroundColor(.gray.opacity(0.5))
                .padding(.top, 10)
            
            Spacer()
            
            Text("¡Este es un modal personalizado!")
                .font(.title2)
                .bold()
                .multilineTextAlignment(.center)
            
            Spacer()
            
            Button("Cerrar") {
                dismiss()
            }
            .buttonStyle(.bordered)
            .padding(.bottom, 20)
        }
        .frame(maxWidth: .infinity)
        .frame(height: 300)
        .background(
            RoundedRectangle(cornerRadius: 30)
                .fill(Color(UIColor.systemBackground))
                .shadow(radius: 20)
        )
        .padding(.horizontal)
        // 2. Aplicamos el desplazamiento a la vista
        .offset(y: dragOffset.height > 0 ? dragOffset.height : 0)
        // 3. Añadimos el gesto
        .gesture(
            DragGesture()
                .onChanged { value in
                    // Actualizamos el desplazamiento solo si es hacia abajo
                    if value.translation.height > 0 {
                        dragOffset = value.translation
                    }
                }
                .onEnded { value in
                    // 4. Lógica de decisión al soltar
                    let threshold: CGFloat = 100 // Píxeles necesarios para cerrar
                    
                    if value.translation.height > threshold || value.velocity.height > 500 {
                        // Si pasó el umbral o se deslizó muy rápido, cerramos
                        dismiss()
                    } else {
                        // Si no, regresamos a la posición original con una animación
                        withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
                            dragOffset = .zero
                        }
                    }
                }
        )
    }
    
    private func dismiss() {
        withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
            isPresented = false
            dragOffset = .zero
        }
    }
}

Análisis del Código

  • offset(y: dragOffset.height > 0 ? dragOffset.height : 0): Esta línea de programación Swift asegura que la vista solo se pueda arrastrar hacia abajo. Si el usuario intenta arrastrar hacia arriba (valores negativos), la vista se mantiene fija.
  • value.velocity.height: Un buen iOS Developer sabe que a veces los usuarios no arrastran hasta el fondo, sino que dan un “toque rápido” (flick) hacia abajo. Leer la velocidad del gesto nos permite cerrar la vista si el impulso es fuerte, mejorando drásticamente la UX.
  • .spring(): En SwiftUI, las animaciones de tipo resorte son esenciales para replicar la física real. Cuando la vista no supera el umbral, regresa a su sitio rebotando ligeramente.

5. Adaptación Multiplataforma (iOS, macOS, watchOS)

La promesa de SwiftUI es “Aprende una vez, aplícalo en todas partes”. Sin embargo, compilar este código directamente para macOS en Xcode presentará problemas de usabilidad. En un Mac, arrastrar un panel modal hacia abajo con el ratón no es una interacción estándar.

Para hacer que nuestro código sea verdaderamente profesional, usaremos directivas del compilador de Swift para adaptar el comportamiento a cada sistema operativo.

Vamos a crear un modificador de vista personalizado para encapsular esta lógica, de modo que podamos reutilizarlo en todo nuestro proyecto de Xcode.

import SwiftUI

// Creamos un modificador de vista reutilizable
struct SwipeToDismissModifier: ViewModifier {
    var onDismiss: () -> Void
    @State private var dragOffset: CGSize = .zero
    
    func body(content: Content) -> some View {
        #if os(iOS) || os(watchOS)
        // Implementación de gestos para pantallas táctiles
        content
            .offset(y: dragOffset.height > 0 ? dragOffset.height : 0)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        if value.translation.height > 0 {
                            dragOffset = value.translation
                        }
                    }
                    .onEnded { value in
                        if value.translation.height > 100 || value.velocity.height > 300 {
                            onDismiss()
                        } else {
                            withAnimation(.spring()) {
                                dragOffset = .zero
                            }
                        }
                    }
            )
        #else
        // Implementación para macOS (sin arrastre, el cierre depende de botones)
        content
        #endif
    }
}

// Extensión para facilitar su uso
extension View {
    func customSwipeToDismiss(onDismiss: @escaping () -> Void) -> some View {
        self.modifier(SwipeToDismissModifier(onDismiss: onDismiss))
    }
}

¿Cómo aplicar esto ahora?

Gracias a esta refactorización mediante programación Swift avanzada, aplicar tu swipe to dismiss en SwiftUI a cualquier vista es tan sencillo como añadir una línea de código:

// En tu vista modal:
.customSwipeToDismiss {
    withAnimation(.spring()) {
        isPresented = false
    }
}

En iOS y watchOS, la vista ganará automáticamente la capacidad de ser arrastrada hacia abajo. En macOS, el modificador devolverá el contenido intacto, evitando interacciones extrañas con el ratón, y forzando al usuario a utilizar el botón “Cerrar” (que deberías proveer en la interfaz).


6. Consideraciones de Rendimiento y Buenas Prácticas

Como iOS Developer, al trabajar con gestos continuos (onChanged) en Xcode, debes tener cuidado de no realizar cálculos pesados durante el arrastre. SwiftUI redibuja la vista en cada fotograma del movimiento.

  1. Evita estados complejos: Asegúrate de que las variables que mutan durante el DragGesture (como dragOffset) afecten únicamente a modificadores de diseño (como .offset o .opacity) y no disparen redibujados de listas inmensas o llamadas a red.
  2. Usa fondos interactivos: En nuestro ejemplo, el espacio transparente alrededor del modal también escucha toques (onTapGesture) para cerrar la vista. Esto es una convención estándar; los usuarios esperan que tocar “fuera” del modal lo cierre.
  3. Control de la opacidad: Un toque extra de calidad es hacer que el fondo oscuro (Color.black.opacity(...)) se vuelva más transparente a medida que el usuario arrastra la vista hacia abajo, vinculando la opacidad al dragOffset.height.

Conclusión

Implementar un swipe to dismiss en SwiftUI de forma personalizada es un rito de paso para cualquier iOS Developer. Demuestra que no solo sabes utilizar los componentes predeterminados de Xcode, sino que comprendes las matemáticas espaciales, las animaciones y el manejo de estados necesarios para manipular la UI a tu antojo usando programación Swift.

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

Tab Bar personalizado en SwiftUI

Next Article

Cómo añadir el botón de cerrar a una Sheet en SwiftUI

Related Posts