Programación en Swift y SwiftUI para iOS Developers

Cómo añadir Swipe Actions a una List en SwiftUI

Desde el lanzamiento de iOS 15, Apple transformó significativamente la forma en que los desarrolladores interactúan con las listas (List) en SwiftUI. Si bien el modificador .onDelete nos sirvió fielmente durante años para eliminar elementos, era limitado: solo funcionaba en el borde derecho, solo servía para borrar y su personalización era escasa.

La llegada del modificador .swipeActions() cambió las reglas del juego. Ahora, los desarrolladores tienen el poder de agregar múltiples acciones, personalizar colores, usar iconos (SF Symbols), elegir entre el borde izquierdo (leading) o derecho (trailing) y controlar la interacción del “deslizamiento completo” (full swipe).

En este tutorial de profundidad técnica, exploraremos cada rincón de esta API, construyendo ejemplos prácticos que puedes copiar y pegar directamente en Xcode.


1. ¿Qué es .swipeActions() y por qué deberías usarlo?

El modificador .swipeActions() permite agregar botones de acción personalizados que se revelan cuando un usuario desliza una fila dentro de una List.

Ventajas clave sobre los métodos antiguos:

  1. Flexibilidad Direccional: Soporta acciones tanto en el lado leading (izquierda en LTR) como en el trailing(derecha en LTR).
  2. Personalización Visual: Soporte nativo para .tint (colores) y combinaciones de texto e imágenes.
  3. Roles Semánticos: Integración con Button(role:) para acciones destructivas o de cancelación.
  4. Full Swipe: Capacidad de ejecutar la acción principal al deslizar completamente la fila, imitando el comportamiento nativo de aplicaciones como Mail o Mensajes.

Nota de Compatibilidad: Este modificador requiere iOS 15.0+macOS 12.0+tvOS 15.0+ o watchOS 8.0+. Si tu app debe soportar iOS 14, necesitarás lógica condicional.


2. Anatomía Básica del Modificador

Antes de sumergirnos en el código complejo, analicemos la firma de la función:

func swipeActions<T>(
    edge: HorizontalEdge = .trailing,
    allowsFullSwipe: Bool = true,
    content: () -> T
) -> some View where T : View
  • edge: Define en qué lado aparecen las acciones (.leading o .trailing). El valor predeterminado es .trailing.
  • allowsFullSwipe: Un booleano. Si es true (por defecto), el usuario puede deslizar hasta el final para ejecutar automáticamente la primera acción de la lista.
  • content: Un ViewBuilder donde colocas tus botones.

3. Implementación: Tu Primer Deslizamiento

Vamos a crear un escenario simple: una lista de correos electrónicos donde queremos tener opciones para borrar y marcar como no leído.

Paso 1: Configurar la estructura de datos

Primero, necesitamos un modelo simple. Abre Xcode y crea un nuevo proyecto SwiftUI.

struct Email: Identifiable {
    let id = UUID()
    var subject: String
    var isRead: Bool
}

Paso 2: La Vista de Lista

Ahora, implementemos la lista con el modificador básico.

struct EmailListView: View {
    @State private var emails = [
        Email(subject: "Factura de Apple", isRead: true),
        Email(subject: "Boletín Semanal", isRead: false),
        Email(subject: "Recordatorio de reunión", isRead: true)
    ]

    var body: some View {
        List {
            ForEach(emails) { email in
                HStack {
                    Image(systemName: email.isRead ? "envelope.open" : "envelope.badge")
                    Text(email.subject)
                }
                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                    // Acción 1: Borrar
                    Button(role: .destructive) {
                        if let index = emails.firstIndex(where: { $0.id == email.id }) {
                            emails.remove(at: index)
                        }
                    } label: {
                        Label("Eliminar", systemImage: "trash")
                    }
                    
                    // Acción 2: Archivar (Ejemplo de botón estándar)
                    Button {
                        print("Archivado")
                    } label: {
                        Label("Archivar", systemImage: "archivebox")
                    }
                    .tint(.indigo) // Color personalizado
                }
            }
        }
    }
}

Análisis del Código

  1. Posición: Usamos edge: .trailing. Las acciones aparecerán al deslizar de derecha a izquierda.
  2. Orden: El primer botón definido en el cierre (en este caso, “Eliminar”) es el que está más a la derecha (el exterior). También es el que se ejecutará si allowsFullSwipe es verdadero.
  3. Roles: Al usar role: .destructive, SwiftUI aplica automáticamente el color rojo y una animación de eliminación adecuada.
  4. Tint: En el botón de “Archivar”, usamos .tint(.indigo) para salir del gris predeterminado.

4. Estrategias de UX: Leading vs. Trailing

Una parte crucial del desarrollo iOS es seguir las guías de interfaz humana (HIG). No debes colocar acciones aleatoriamente.

El Borde Final (.trailing)

Este es el lugar estándar para acciones destructivas o las acciones más comunes.

  • Ejemplos: Eliminar, Silenciar, Más opciones.
  • Por qué: Es el gesto más natural para usuarios diestros y el estándar establecido por la app Mail.

El Borde Inicial (.leading)

Se utiliza para acciones de estado positivo o alternativo que no eliminan el ítem de la vista inmediata, o para acciones secundarias.

  • Ejemplos: Marcar como leído/no leído, Fijar (Pin), Responder.

Ejemplo Combinado

Vamos a modificar nuestro ejemplo anterior para usar ambos lados.

.swipeActions(edge: .leading, allowsFullSwipe: false) {
    Button {
        toggleReadStatus(email)
    } label: {
        Label(email.isRead ? "No leído" : "Leído", 
              systemImage: email.isRead ? "envelope.badge" : "envelope.open")
    }
    .tint(.blue)
}
.swipeActions(edge: .trailing) {
    Button(role: .destructive) {
        delete(email)
    } label: {
        Label("Eliminar", systemImage: "trash")
    }
}

Nota importante: Puedes encadenar múltiples modificadores .swipeActions en la misma vista, siempre y cuando especifiques edge diferentes.


5. El Poder de allowsFullSwipe

El parámetro allowsFullSwipe es potente pero peligroso si no se diseña bien.

  • True: Permite al usuario deslizar con fuerza todo el camino a través de la pantalla. Esto dispara la acción más externa (la primera en el código) automáticamente.
    • Uso ideal: Eliminar, Archivar. Acciones que el usuario realiza repetitivamente y quiere hacer rápido.
  • False: El usuario puede deslizar para revelar los botones, pero si sigue deslizando, la fila rebota y no ejecuta nada. Debe tocar explícitamente el botón.
    • Uso ideal: Acciones irreversibles peligrosas (si no hay papelera de reciclaje) o acciones que requieren una decisión consciente, como “Más opciones”.

6. Caso de Estudio Avanzado: Gestor de Tareas

Vamos a construir algo más complejo. Un gestor de tareas donde las acciones cambian dinámicamente según el estado de la tarea y manejamos la lógica de forma segura.

El Modelo

struct ToDoItem: Identifiable {
    let id = UUID()
    var title: String
    var isCompleted: Bool
    var isFlagged: Bool
}

El ViewModel (Simulado)

Para mantener el código limpio, separamos la lógica.

class ToDoViewModel: ObservableObject {
    @Published var items: [ToDoItem] = [
        ToDoItem(title: "Comprar leche", isCompleted: false, isFlagged: false),
        ToDoItem(title: "Llamar al médico", isCompleted: true, isFlagged: true),
        ToDoItem(title: "Escribir tutorial de SwiftUI", isCompleted: false, isFlagged: true)
    ]
    
    func toggleComplete(item: ToDoItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].isCompleted.toggle()
        }
    }
    
    func toggleFlag(item: ToDoItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].isFlagged.toggle()
        }
    }
    
    func delete(item: ToDoItem) {
        items.removeAll(where: { $0.id == item.id })
    }
}

La Vista Compleja con Lógica Condicional

Aquí es donde .swipeActions brilla. Podemos usar bloques if/else dentro del ViewBuilder de las acciones.

struct AdvancedToDoList: View {
    @StateObject private var viewModel = ToDoViewModel()

    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.items) { item in
                    HStack {
                        Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
                            .foregroundColor(item.isCompleted ? .green : .gray)
                        
                        Text(item.title)
                            .strikethrough(item.isCompleted)
                        
                        Spacer()
                        
                        if item.isFlagged {
                            Image(systemName: "flag.fill")
                                .foregroundColor(.orange)
                        }
                    }
                    // ACCIONES A LA DERECHA (Trailing)
                    .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                        // Acción 1: Borrar (Siempre disponible)
                        Button(role: .destructive) {
                            withAnimation {
                                viewModel.delete(item: item)
                            }
                        } label: {
                            Label("Borrar", systemImage: "trash")
                        }
                        
                        // Acción 2: Bandera (Cambia texto y color según estado)
                        Button {
                            withAnimation {
                                viewModel.toggleFlag(item: item)
                            }
                        } label: {
                            Label(item.isFlagged ? "Quitar Bandera" : "Abanderar", 
                                  systemImage: item.isFlagged ? "flag.slash" : "flag")
                        }
                        .tint(.orange)
                    }
                    // ACCIONES A LA IZQUIERDA (Leading)
                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
                        Button {
                            withAnimation {
                                viewModel.toggleComplete(item: item)
                            }
                        } label: {
                            Label(item.isCompleted ? "Reabrir" : "Completar", 
                                  systemImage: item.isCompleted ? "arrow.uturn.backward" : "checkmark")
                        }
                        .tint(item.isCompleted ? .gray : .green)
                    }
                }
            }
            .navigationTitle("Mis Tareas")
        }
    }
}

Detalles Técnicos del Ejemplo

  1. Condicionales dentro de SwipeActions: Observa cómo el texto y el icono del botón “Bandera” cambian dinámicamente (item.isFlagged ? ... : ...). SwiftUI repinta las acciones si el estado cambia, permitiendo interfaces reactivas.
  2. Animaciones: Envolver las llamadas al ViewModel en withAnimation asegura que, si la fila cambia de apariencia (como el tachado de texto), la transición sea suave, aunque la animación del swipe en sí es manejada por el sistema.
  3. Gestión de Color: El botón de “Completar” cambia de verde a gris dependiendo de si la tarea ya está hecha.

7. Limitaciones y Consideraciones de Diseño

Aunque .swipeActions es robusto, tiene limitaciones que un desarrollador senior debe conocer:

1. No se puede personalizar la fuente o el estilo del texto

Los botones dentro de swipeActions ignoran modificadores como .font() o .foregroundColor() aplicados al texto directamente. Debes confiar en .tint() para el fondo y el sistema se encarga del color del texto (generalmente blanco).

2. Estilo de Botón

Solo el estilo predeterminado funciona correctamente. No intentes incrustar vistas complejas (VStack, imágenes personalizadas grandes) dentro del label del botón. El sistema espera LabelText o Image. Si pones una vista personalizada compleja, es probable que no se renderice como esperas o que pierda interactividad.

3. Rendimiento en Listas Grandes

SwiftUI es eficiente, pero generar muchas acciones complejas en listas de miles de elementos puede tener un costo. Mantén la lógica dentro del bloque swipeActions lo más ligera posible. Evita cálculos pesados al renderizar el botón.

4. Accesibilidad

SwiftUI maneja gran parte de la accesibilidad automáticamente. VoiceOver anunciará “Acciones disponibles” en la fila. Sin embargo, asegúrate de que tus etiquetas (Label) sean descriptivas. Evita usar solo iconos si el significado no es universalmente claro.


8. Solución de Problemas Comunes

Problema: Las acciones de deslizamiento no aparecen. Solución: Asegúrate de que la fila está dentro de una List.swipeActions no funciona en VStack dentro de un ScrollView normal (a menos que implementes gestos personalizados complejos).

Problema: El gesto interfiere con otros gestos de navegación. Solución: Si tienes un NavigationLink en la fila, SwiftUI generalmente maneja bien el conflicto. Pero evita poner otros elementos con gestos de arrastre (drag gestures) dentro de la misma fila.

Problema: Quiero un fondo transparente o diferente para el botón. Solución: Actualmente, .tint es tu única opción nativa para el color de fondo. No hay API nativa para transparencia total o gradientes complejos en el fondo del botón de acción en iOS 16/17 (a la fecha de este artículo).


9. Comparación con Soluciones de Terceros

Antes de iOS 15, librerías como SwipeCellKit (UIKit) o soluciones personalizadas en SwiftUI eran la norma.

  • ¿Debes seguir usándolas? Probablemente no, a menos que necesites un diseño visual extremadamente específico (como botones circulares o animaciones de revelado personalizadas) que la API nativa no permite.
  • Beneficio de lo Nativo: La API de Apple garantiza que la física del deslizamiento (la fricción, el rebote) se sienta exactamente igual que en el resto del sistema operativo, lo cual es vital para una buena experiencia de usuario (UX).

10. Conclusión y Resumen

El modificador .swipeActions() es una herramienta esencial en el arsenal de cualquier desarrollador SwiftUI moderno. Nos permite limpiar la interfaz de usuario ocultando acciones secundarias detrás de gestos intuitivos, manteniendo la pantalla principal enfocada en el contenido.

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 añadir botones a una toolbar en SwiftUI

Next Article

Explorando el framework Foundation Models en SwiftUI

Related Posts