Skip to content
Todos los artículos

Reordenar una lista en SwiftUI con iOS 27: reorderable() y swipe actions

Arrastra para reordenar listas, pilas y cuadrículas de SwiftUI en iOS 27 con reorderable() y reorderContainer, y usa swipe actions fuera de List en iPhone e iPhone Duo.

Por la redacción de AppleDuoPublicado 7 min de lectura

También disponible en:EnglishDeutschFrançais日本語Türkçe

En esta página
  1. ¿Cómo reordeno una lista de SwiftUI en iOS 27?
  2. ¿Cómo actualiza mis datos el closure de reordenación?
  3. ¿En qué se diferencia reorderable() de onMove y EditButton?
  4. ¿Puedo usar swipe actions fuera de una List en iOS 27?
  5. ¿Están disponibles estas API en versiones anteriores de iOS?
  6. ¿Cómo ayuda esto en iPhone Duo?
  7. Puntos clave

Durante años, la respuesta a «¿cómo dejo que los usuarios reordenen una lista en SwiftUI?» fue siempre la misma: añadir .onMove a un ForEach dentro de una List, poner un EditButton y traducir los desplazamientos de índice a tu modelo. Solo funcionaba en List y venía con el modo edición. Esta guía explica cómo reordenar una lista en SwiftUI con la nueva API de iOS 27, que funciona en cualquier contenedor y no necesita ninguna de las dos cosas.

iOS 27 también elimina otra limitación exclusiva de List: las swipe actions. Ambos cambios llegan con la WWDC26 y están documentados en la referencia de SwiftUI de Apple. Este artículo se basa en esa documentación, en la sesión de Apple Code-along: Build powerful drag and drop in SwiftUI y en publicaciones del desarrollador iOS @sarunw. Estas API son nuevas en iOS 27.0, y los detalles pueden cambiar entre versiones.

¿Cómo reordeno una lista de SwiftUI en iOS 27?

Necesitas dos modificadores. Como resume @sarunw, iOS 27 da a ForEach su propio arrastre para reordenar: márcalo con .reorderable() y aplica .reorderContainer a la List. «Sin .onMove. Sin modo edición».

  • reorderable() va en el ForEach (cualquier DynamicViewContent) cuyas vistas pueden moverse.
  • reorderContainer(for:isEnabled:move:) va en el contenedor y define el ámbito del arrastre. Le pasas el tipo de tus elementos y un closure move que recibe el cambio.
struct TaskListView: View {
    @State private var tasks: [TaskItem] = TaskItem.samples

    var body: some View {
        List {
            ForEach(tasks) { task in
                TaskRow(task: task)
            }
            .reorderable()
        }
        .reorderContainer(for: TaskItem.self) { difference in
            tasks.apply(difference)
        }
    }
}

TaskItem es Identifiable, y la guía de Apple pide un tipo de ID que sea Hashable y Sendable. La llamada a apply(_:) es un helper nuestro, que se muestra en la siguiente sección; SwiftUI no documenta ninguno para arrays.

Apple describe la propia interacción: el usuario levanta una vista con un gesto de arrastre, el sistema coloca un marcador donde puede soltarse, el marcador sigue al arrastre y, al soltar, el sistema llama a move. No escribes código de gestos ni animaciones.

El contenedor no tiene por qué ser una List. La documentación de Apple dice que puede ser una lista, una pila, una cuadrícula o un diseño personalizado. El propio ejemplo de Apple aplica .reorderContainer a un VStack simple, y la sesión de la WWDC26 usa un HStack de cartas.

¿Cómo actualiza mis datos el closure de reordenación?

El closure move recibe un ReorderDifference. Describe el cambio por identidad, no por índice:

PropiedadTipoSignificado
sources[ItemID]ID de los elementos movidos, en el orden en que el usuario los seleccionó
destination.position.before(_:) o .endInsertar antes del elemento con ese ID o añadir al final
destination.collectionIDTu tipo de ID de colecciónQué sección recibe los elementos (contenedores con varias colecciones)

Trabajar con ID tiene una ventaja práctica. La guía de Apple señala que puedes mover los elementos «aunque se produzcan a la vez otros cambios en el array, como una sincronización con la nube», porque un ID sigue apuntando al elemento correcto después de que cambie el array. Un IndexSet de .onMove no lo hace.

Esta es una forma de aplicar a un array una diferencia de una sola colección. Elimina los elementos movidos y los vuelve a insertar, en el orden de selección, en el destino:

@available(iOS 27.0, *)
extension Array where Element: Identifiable, Element.ID: Sendable {
    mutating func apply(
        _ difference: ReorderDifference<Element.ID, ReorderableSingleCollectionIdentifier>
    ) {
        let moved = difference.sources.compactMap { id in
            first { $0.id == id }
        }
        removeAll { difference.sources.contains($0.id) }

        switch difference.destination.position {
        case .before(let id):
            let index = firstIndex { $0.id == id } ?? endIndex
            insert(contentsOf: moved, at: index)
        case .end:
            append(contentsOf: moved)
        }
    }
}

El tipo genérico coincide con la sobrecarga de una sola colección, cuyo closure recibe ReorderDifference<Item.ID, ReorderableSingleCollectionIdentifier>. Toma el helper como punto de partida y pruébalo con tu modelo.

Reordenar entre secciones

Para datos con secciones, da a cada ForEach un identificador con reorderable(collectionID:) y usa reorderContainer(for:in:isEnabled:move:) con el tipo que identifica las secciones. Así los usuarios pueden mover elementos dentro de una sección y entre secciones, y destination.collectionID te indica cuál los ha recibido.

List {
    ForEach(model.sections) { section in
        Section(section.name) {
            ForEach(section.reminders) { reminder in
                ReminderView(reminder)
            }
            .reorderable(collectionID: section.id)
        }
    }
}
.reorderContainer(for: Reminder.self, in: ReminderModel.Section.ID.self) { difference in
    model.apply(difference: difference)
}

Esto reproduce el ejemplo de la documentación de Apple; model.apply(difference:) es código de tu modelo. Si tu ForEach usa un ID distinto de id, las sobrecargas con itemID: aceptan un key path, como \.databaseID.

¿En qué se diferencia reorderable() de onMove y EditButton?

El patrón antiguo sigue funcionando. Conviene saber exactamente qué cambia antes de migrar:

.onMove + EditButton.reorderable() + .reorderContainer
Introducido eniOS 13iOS 27.0
ContenedoresListLista, pila, cuadrícula o diseño personalizado
Cómo empiezaAsas de arrastre, normalmente en modo ediciónGesto de arrastre del sistema
Entrada del closureDesplazamientos (IndexSet, Int)ReorderDifference con ID de elementos
Mover entre seccionesCada ForEach tiene su propio onMovereorderable(collectionID:), un solo closure
Varios elementosNo se describe en la documentaciónOrígenes enumerados en orden de selección
DesactivarloPasar nil a onMoveisEnabled: false
Arrastrar desde otras appsonInsert o dropDestination aparteCombinar con dropDestination y reorderDestination

La veterana guía de Hacking with Swift muestra la configuración clásica: onMove(perform:) en el ForEach, más un EditButton en la barra de herramientas para que aparezcan las asas de arrastre. La documentación de onMove de Apple describe los argumentos de su closure como desplazamientos en la colección subyacente, y por eso el código antiguo suele terminar con move(fromOffsets:toOffset:).

El parámetro isEnabled resulta útil en apps reales. La guía de Apple sugiere desactivar la reordenación mientras sincronizas, por ejemplo isEnabled: !model.isSaving.

La sesión añade un consejo de diseño a partir de su ejemplo del Solitario: para mantener fijos algunos elementos, sepáralos en un segundo ForEach sin .reorderable(). Solo se puede levantar el contenido que marcas. La documentación de Apple no describe cómo combinar .onMove y .reorderable() en el mismo ForEach, así que elige uno.

¿Puedo usar swipe actions fuera de una List en iOS 27?

Sí. @sarunw lo resume así: «Las swipe actions necesitaban una fila de List. En iOS 27, cualquier vista puede tenerlas». La pieza que lo hace posible es swipeActionsContainer().

Sigues escribiendo .swipeActions en cada fila, igual que en una List. Después aplicas .swipeActionsContainer() al ScrollView, LazyVStack u otro contenedor que albergue las filas:

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item)
                .swipeActions {
                    Button("Delete", role: .destructive) {
                        delete(item)
                    }
                }
        }
    }
}
.swipeActionsContainer()

La documentación de Apple enumera lo que coordina el contenedor:

  • Solo se muestran las swipe actions de una fila a la vez.
  • Desplazar el contenedor cierra las acciones abiertas.
  • Tocar fuera de la fila activa cierra sus acciones.

List ya hace todo esto, así que Apple dice que aplicar el modificador a una List no tiene ningún efecto. El artículo de Majid Jabrayilov llega a la misma regla: todo lo que no sea List necesita el modificador de contenedor.

iOS 27 también añade swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:). Su closure recibe true cuando aparecen las acciones de una fila y false cuando se cierran. Apple sugiere usarlo para atenuar la fila o actualizar la interfaz que la rodea mientras se muestran las acciones.

Problemas conocidos que conviene comprobar

Dos informes de desarrolladores en los foros de Apple merecen una prueba en tu propia compilación. Uno dice que en diseños de derecha a izquierda las acciones aparecen en el borde reflejado, pero el gesto sigue esperando la dirección de izquierda a derecha. El otro dice que, fuera de List, el contenido de las swipe actions no se vuelve a evaluar cuando cambia el estado, así que un título Pin puede no cambiar a Unpin hasta que se recrea la fila. En el momento de escribir esto, ninguno de los dos hilos tenía respuesta de Apple.

¿Están disponibles estas API en versiones anteriores de iOS?

No. reorderable(), todas las sobrecargas de reorderContainer, swipeActionsContainer() y la sobrecarga con onPresentationChanged están documentadas como disponibles desde iOS 27.0, iPadOS 27.0, Mac Catalyst 27.0, macOS 27.0, visionOS 27.0 y watchOS 27.0. La sesión de la WWDC26 describe las API de reordenación como «disponibles ahora en todas las plataformas de Apple que admiten arrastrar y soltar».

Si das soporte a iOS 26 o anterior, bifurca con #available y conserva el camino antiguo:

@ViewBuilder
private var taskList: some View {
    if #available(iOS 27.0, *) {
        List {
            ForEach(tasks) { TaskRow(task: $0) }
                .reorderable()
        }
        .reorderContainer(for: TaskItem.self) { tasks.apply($0) }
    } else {
        List {
            ForEach(tasks) { TaskRow(task: $0) }
                .onMove { tasks.move(fromOffsets: $0, toOffset: $1) }
        }
        .toolbar { EditButton() }
    }
}

En versiones anteriores, las swipe actions fuera de List no tienen alternativa; Nil Coalescing señala que no tenían ningún efecto en filas dentro de un LazyVStack o una cuadrícula antes de iOS 27. En esas versiones, mantén esas filas en una List u ofrece las mismas acciones en un menú contextual.

¿Cómo ayuda esto en iPhone Duo?

En iPhone Duo, la pantalla interior informa una size class horizontal regular, como un iPad, mientras que la pantalla exterior sigue siendo compacta (consulta nuestra guía sobre las size classes de iPhone Duo). Muchas apps aprovechan ese ancho extra con una cuadrícula o un diseño personalizado en lugar de una List de una sola columna.

Hasta iOS 27, esa decisión te costaba funciones. Una LazyVGrid de tarjetas no tenía reordenación integrada ni swipe actions, así que pasar a una cuadrícula en la pantalla interior obligaba a reconstruir ambas a mano. Ahora el mismo código con .reorderable() y .swipeActions funciona tanto si un cambio de size class muestra una List en la pantalla exterior como una cuadrícula en la interior:

let columns = [GridItem(.adaptive(minimum: 220))]

ScrollView {
    LazyVGrid(columns: columns) {
        ForEach(boards) { board in
            BoardCard(board: board)
        }
        .reorderable()
    }
}
.reorderContainer(for: Board.self) { boards.apply($0) }

La guía de Apple señala que la reordenación solo cubre los movimientos dentro del contenedor. Arrastrar elementos hacia otras partes de tu app, o desde otras apps, es un paso aparte con dragContainer(for:in:_:) y dropDestination(for:isEnabled:action:). Si la pantalla interior muestra dos paneles uno junto a otro con ArrangementView, nuestra guía de ArrangementView explica la división en sí.

Puntos clave

  • En iOS 27, .reorderable() en un ForEach más .reorderContainer(for:) en su contenedor te dan arrastrar para reordenar sin modo edición y sin .onMove.
  • El contenedor puede ser una List, una pila, una cuadrícula o un diseño personalizado.
  • El closure move recibe un ReorderDifference: los ID movidos en sources y un destination con .before(_:) o .end. Aplícalo tú mismo a tu array.
  • Usa reorderable(collectionID:) y reorderContainer(for:in:) para mover elementos entre secciones.
  • Las swipe actions ya funcionan fuera de List; añade .swipeActionsContainer() al ScrollView o al diseño.
  • Todo lo que se explica aquí requiere iOS 27.0 o posterior, así que protégelo con #available y conserva .onMove como alternativa.

Las listas son solo la mitad de una interfaz cuidada; el texto dentro de las filas también importa. A continuación, lee nuestra guía sobre la altura de línea en SwiftUI con iOS 26 y visita Built for the fold para ver apps y juegos que ya sacan el máximo partido a iPhone Duo.

Preguntas frecuentes

¿Cómo reordeno una List de SwiftUI en iOS 27 sin modo edición?

Aplica .reorderable() al ForEach dentro de la List y .reorderContainer(for:) a la propia List, pasando el tipo de tus elementos. Así los usuarios pueden arrastrar las filas directamente, y el closure move te da un ReorderDifference que aplicas a tus datos. No hace falta EditButton ni onMove.

¿Funciona reorderable() en LazyVStack y LazyVGrid?

Sí. La documentación de Apple dice que un contenedor reordenable puede ser una lista, una pila, una cuadrícula o un diseño personalizado. Aplica .reorderContainer a ese contenedor y .reorderable() al ForEach que contiene.

¿Qué es ReorderDifference en SwiftUI?

Es el valor que recibe el closure move al terminar un arrastre. Su propiedad sources enumera los ID de los elementos movidos en el orden en que el usuario los seleccionó, y su destination indica adónde van: antes de un elemento concreto o al final de una colección.

¿Puedo usar swipe actions fuera de una List en iOS 27?

Sí. Añade .swipeActions a cada fila como siempre y aplica .swipeActionsContainer() al ScrollView o al diseño que las contiene. El contenedor garantiza que solo haya una fila abierta a la vez y que desplazarse o tocar en otro sitio la cierre.

¿Están disponibles reorderable() y swipeActionsContainer() en iOS 26?

No. Ambos están documentados como disponibles desde iOS 27.0, así que las apps que admiten versiones anteriores necesitan una comprobación if #available con una alternativa como onMove en una List.

Fuentes

  1. reorderable() · Apple Developer Documentation
  2. reorderContainer(for:isEnabled:move:) · Apple Developer Documentation
  3. reorderContainer(for:in:isEnabled:move:) · Apple Developer Documentation
  4. Reordering items in lists, stacks, grids, and custom layouts · Apple Developer Documentation
  5. ReorderDifference · Apple Developer Documentation
  6. onMove(perform:) · Apple Developer Documentation
  7. swipeActionsContainer() · Apple Developer Documentation
  8. swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:) · Apple Developer Documentation
  9. Code-along: Build powerful drag and drop in SwiftUI (WWDC26) · Apple Developer
  10. swipeActionsContainer() does not mirror the swipe gesture in right-to-left layouts · Apple Developer Forums
  11. iOS 27: swipeActions outside List never re-evaluates its content closure · Apple Developer Forums
  12. iOS 27 gives ForEach its own drag to reorder: .reorderable() and .reorderContainer · @sarunw on X
  13. No .onMove. No edit mode. · @sarunw on X
  14. Swipe actions used to need a List row; in iOS 27 any view can have them · @sarunw on X
  15. New SwiftUI APIs for reordering and drag and drop on iOS 27 · Nil Coalescing
  16. Custom scroll layouts with swipe actions in SwiftUI on iOS 27 · Nil Coalescing
  17. Swipe actions outside of List in SwiftUI · Swift with Majid
  18. How to let users move rows in a list · Hacking with Swift

Haz que el pliegue forme parte de tu app.

Mostramos apps y juegos que sacan partido de verdad al iPhone Duo. Envíanos la tuya con un enlace al App Store, o mira lo que han creado otros desarrolladores.

[email protected]