wandres.dev
LISTAS Y COLECCIONES · rendimiento y edición

Interacción: swipe, selección, edición y reordenar

Las acciones al deslizar, la selección simple y múltiple, el modo de edición como estado del entorno y la reordenación de filas. Qué se declara sobre ForEach y qué sobre List, por qué la selección exige tipos coherentes de identificador, y por qué mover una fila obliga a que el orden viva en el modelo.

⏱ 19 min

Una lista deja de ser un escaparate en cuanto el usuario puede actuar sobre ella, y ahí List devuelve con creces lo que cobra: deslizar para borrar, seleccionar varios elementos, entrar en modo de edición y arrastrar filas son cuatro mecánicas que la plataforma ya trae resueltas con sus gestos, sus tiempos y su accesibilidad. El precio de acceder a ellas es respetar dónde se declara cada cosa y entender que el modo de edición no es un booleano de tu vista sino un valor del entorno que atraviesa toda la jerarquía. Y hay una trampa final que no es de interfaz: reordenar visualmente no reordena nada si el orden no está guardado en algún sitio.

🎯 Al terminar esta lección sabrás
  • Declarar acciones al deslizar en ambos bordes y controlar el deslizamiento completo.
  • Distinguir qué se declara sobre ForEach y qué sobre List, y por qué esa asimetría existe.
  • Manejar selección simple y múltiple con tipos de identificador coherentes.
  • Tratar el modo de edición como estado del entorno y persistir el orden tras reordenar.

Deslizar como superficie de acciones

Las acciones al deslizar se declaran sobre la fila y pueden vivir en los dos bordes. El borde final es el destino tradicional de las acciones destructivas; el inicial se reserva para acciones frecuentes y reversibles. El papel destructivo no es decorativo: colorea el botón, ajusta la semántica de accesibilidad y activa el comportamiento de deslizamiento completo.

FilaTarea(tarea: tarea)
    .swipeActions(edge: .trailing, allowsFullSwipe: true) {
        Button(role: .destructive) {
            borrar(tarea)
        } label: {
            Label("Borrar", systemImage: "trash")
        }
        Button {
            posponer(tarea)
        } label: {
            Label("Posponer", systemImage: "clock")
        }
        .tint(.orange)
    }
    .swipeActions(edge: .leading) {
        Button {
            marcarHecha(tarea)
        } label: {
            Label("Hecha", systemImage: "checkmark")
        }
        .tint(.green)
    }

Dos detalles que conviene tener presentes desde el principio. El primero es que el deslizamiento completo dispara la primera acción declarada en ese borde, así que el orden de los botones es una decisión de seguridad y no de estética: nunca pongas primera una acción irreversible si el deslizamiento completo está activo. El segundo es que declarar acciones propias sustituye el comportamiento por defecto que aporta el borrado integrado, de modo que si querías conservar ese borrado tendrás que incluirlo tú entre los botones.

⚠️
Tres o más botones no caben en la mano

El área disponible al deslizar depende del ancho de pantalla, del tamaño de texto y del idioma. Con etiquetas largas o texto grande, el tercer botón queda ilegible o inalcanzable. Como norma, dos acciones por borde y siempre con icono además de texto; lo demás va a un menú contextual, que además funciona igual con navegación por teclado y con lector de pantalla.

Selección y modo de edición

Aquí aparece la asimetría que causa más confusión del nivel. El borrado y el movimiento se declaran sobre ForEach, porque son operaciones sobre la colección que ese bucle representa. La selección se declara sobre List, porque es un estado de la lista completa. No es un capricho de la API: el ForEach es el único que sabe a qué colección aplicar los índices de una eliminación o de un desplazamiento.

struct ListaTareas: View {
    @State private var tareas: [Tarea]
    @State private var seleccion = Set<Tarea.ID>()
    @Environment(\.editMode) private var editMode

    var body: some View {
        List(selection: $seleccion) {
            ForEach(tareas) { FilaTarea(tarea: $0) }
                .onDelete { indices in tareas.remove(atOffsets: indices) }
                .onMove { origen, destino in
                    tareas.move(fromOffsets: origen, toOffset: destino)
                }
        }
        .toolbar {
            EditButton()
            if editMode?.wrappedValue.isEditing == true {
                Button("Borrar seleccion") { borrar(seleccion) }
                    .disabled(seleccion.isEmpty)
            }
        }
    }
}
👉

Sobre la fila

Acciones al deslizar, menú contextual, desactivar movimiento o borrado. Todo lo que es propiedad de un elemento concreto.

🔁

Sobre el ForEach

Borrado y movimiento. Solo el bucle sabe a qué colección aplicar los índices que el sistema entrega.

📋

Sobre la List

La selección, porque es estado de la lista entera y no de ninguna fila en particular.

🌍

En el entorno

El modo de edición. Se lee y se escribe desde el entorno para que barra, gestos e interfaz propia digan lo mismo.

El tipo de la selección tiene que coincidir exactamente con el tipo del identificador que usa el ForEach. Un conjunto de identificadores para selección múltiple, un opcional para selección simple. Si los tipos no encajan, la lista compila y la selección simplemente no ocurre nunca, que es la peor forma posible de fallar. En iPhone la selección solo está activa dentro del modo de edición; en iPad y Mac también fuera de él, donde suele usarse para gobernar el panel de detalle.

Merece la pena insistir en que el borrado y el movimiento se aplican al bucle completo y no a una fila: los índices que el sistema entrega son posiciones dentro de esa colección, no identificadores. Si la lista muestra una versión filtrada u ordenada de otra colección, aplicar esos índices a la colección original borra el elemento equivocado. La regla segura es traducir siempre índices a identificadores antes de mutar la fuente real.

El modo de edición es un valor del entorno con tres estados, y leerlo desde el entorno en lugar de mantener un booleano propio es lo que mantiene sincronizados el botón de la barra, el gesto de arrastre, la aparición de los círculos de selección y tu propia interfaz condicional. Puedes además escribirlo para entrar y salir del modo de forma programática, algo necesario cuando la edición se dispara desde un menú o al terminar una operación por lotes.

stateDiagram-v2
[*] --> Inactivo
Inactivo --> Transitorio: gesto de deslizar sobre una fila
Transitorio --> Inactivo: se completa o se cancela la accion
Inactivo --> Activo: pulsar el boton de edicion
Activo --> Inactivo: pulsar terminado
Activo --> Activo: seleccionar y mover filas
note right of Transitorio
  Estado efimero de una sola fila
  No muestra circulos de seleccion
end note

Reordenar: el orden tiene que vivir en el modelo

Mover una fila produce una animación convincente y una mentira peligrosa. La operación de movimiento reordena la colección que está en memoria; si esa colección se reconstruye desde una fuente que impone su propio criterio de ordenación, el siguiente refresco devolverá todo a su sitio original y el usuario verá cómo su trabajo se deshace solo.

.onMove { origen, destino in
    var ordenadas = tareas.sorted { $0.posicion < $1.posicion }
    ordenadas.move(fromOffsets: origen, toOffset: destino)
    for (indice, tarea) in ordenadas.enumerated() {
        tarea.posicion = indice          // el orden nuevo se escribe en el modelo
    }
    try? contexto.save()
}

La solución estructural es guardar el orden como un dato del dominio: un campo de posición en cada elemento, actualizado tras el movimiento y usado como criterio de ordenación al leer. Con almacenamiento persistente y consultas ordenadas, ese campo es obligatorio; sin él, no hay reordenación posible por mucho que la interfaz la simule. Reasignar posiciones consecutivas tras cada movimiento es lo más simple y suficiente para listas humanas; los esquemas de posiciones fraccionarias solo compensan cuando el coste de reescribir muchas filas importa de verdad.

Hay una consideración de accesibilidad que no es opcional y que se olvida sistemáticamente: un gesto de deslizamiento no existe para quien navega con lector de pantalla o con teclado. Las acciones al deslizar se exponen como acciones personalizadas del elemento y la reordenación como un ajuste, siempre que las declares con la API de la lista en lugar de reconstruirlas con reconocedores de gestos propios. Duplicar cada acción destructiva en un menú contextual es, además de accesible, la forma más simple de que la función siga siendo alcanzable en pantallas grandes y con ratón.

También conviene decidir qué filas no deben poder moverse ni borrarse. Los modificadores que desactivan el movimiento y el borrado se aplican por fila, y sirven tanto para elementos fijados como para casos en los que la operación no tiene sentido en ese contexto. Desactivarlos es preferible a permitirlos y luego ignorar la acción, porque la interfaz deja de prometer algo que no va a cumplir.

El gesto no es la operación: es una propuesta que el modelo debe aceptar

La confusión que produce casi todos los fallos de esta lección es tratar la interacción como si fuera la mutación. Deslizar, seleccionar y arrastrar no cambian nada por sí mismos: son maneras que tiene el usuario de proponer una intención, y el sistema las traduce a una llamada a tu código con la información mínima para ejecutarla. Si esa llamada no persiste el efecto donde vive la verdad, la interfaz habrá contado una historia que los datos no respaldan, y el desajuste aparecerá más tarde, en otro sitio y sin relación aparente con el gesto que lo causó. Por eso la pregunta correcta ante cada interacción no es cómo se declara sino dónde queda registrada su consecuencia, y por eso la reordenación es el caso didáctico perfecto: es la única de las cuatro donde el estado modificado no tiene ningún sitio natural donde guardarse a menos que lo hayas diseñado antes en el modelo. Hay una segunda lectura, más incómoda, para quien viene de construirlo todo a mano: la razón por la que estas mecánicas son tan buenas gratis es que llevan décadas de refinamiento en tiempos de gesto, umbrales, retroalimentación háptica y comportamiento con lector de pantalla. Reimplementarlas sobre una pila propia no cuesta lo que cuesta detectar un arrastre; cuesta todo lo que hay entre detectar un arrastre y que ese arrastre se sienta como los del sistema, que es exactamente la distancia que separa una app que parece nativa de una que solo lo aparenta en las capturas.

⚔️ Una lista completamente operable
  1. Añade acciones en los dos bordes con roles correctos y comprueba qué dispara el deslizamiento completo.
  2. Implementa borrado y movimiento sobre el ForEach y selección múltiple sobre la List, con tipos coherentes.
  3. Lee el modo de edición desde el entorno y muestra una barra de acciones por lotes solo cuando esté activo.
  4. Persiste el orden en un campo del modelo y verifica que sobrevive a cerrar y abrir la app.
  5. Desactiva el movimiento en una fila fijada y revisa toda la pantalla con el lector de pantalla activado.