Reorder all the things in SwiftUI

WWDC26

Reordering a List in SwiftUI has been pretty easy for a while. Reordering just about anything else? Not so much.

The moment you wanted the same interaction in a stack, a grid, or a layout of your own, you were suddenly building a lot of drag and drop code for something that felt like it should have been built in.

iOS 27 fixes that with a new set of reordering modifiers. SwiftUI now handles the drag, the placeholder, and the animation for us, whilst still leaving our model in charge of the final move. Let's take a look.

These APIs are available in iOS 27, iPadOS 27, macOS 27, watchOS 27, and visionOS 27.

Yes, this sucks - but it's still fun to play around with.

Something to move

We'll start with the simplest possible thing: a row of coffees.

First, we'll need a small model. The ID is important here, as SwiftUI follows the ID when an item moves rather than whatever position it happened to have in the array when the drag started.

struct Coffee: Identifiable, Sendable {
    let id: UUID
    let name: String
}

extension Coffee {
    static let samples = [
        Coffee(id: UUID(), name: "Flat White"),
        Coffee(id: UUID(), name: "Cappuccino"),
        Coffee(id: UUID(), name: "Mocha"),
        Coffee(id: UUID(), name: "Espresso")
    ]
}
struct Coffee: Identifiable, Sendable {
    let id: UUID
    let name: String
}

extension Coffee {
    static let samples = [
        Coffee(id: UUID(), name: "Flat White"),
        Coffee(id: UUID(), name: "Cappuccino"),
        Coffee(id: UUID(), name: "Mocha"),
        Coffee(id: UUID(), name: "Espresso")
    ]
}

We'll also make a small card to show each one. There's nothing reordering-specific in here yet, it's just a bit nicer than dragging plain text around.

struct CoffeeCard: View {
    let name: String

    var body: some View {
        Text(name)
            .font(.headline)
            .padding()
            .frame(height: 64)
            .background(.quaternary, in: .rect(cornerRadius: 12))
    }
}
struct CoffeeCard: View {
    let name: String

    var body: some View {
        Text(name)
            .font(.headline)
            .padding()
            .frame(height: 64)
            .background(.quaternary, in: .rect(cornerRadius: 12))
    }
}

Finally, put those cards in an HStack, with the array stored in State so we can change it later.

struct CoffeeOrderView: View {
    @State private var coffees = Coffee.samples

    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 12) {
                ForEach(coffees) { coffee in
                    CoffeeCard(name: coffee.name)
                }
            }
            .padding()
        }
    }
}
struct CoffeeOrderView: View {
    @State private var coffees = Coffee.samples

    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 12) {
                ForEach(coffees) { coffee in
                    CoffeeCard(name: coffee.name)
                }
            }
            .padding()
        }
    }
}

At this point, if you ran the app, you'd have a nice row of coffees that does absolutely nothing. A strong start.

Making it lift

The first new piece is reorderable(). Add it to the ForEach, just after its closing brace.

ForEach(coffees) { coffee in
    CoffeeCard(name: coffee.name)
}
.reorderable()
ForEach(coffees) { coffee in
    CoffeeCard(name: coffee.name)
}
.reorderable()

This tells SwiftUI that the views made by this ForEach are allowed to move. If you try it now, though, you'll still get nothing.

We have reorderable items, but we haven't told SwiftUI which bit of the interface is responsible for arranging them. For that, add reorderContainer to the stack around them.

HStack(spacing: 12) {
    ForEach(coffees) { coffee in
        CoffeeCard(name: coffee.name)
    }
    .reorderable()
}
.padding()
.reorderContainer(for: Coffee.self) { difference in
    print(difference)
}
HStack(spacing: 12) {
    ForEach(coffees) { coffee in
        CoffeeCard(name: coffee.name)
    }
    .reorderable()
}
.padding()
.reorderContainer(for: Coffee.self) { difference in
    print(difference)
}

The type passed to reorderContainer is our model type, which in this case is Coffee. SwiftUI gets the ID type from its Identifiable conformance, so the difference it hands back to us still contains Coffee.ID values.

Now if you run the app and drag a coffee, things get a lot more interesting. SwiftUI lifts the card, moves the others out of the way, and gives us a placeholder showing where it'll land.

Then you let go, and it snaps straight back to where it started.

This isn't a bug. SwiftUI handles the interaction, but it doesn't assume anything about how our data should be stored. Instead, the closure gives us a ReorderDifference, and it's up to us to apply that to the array.

Making it stick

ReorderDifference gives us two useful pieces of information. sources contains the IDs that moved, and destination tells us where they should go.

Let's start by finding those items, then removing them from their old positions.

let sourceIDs = Set(difference.sources)
let movingItems = difference.sources.compactMap { sourceID in
    first { $0.id == sourceID }
}

guard movingItems.count == difference.sources.count else { return }

removeAll { sourceIDs.contains($0.id) }
let sourceIDs = Set(difference.sources)
let movingItems = difference.sources.compactMap { sourceID in
    first { $0.id == sourceID }
}

guard movingItems.count == difference.sources.count else { return }

removeAll { sourceIDs.contains($0.id) }

We're deliberately looking everything up by ID here. Using an array offset would be a little shorter, but it can point at the wrong thing if the data changes whilst a drag is happening.

Next, we need to work out where the items are going. A destination is either before another item, or at the end of the collection.

let destinationIndex = switch difference.destination.position {
case .before(let itemID):
    firstIndex { $0.id == itemID } ?? endIndex
case .end:
    endIndex
}
let destinationIndex = switch difference.destination.position {
case .before(let itemID):
    firstIndex { $0.id == itemID } ?? endIndex
case .end:
    endIndex
}

Then we can put the items back in at that position.

insert(contentsOf: movingItems, at: destinationIndex)
insert(contentsOf: movingItems, at: destinationIndex)

Here's that wrapped up into a small array extension, so the view doesn't have to care about any of it.

extension Array where Element: Identifiable, Element.ID: Sendable {
    mutating func apply<CollectionID: Hashable & Sendable>(
        difference: ReorderDifference<Element.ID, CollectionID>
    ) {
        let sourceIDs = Set(difference.sources)
        let movingItems = difference.sources.compactMap { sourceID in
            first { $0.id == sourceID }
        }

        guard movingItems.count == difference.sources.count else { return }

        removeAll { sourceIDs.contains($0.id) }

        let destinationIndex = switch difference.destination.position {
        case .before(let itemID):
            firstIndex { $0.id == itemID } ?? endIndex
        case .end:
            endIndex
        }

        insert(contentsOf: movingItems, at: destinationIndex)
    }
}
extension Array where Element: Identifiable, Element.ID: Sendable {
    mutating func apply<CollectionID: Hashable & Sendable>(
        difference: ReorderDifference<Element.ID, CollectionID>
    ) {
        let sourceIDs = Set(difference.sources)
        let movingItems = difference.sources.compactMap { sourceID in
            first { $0.id == sourceID }
        }

        guard movingItems.count == difference.sources.count else { return }

        removeAll { sourceIDs.contains($0.id) }

        let destinationIndex = switch difference.destination.position {
        case .before(let itemID):
            firstIndex { $0.id == itemID } ?? endIndex
        case .end:
            endIndex
        }

        insert(contentsOf: movingItems, at: destinationIndex)
    }
}

Now replace our print with a call to the new function.

.reorderContainer(for: Coffee.self) { difference in
    coffees.apply(difference: difference)
}
.reorderContainer(for: Coffee.self) { difference in
    coffees.apply(difference: difference)
}

Run it again, move a coffee, and this time it stays put. The array changes to match the interaction, which causes the stack to redraw in its new order.

That's the basic reordering pipeline up and running. Not too scary.

Throwing it into a grid

The lovely bit about these modifiers is that they aren't really interested in the layout itself. Now our data code works, we can swap the stack for a grid without changing the reordering code at all.

struct CoffeeGridView: View {
    @State private var coffees = Coffee.samples

    private let columns = [
        GridItem(.adaptive(minimum: 140), spacing: 12)
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(coffees) { coffee in
                    CoffeeCard(name: coffee.name)
                }
                .reorderable()
            }
            .padding()
            .reorderContainer(for: Coffee.self) { difference in
                coffees.apply(difference: difference)
            }
        }
    }
}
struct CoffeeGridView: View {
    @State private var coffees = Coffee.samples

    private let columns = [
        GridItem(.adaptive(minimum: 140), spacing: 12)
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(coffees) { coffee in
                    CoffeeCard(name: coffee.name)
                }
                .reorderable()
            }
            .padding()
            .reorderContainer(for: Coffee.self) { difference in
                coffees.apply(difference: difference)
            }
        }
    }
}

If you run this version, the placeholder now moves through the grid, and SwiftUI works out all of the animation for us.

This also works with lists, vertical stacks, lazy stacks, and your own types conforming to Layout. Personally, the custom layout support is the bit I'm most pleased about. That's where this interaction used to get fiddly very quickly.

Moving between rows

Reordering one collection is handy, but let's make it a little more useful. We're going to make two rows, one for our menu and one for our favourites, then drag coffees between them.

First, we need a model for each row.

struct CoffeeSection: Identifiable, Sendable {
    let id: UUID
    let title: String
    var coffees: [Coffee]
}

extension CoffeeSection {
    static let samples = [
        CoffeeSection(
            id: UUID(),
            title: "Menu",
            coffees: [
                Coffee(id: UUID(), name: "Flat White"),
                Coffee(id: UUID(), name: "Cappuccino"),
                Coffee(id: UUID(), name: "Mocha")
            ]
        ),
        CoffeeSection(
            id: UUID(),
            title: "Favourites",
            coffees: [
                Coffee(id: UUID(), name: "Espresso")
            ]
        )
    ]
}
struct CoffeeSection: Identifiable, Sendable {
    let id: UUID
    let title: String
    var coffees: [Coffee]
}

extension CoffeeSection {
    static let samples = [
        CoffeeSection(
            id: UUID(),
            title: "Menu",
            coffees: [
                Coffee(id: UUID(), name: "Flat White"),
                Coffee(id: UUID(), name: "Cappuccino"),
                Coffee(id: UUID(), name: "Mocha")
            ]
        ),
        CoffeeSection(
            id: UUID(),
            title: "Favourites",
            coffees: [
                Coffee(id: UUID(), name: "Espresso")
            ]
        )
    ]
}

Each row needs an ID now, as well as each coffee. We'll pass that ID to a slightly different version of reorderable.

struct CoffeeLane: View {
    let title: String
    let coffees: [Coffee]
    let collectionID: CoffeeSection.ID

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)

            HStack(spacing: 8) {
                ForEach(coffees) { coffee in
                    CoffeeCard(name: coffee.name)
                }
                .reorderable(collectionID: collectionID)
            }
            .frame(minWidth: 340, minHeight: 84, alignment: .leading)
            .padding(.horizontal, 10)
            .background(.quaternary, in: .rect(cornerRadius: 16))
        }
    }
}
struct CoffeeLane: View {
    let title: String
    let coffees: [Coffee]
    let collectionID: CoffeeSection.ID

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)

            HStack(spacing: 8) {
                ForEach(coffees) { coffee in
                    CoffeeCard(name: coffee.name)
                }
                .reorderable(collectionID: collectionID)
            }
            .frame(minWidth: 340, minHeight: 84, alignment: .leading)
            .padding(.horizontal, 10)
            .background(.quaternary, in: .rect(cornerRadius: 16))
        }
    }
}

The important change is reorderable(collectionID:). It tells SwiftUI which row each coffee currently belongs to.

Next, we'll put two of those rows in a VStack. Our container also gets an extra argument, telling it the type used for the collection IDs.

struct CoffeeSectionsView: View {
    @State private var sections = CoffeeSection.samples

    var body: some View {
        ScrollView(.horizontal) {
            VStack(alignment: .leading, spacing: 16) {
                ForEach(sections) { section in
                    CoffeeLane(
                        title: section.title,
                        coffees: section.coffees,
                        collectionID: section.id
                    )
                }
            }
            .padding()
            .reorderContainer(
                for: Coffee.self,
                in: CoffeeSection.ID.self
            ) { difference in
                move(difference)
            }
        }
    }
}
struct CoffeeSectionsView: View {
    @State private var sections = CoffeeSection.samples

    var body: some View {
        ScrollView(.horizontal) {
            VStack(alignment: .leading, spacing: 16) {
                ForEach(sections) { section in
                    CoffeeLane(
                        title: section.title,
                        coffees: section.coffees,
                        collectionID: section.id
                    )
                }
            }
            .padding()
            .reorderContainer(
                for: Coffee.self,
                in: CoffeeSection.ID.self
            ) { difference in
                move(difference)
            }
        }
    }
}

Our difference now includes the destination collection, so the move function has one extra job. It needs to find the coffees across all of the rows, remove them, then add them to the right row.

private func move(
    _ difference: ReorderDifference<Coffee.ID, CoffeeSection.ID>
) {
    guard let destinationSectionIndex = sections.firstIndex(
        where: { $0.id == difference.destination.collectionID }
    ) else { return }

    let movingCoffees = difference.sources.compactMap { sourceID in
        sections.lazy.compactMap { section in
            section.coffees.first { $0.id == sourceID }
        }.first
    }

    guard movingCoffees.count == difference.sources.count else { return }

    let sourceIDs = Set(difference.sources)
    for sectionIndex in sections.indices {
        sections[sectionIndex].coffees.removeAll {
            sourceIDs.contains($0.id)
        }
    }

    let destinationIndex = switch difference.destination.position {
    case .before(let coffeeID):
        sections[destinationSectionIndex].coffees.firstIndex {
            $0.id == coffeeID
        } ?? sections[destinationSectionIndex].coffees.endIndex
    case .end:
        sections[destinationSectionIndex].coffees.endIndex
    }

    sections[destinationSectionIndex].coffees.insert(
        contentsOf: movingCoffees,
        at: destinationIndex
    )
}
private func move(
    _ difference: ReorderDifference<Coffee.ID, CoffeeSection.ID>
) {
    guard let destinationSectionIndex = sections.firstIndex(
        where: { $0.id == difference.destination.collectionID }
    ) else { return }

    let movingCoffees = difference.sources.compactMap { sourceID in
        sections.lazy.compactMap { section in
            section.coffees.first { $0.id == sourceID }
        }.first
    }

    guard movingCoffees.count == difference.sources.count else { return }

    let sourceIDs = Set(difference.sources)
    for sectionIndex in sections.indices {
        sections[sectionIndex].coffees.removeAll {
            sourceIDs.contains($0.id)
        }
    }

    let destinationIndex = switch difference.destination.position {
    case .before(let coffeeID):
        sections[destinationSectionIndex].coffees.firstIndex {
            $0.id == coffeeID
        } ?? sections[destinationSectionIndex].coffees.endIndex
    case .end:
        sections[destinationSectionIndex].coffees.endIndex
    }

    sections[destinationSectionIndex].coffees.insert(
        contentsOf: movingCoffees,
        at: destinationIndex
    )
}

There's a fair bit there, but it's the same move as before with one more lookup. The destination tells us which section to use, and its position tells us where inside that section to insert the coffees.

At this point, run the app and you can move Espresso back into the menu, or pull any of the menu items down into favourites. Lovely.

The empty row

Now let's try something that feels like it should work. Change the favourites section so it starts empty.

CoffeeSection(
    id: UUID(),
    title: "Favourites",
    coffees: []
)
CoffeeSection(
    id: UUID(),
    title: "Favourites",
    coffees: []
)

The row still appears because of the frame we added earlier, but try dragging a coffee into it and nothing happens.

There's a fun little quirk here ( fun meaning annoying ): you can't reorder into a completely empty collection.

SwiftUI places the dragged view before another item, or at the end after one. If the row doesn't contain any reorderable views, there isn't anything for it to use as a destination.

The workaround is to put a dummy item in the empty collection. We don't want to show a fake coffee, so ours will be a little card that says “Drag here”. As soon as a real coffee arrives it disappears, and if the last coffee leaves it'll come back.

First, add a flag to Coffee so we can tell the dummy apart from the real data.

struct Coffee: Identifiable, Sendable {
    let id: UUID
    let name: String
    let isDropTarget: Bool

    init(id: UUID, name: String, isDropTarget: Bool = false) {
        self.id = id
        self.name = name
        self.isDropTarget = isDropTarget
    }
}
struct Coffee: Identifiable, Sendable {
    let id: UUID
    let name: String
    let isDropTarget: Bool

    init(id: UUID, name: String, isDropTarget: Bool = false) {
        self.id = id
        self.name = name
        self.isDropTarget = isDropTarget
    }
}

Next, give every section its own stable drop target. The section only adds it to coffees when there aren't any real coffees to show.

struct CoffeeSection: Identifiable, Sendable {
    let id: UUID
    let title: String
    let dropTarget: Coffee
    var coffees: [Coffee]

    init(id: UUID, title: String, coffees: [Coffee]) {
        let dropTarget = Coffee(
            id: UUID(),
            name: "Drag here",
            isDropTarget: true
        )

        self.id = id
        self.title = title
        self.dropTarget = dropTarget
        self.coffees = coffees.isEmpty ? [dropTarget] : coffees
    }
}
struct CoffeeSection: Identifiable, Sendable {
    let id: UUID
    let title: String
    let dropTarget: Coffee
    var coffees: [Coffee]

    init(id: UUID, title: String, coffees: [Coffee]) {
        let dropTarget = Coffee(
            id: UUID(),
            name: "Drag here",
            isDropTarget: true
        )

        self.id = id
        self.title = title
        self.dropTarget = dropTarget
        self.coffees = coffees.isEmpty ? [dropTarget] : coffees
    }
}

It's important that the dummy has a real, stable ID. Don't make a fresh one inside body, as SwiftUI would think it was a brand new item every time the view updated.

Now we can make our two-row example, with favourites genuinely empty to begin with.

extension CoffeeSection {
    static let emptyCollectionDemo = [
        CoffeeSection(
            id: UUID(),
            title: "Menu",
            coffees: Coffee.samples
        ),
        CoffeeSection(
            id: UUID(),
            title: "Favourites",
            coffees: []
        )
    ]
}
extension CoffeeSection {
    static let emptyCollectionDemo = [
        CoffeeSection(
            id: UUID(),
            title: "Menu",
            coffees: Coffee.samples
        ),
        CoffeeSection(
            id: UUID(),
            title: "Favourites",
            coffees: []
        )
    ]
}

Change the state at the top of CoffeeSectionsView to use the new sample.

@State private var sections = CoffeeSection.emptyCollectionDemo
@State private var sections = CoffeeSection.emptyCollectionDemo

Finally, update CoffeeLane to use a CoffeeTile instead of CoffeeCard directly.

ForEach(coffees) { coffee in
    CoffeeTile(coffee: coffee)
}
.reorderable(collectionID: collectionID)
ForEach(coffees) { coffee in
    CoffeeTile(coffee: coffee)
}
.reorderable(collectionID: collectionID)

CoffeeTile will show our normal card for a real coffee, and the much more honest “Drag here” card for the dummy.

struct CoffeeTile: View {
    let coffee: Coffee

    var body: some View {
        if coffee.isDropTarget {
            DropTargetCard()
        } else {
            CoffeeCard(name: coffee.name)
        }
    }
}

struct DropTargetCard: View {
    var body: some View {
        Text("Drag here")
            .font(.subheadline.weight(.semibold))
            .foregroundStyle(.secondary)
            .frame(width: 100, height: 64)
            .overlay {
                RoundedRectangle(cornerRadius: 12)
                    .strokeBorder(
                        .secondary,
                        style: StrokeStyle(lineWidth: 1, dash: [5])
                    )
            }
    }
}
struct CoffeeTile: View {
    let coffee: Coffee

    var body: some View {
        if coffee.isDropTarget {
            DropTargetCard()
        } else {
            CoffeeCard(name: coffee.name)
        }
    }
}

struct DropTargetCard: View {
    var body: some View {
        Text("Drag here")
            .font(.subheadline.weight(.semibold))
            .foregroundStyle(.secondary)
            .frame(width: 100, height: 64)
            .overlay {
                RoundedRectangle(cornerRadius: 12)
                    .strokeBorder(
                        .secondary,
                        style: StrokeStyle(lineWidth: 1, dash: [5])
                    )
            }
    }
}

If you run it now, you can drop a coffee to either side of “Drag here”. We have one last bit of tidying to do, though: the dummy is still in the array.

Add this function next to move. It removes the dummy whenever a section has real coffees, and puts the same dummy back when a section becomes empty.

private func normaliseDropTargets() {
    for sectionIndex in sections.indices {
        let realCoffees = sections[sectionIndex].coffees.filter {
            !$0.isDropTarget
        }

        sections[sectionIndex].coffees = realCoffees.isEmpty
            ? [sections[sectionIndex].dropTarget]
            : realCoffees
    }
}
private func normaliseDropTargets() {
    for sectionIndex in sections.indices {
        let realCoffees = sections[sectionIndex].coffees.filter {
            !$0.isDropTarget
        }

        sections[sectionIndex].coffees = realCoffees.isEmpty
            ? [sections[sectionIndex].dropTarget]
            : realCoffees
    }
}

Call that at the very end of move(_:).

sections[destinationSectionIndex].coffees.insert(
    contentsOf: movingCoffees,
    at: destinationIndex
)

normaliseDropTargets()
sections[destinationSectionIndex].coffees.insert(
    contentsOf: movingCoffees,
    at: destinationIndex
)

normaliseDropTargets()

We should also stop our dummy from becoming actual data. Add this guard at the start of move(_:), before finding the destination section.

let allCoffees = sections.lazy.flatMap(\.coffees)
guard difference.sources.allSatisfy({ sourceID in
    allCoffees.first { $0.id == sourceID }?.isDropTarget == false
}) else { return }
let allCoffees = sections.lazy.flatMap(\.coffees)
guard difference.sources.allSatisfy({ sourceID in
    allCoffees.first { $0.id == sourceID }?.isDropTarget == false
}) else { return }

Now the second row starts with “Drag here”. Drop the first coffee into it and that message disappears. Drag the last coffee back out and it returns, ready for the next drop.

The dummy is purely a piece of interface state, so I wouldn't save it to a database or send it to a server. Keep the real coffees as the real data, then add or remove the drop target around them like we have here.

It's a slightly odd workaround, but it gives us a proper empty destination without leaving a fake item behind once the collection has content.

Taking it further

There are a couple of useful bits worth knowing once you have the basics working.

You can disable the whole interaction whilst saving or syncing by passing isEnabled to the container.

.reorderContainer(
    for: Coffee.self,
    isEnabled: !model.isSaving
) { difference in
    model.apply(difference)
}
.reorderContainer(
    for: Coffee.self,
    isEnabled: !model.isSaving
) { difference in
    model.apply(difference)
}

These modifiers are for arranging items inside a reorder container. If an item needs to leave that area, move to another window, or go to another app, you'll still want the drag and drop APIs.

Apple's card game sample is a great example of the two working together. It puts one reorder container around seven card piles, gives each pile a collection ID, then adds dragContainer and dropDestination for moves outside that area. It also uses a custom card layout, which is much more fun than my row of coffee.

So that's reordering in SwiftUI. I really like the split here: SwiftUI deals with making the interaction feel right, and our model still gets the final say over what actually moves.

The empty collection trick is a little strange, but it's also a good excuse to build a nicer empty state. Try replacing “Drag here” with an illustration, or making a third row that only accepts a certain kind of item.

You can read more in Apple's reordering guide and the reorderable() documentation.

If you fancy sharing what you build, or have questions about the new APIs, I'm @SwiftyAlex on twitter.