Desarrolladores
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
En esta página
- ¿Cómo reordeno una lista de SwiftUI en iOS 27?
- ¿Cómo actualiza mis datos el closure de reordenación?
- ¿En qué se diferencia reorderable() de onMove y EditButton?
- ¿Puedo usar swipe actions fuera de una List en iOS 27?
- ¿Están disponibles estas API en versiones anteriores de iOS?
- ¿Cómo ayuda esto en iPhone Duo?
- 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 elForEach(cualquierDynamicViewContent) 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 closuremoveque 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:
| Propiedad | Tipo | Significado |
|---|---|---|
sources | [ItemID] | ID de los elementos movidos, en el orden en que el usuario los seleccionó |
destination.position | .before(_:) o .end | Insertar antes del elemento con ese ID o añadir al final |
destination.collectionID | Tu tipo de ID de colección | Qué 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 en | iOS 13 | iOS 27.0 |
| Contenedores | List | Lista, pila, cuadrícula o diseño personalizado |
| Cómo empieza | Asas de arrastre, normalmente en modo edición | Gesto de arrastre del sistema |
| Entrada del closure | Desplazamientos (IndexSet, Int) | ReorderDifference con ID de elementos |
| Mover entre secciones | Cada ForEach tiene su propio onMove | reorderable(collectionID:), un solo closure |
| Varios elementos | No se describe en la documentación | Orígenes enumerados en orden de selección |
| Desactivarlo | Pasar nil a onMove | isEnabled: false |
| Arrastrar desde otras apps | onInsert o dropDestination aparte | Combinar 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 unForEachmá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
moverecibe unReorderDifference: los ID movidos ensourcesy undestinationcon.before(_:)o.end. Aplícalo tú mismo a tu array. - Usa
reorderable(collectionID:)yreorderContainer(for:in:)para mover elementos entre secciones. - Las swipe actions ya funcionan fuera de
List; añade.swipeActionsContainer()alScrollViewo al diseño. - Todo lo que se explica aquí requiere iOS 27.0 o posterior, así que protégelo con
#availabley conserva.onMovecomo 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
- reorderable() · Apple Developer Documentation
- reorderContainer(for:isEnabled:move:) · Apple Developer Documentation
- reorderContainer(for:in:isEnabled:move:) · Apple Developer Documentation
- Reordering items in lists, stacks, grids, and custom layouts · Apple Developer Documentation
- ReorderDifference · Apple Developer Documentation
- onMove(perform:) · Apple Developer Documentation
- swipeActionsContainer() · Apple Developer Documentation
- swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:) · Apple Developer Documentation
- Code-along: Build powerful drag and drop in SwiftUI (WWDC26) · Apple Developer
- swipeActionsContainer() does not mirror the swipe gesture in right-to-left layouts · Apple Developer Forums
- iOS 27: swipeActions outside List never re-evaluates its content closure · Apple Developer Forums
- iOS 27 gives ForEach its own drag to reorder: .reorderable() and .reorderContainer · @sarunw on X
- No .onMove. No edit mode. · @sarunw on X
- Swipe actions used to need a List row; in iOS 27 any view can have them · @sarunw on X
- New SwiftUI APIs for reordering and drag and drop on iOS 27 · Nil Coalescing
- Custom scroll layouts with swipe actions in SwiftUI on iOS 27 · Nil Coalescing
- Swipe actions outside of List in SwiftUI · Swift with Majid
- 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.