Skip to content
Tous les articles

Réordonner une liste SwiftUI dans iOS 27 : reorderable() et actions de balayage

Réordonnez par glisser une liste, une pile ou une grille SwiftUI dans iOS 27 avec reorderable(), et ajoutez des actions de balayage hors de List, sur iPhone Duo.

Par la rédaction AppleDuoPublié le 8 min de lecture

Également disponible en:EnglishDeutschEspañol日本語Türkçe

Sur cette page
  1. Comment réordonner une liste SwiftUI dans iOS 27 ?
  2. Comment la closure de réordonnancement met-elle à jour mes données ?
  3. En quoi reorderable() diffère-t-il de onMove et EditButton ?
  4. Peut-on utiliser des actions de balayage hors d'une List dans iOS 27 ?
  5. Ces API sont-elles disponibles sur les versions antérieures d'iOS ?
  6. Quel est l'intérêt sur iPhone Duo ?
  7. Ce qu'il faut retenir

Pendant des années, la réponse à « comment permettre de réordonner une liste SwiftUI ? » n'a pas changé : attacher .onMove à un ForEach dans une List, ajouter un EditButton, puis reconvertir des décalages d'index vers votre modèle. Cela ne fonctionnait que dans une List, et imposait le mode édition. Ce guide explique comment réordonner une liste SwiftUI dans iOS 27 avec une API qui fonctionne dans n'importe quel conteneur et se passe des deux.

iOS 27 lève aussi une seconde limite propre à List : les actions de balayage. Les deux changements viennent de la WWDC26 et sont documentés dans la référence SwiftUI d'Apple. Cet article s'appuie sur cette documentation, sur la session Code-along: Build powerful drag and drop in SwiftUI d'Apple et sur les publications du développeur iOS @sarunw. Ces API sont nouvelles dans iOS 27.0, et leurs détails peuvent évoluer d'une version à l'autre.

Comment réordonner une liste SwiftUI dans iOS 27 ?

Il faut deux modificateurs. Comme le résume @sarunw, iOS 27 donne à ForEach son propre glisser pour réordonner : marquez-le .reorderable() et placez .reorderContainer sur la List. Plus de .onMove, plus de mode édition.

  • reorderable() se place sur le ForEach (tout DynamicViewContent) dont les vues peuvent être déplacées.
  • reorderContainer(for:isEnabled:move:) se place sur le conteneur et définit la portée du glissement. Vous lui passez votre type d'élément et une closure move qui reçoit la modification.
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 est Identifiable, et le guide d'Apple demande un type d'identifiant Hashable et Sendable. L'appel apply(_:) est notre propre fonction utilitaire, présentée dans la section suivante ; SwiftUI n'en documente aucune pour les tableaux.

Apple décrit l'interaction elle-même : la personne soulève une vue d'un geste de glissement, le système place un emplacement réservé là où elle peut la déposer, cet emplacement suit le glissement et, au dépôt, le système appelle move. Vous n'écrivez ni code de geste ni animation.

Le conteneur n'a pas besoin d'être une List. La documentation d'Apple précise qu'il peut s'agir d'une liste, d'une pile, d'une grille ou d'une mise en page personnalisée. L'exemple d'Apple applique .reorderContainer à un simple VStack, et la session de la WWDC26 utilise un HStack de cartes à jouer.

Comment la closure de réordonnancement met-elle à jour mes données ?

La closure move reçoit une ReorderDifference. Elle décrit la modification par identité, pas par index :

PropriétéTypeSignification
sources[ItemID]Identifiants des éléments déplacés, dans l'ordre de sélection
destination.position.before(_:) ou .endInsérer avant l'élément portant cet identifiant, ou ajouter à la fin
destination.collectionIDVotre type d'identifiant de collectionLa section qui reçoit les éléments (conteneurs à plusieurs collections)

Travailler avec des identifiants a un avantage concret. Le guide d'Apple note que vous pouvez déplacer les éléments même si d'autres modifications du tableau, comme une synchronisation avec le cloud, ont lieu en même temps, car un identifiant pointe toujours vers le bon élément après la modification du tableau. Ce n'est pas le cas d'un IndexSet fourni par .onMove.

Voici une façon d'appliquer une différence à collection unique à un tableau. Elle retire les éléments déplacés puis les réinsère, dans l'ordre de sélection, à la destination :

@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)
        }
    }
}

Le type générique correspond à la surcharge à collection unique, dont la closure prend ReorderDifference<Item.ID, ReorderableSingleCollectionIdentifier>. Considérez cette fonction comme un point de départ et testez-la avec votre modèle.

Réordonner entre sections

Pour des données en sections, donnez un identifiant à chaque ForEach avec reorderable(collectionID:), et utilisez reorderContainer(for:in:isEnabled:move:) avec le type qui identifie les sections. Les utilisateurs peuvent alors déplacer des éléments au sein d'une section et d'une section à l'autre, et destination.collectionID vous indique laquelle les a reçus.

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)
}

Cet exemple reprend celui de la documentation d'Apple ; model.apply(difference:) relève du code de votre modèle. Si votre ForEach utilise un identifiant autre que id, les surcharges itemID: acceptent un key path, comme \.databaseID.

En quoi reorderable() diffère-t-il de onMove et EditButton ?

L'ancien modèle fonctionne toujours. Mieux vaut savoir précisément ce qui change avant de migrer :

.onMove + EditButton.reorderable() + .reorderContainer
Introduit dansiOS 13iOS 27.0
ConteneursListListe, pile, grille ou mise en page personnalisée
DéclenchementPoignées de déplacement, en général en mode éditionGeste de glissement système
Entrée de la closureDécalages (IndexSet, Int)ReorderDifference avec identifiants d'éléments
Déplacement entre sectionsUn onMove par ForEachreorderable(collectionID:), une seule closure
Plusieurs élémentsNon décrit dans la documentationSources listées dans l'ordre de sélection
DésactivationPasser nil à onMoveisEnabled: false
Glisser depuis d'autres appsonInsert ou dropDestination séparéAssocier à dropDestination et reorderDestination

Le guide de longue date de Hacking with Swift montre la configuration classique : onMove(perform:) sur le ForEach, plus un EditButton dans la barre d'outils pour faire apparaître les poignées. La documentation d'Apple sur onMove décrit les arguments de sa closure comme des décalages dans la collection sous-jacente, d'où le move(fromOffsets:toOffset:) qui termine généralement l'ancien code.

Le paramètre isEnabled est pratique dans une vraie app. Le guide d'Apple suggère de désactiver le réordonnancement pendant une synchronisation, par exemple isEnabled: !model.isSaving.

La session ajoute un conseil de conception tiré de son exemple de Solitaire : pour garder certains éléments fixes, placez-les dans un second ForEach sans .reorderable(). Seul le contenu marqué peut être soulevé. La documentation d'Apple ne décrit pas la combinaison de .onMove et .reorderable() sur un même ForEach : choisissez l'un ou l'autre.

Peut-on utiliser des actions de balayage hors d'une List dans iOS 27 ?

Oui. @sarunw le résume simplement : les actions de balayage exigeaient auparavant une rangée de List ; dans iOS 27, n'importe quelle vue peut en avoir. L'élément qui rend cela possible est swipeActionsContainer().

Vous écrivez toujours .swipeActions sur chaque rangée, exactement comme dans une List. Puis vous appliquez .swipeActionsContainer() à la ScrollView, au LazyVStack ou à tout autre conteneur qui contient les rangées :

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

La documentation d'Apple liste ce que coordonne le conteneur :

  • Les actions de balayage d'une seule rangée sont révélées à la fois.
  • Faire défiler le conteneur referme les actions ouvertes.
  • Toucher en dehors de la rangée active referme ses actions.

List fait déjà tout cela ; Apple indique donc qu'appliquer le modificateur à une List n'a aucun effet. L'article de Majid Jabrayilov arrive à la même règle : tout ce qui n'est pas une List a besoin du modificateur de conteneur.

iOS 27 ajoute aussi swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:). Sa closure reçoit true quand les actions d'une rangée apparaissent et false quand elles sont refermées. Apple suggère de l'utiliser pour atténuer la rangée ou mettre à jour l'interface environnante pendant l'affichage des actions.

Problèmes connus à vérifier

Deux signalements de développeurs sur les forums d'Apple méritent d'être testés sur votre propre build. Le premier indique que, dans les mises en page de droite à gauche, les actions apparaissent sur le bord inversé mais le geste attend toujours le sens gauche-droite. Le second indique qu'en dehors de List, le contenu des actions de balayage n'est pas réévalué quand l'état change : un titre Pin peut donc ne pas devenir Unpin tant que la rangée n'est pas recréée. Aucun des deux fils n'avait de réponse d'Apple au moment de la rédaction.

Ces API sont-elles disponibles sur les versions antérieures d'iOS ?

Non. reorderable(), toutes les surcharges de reorderContainer, swipeActionsContainer() et la surcharge onPresentationChanged sont documentés comme disponibles à partir d'iOS 27.0, iPadOS 27.0, Mac Catalyst 27.0, macOS 27.0, visionOS 27.0 et watchOS 27.0. La session de la WWDC26 présente les API de réordonnancement comme disponibles sur toutes les plateformes Apple qui prennent en charge le glisser-déposer.

Si vous prenez en charge iOS 26 ou une version antérieure, séparez les chemins avec #available et conservez l'ancien :

@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() }
    }
}

Sur les versions plus anciennes, les actions de balayage hors de List n'ont pas de solution de repli ; Nil Coalescing note qu'elles n'avaient aucun effet sur les rangées d'un LazyVStack ou d'une grille avant iOS 27. Gardez ces rangées dans une List sur ces versions, ou proposez les mêmes actions dans un menu contextuel.

Quel est l'intérêt sur iPhone Duo ?

Sur iPhone Duo, l'écran intérieur signale une classe de taille horizontale regular, comme un iPad, tandis que l'écran extérieur reste compact (voir notre guide des classes de taille de l'iPhone Duo). Beaucoup d'apps exploitent cette largeur supplémentaire avec une grille ou une mise en page personnalisée plutôt qu'une List à une seule colonne.

Jusqu'à iOS 27, ce choix coûtait des fonctionnalités. Une LazyVGrid de cartes n'avait ni réordonnancement intégré ni actions de balayage : passer à une grille sur l'écran intérieur obligeait à reconstruire les deux à la main. Désormais, le même code .reorderable() et .swipeActions fonctionne, qu'un changement de classe de taille affiche une List sur l'écran extérieur ou une grille sur l'écran intérieur :

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) }

Le guide d'Apple précise que le réordonnancement ne couvre que les déplacements à l'intérieur du conteneur. Faire glisser des éléments vers d'autres parties de votre app, ou depuis d'autres apps, est une étape distincte avec dragContainer(for:in:_:) et dropDestination(for:isEnabled:action:). Si l'écran intérieur affiche deux panneaux côte à côte avec ArrangementView, notre guide d'ArrangementView traite de la séparation elle-même.

Ce qu'il faut retenir

  • Dans iOS 27, .reorderable() sur un ForEach et .reorderContainer(for:) sur son conteneur offrent le glisser pour réordonner, sans mode édition ni .onMove.
  • Le conteneur peut être une List, une pile, une grille ou une mise en page personnalisée.
  • La closure move reçoit une ReorderDifference : les identifiants déplacés dans sources, et une destination .before(_:) ou .end. À vous de l'appliquer à votre tableau.
  • Utilisez reorderable(collectionID:) et reorderContainer(for:in:) pour déplacer des éléments entre sections.
  • Les actions de balayage fonctionnent désormais hors de List ; ajoutez .swipeActionsContainer() à la ScrollView ou à la mise en page.
  • Tout ceci requiert iOS 27.0 ou ultérieur : protégez-le avec #available et gardez .onMove comme solution de repli.

Les listes ne sont que la moitié d'une interface soignée ; le texte des rangées compte aussi. Lisez ensuite notre guide de la hauteur de ligne en SwiftUI dans iOS 26, et parcourez Built for the fold pour découvrir des apps et des jeux qui tirent déjà le meilleur parti de l'iPhone Duo.

Questions fréquentes

Comment réordonner une List SwiftUI dans iOS 27 sans mode édition ?

Appliquez .reorderable() au ForEach dans la List et .reorderContainer(for:) à la List elle-même, en passant votre type d'élément. Les utilisateurs peuvent alors faire glisser les rangées directement, et la closure move vous fournit une ReorderDifference à appliquer à vos données. Ni EditButton ni onMove ne sont nécessaires.

reorderable() fonctionne-t-il dans LazyVStack et LazyVGrid ?

Oui. La documentation d'Apple indique qu'un conteneur réordonnable peut être une liste, une pile, une grille ou une mise en page personnalisée. Placez .reorderContainer sur ce conteneur et .reorderable() sur le ForEach qu'il contient.

Qu'est-ce que ReorderDifference en SwiftUI ?

C'est la valeur que reçoit la closure move à la fin d'un glissement. Sa propriété sources liste les identifiants des éléments déplacés dans l'ordre où la personne les a sélectionnés, et sa destination indique où ils vont : avant un élément donné ou à la fin d'une collection.

Peut-on utiliser des actions de balayage hors d'une List dans iOS 27 ?

Oui. Ajoutez .swipeActions à chaque rangée comme d'habitude et appliquez .swipeActionsContainer() à la ScrollView ou à la mise en page qui les contient. Le conteneur garantit qu'une seule rangée est ouverte à la fois, et qu'un défilement ou un toucher ailleurs la referme.

reorderable() et swipeActionsContainer() sont-ils disponibles sur iOS 26 ?

Non. Les deux sont documentés comme disponibles à partir d'iOS 27.0 ; les apps qui prennent en charge des versions antérieures ont besoin d'un test if #available avec une solution de repli, comme onMove dans une List.

Sources

  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

Faites du pli une partie de votre app.

Nous présentons les apps et les jeux qui exploitent vraiment l’iPhone Duo. Envoyez-nous la vôtre avec un lien vers l’App Store, ou découvrez ce que d’autres ont créé.

[email protected]