SwiftUI Drag to Reorder in a Grid: The iOS 27 reorderable() API
I have a file in my project called GridReorder.swift. Two hundred lines. It exists because SwiftUI would only let you drag things around inside a List.
I wrote it three years ago for a board of photo tiles. It measured frames with GeometryReader, matched them back to indices, and animated a placeholder while you dragged. It worked about eighty percent of the time. Rotate the device and the cached frames were wrong. Turn VoiceOver on and reordering was impossible, because the whole thing depended on a drag gesture assistive tech never sends.
As of iOS 27, that file has no reason to exist. The reordering APIs Apple showed at WWDC26 shipped with the OS on September 14, and I spent Monday afternoon deleting code.
What we had before
Inside a List, onMove was fine. You get a set of offsets, you call move(fromOffsets:toOffset:) on your array, done.
The moment your data needed to live in a grid, a stack, or a horizontally scrolling row of cards, you fell off a cliff. You had onDrag and onDrop, which are transfer APIs. dropDestination(for:action:) tells you something was dropped somewhere, and then you spend an afternoon figuring out where "somewhere" is relative to every other tile. Mine recorded the midpoint of each visible tile in a named coordinate space. It was not good code and I knew it.
reorderable() and reorderContainer
The new API splits the job in two. You mark the content that can be reordered, then you mark the container it can be reordered inside.
Marking the content is one modifier on the ForEach, not on the cell inside it. Marking the container is one modifier on the list, stack, grid, or custom layout that encloses it.
struct Photo: Identifiable, Hashable, Sendable {
let id: UUID
var caption: String
}
struct PhotoBoard: View {
@State private var photos: [Photo] = []
private let columns = [GridItem(.adaptive(minimum: 140), spacing: 12)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(photos) { photo in
PhotoTile(photo: photo)
}
.reorderable()
}
.reorderContainer(for: Photo.self) { difference in
photos.apply(difference)
}
}
}
}
Your item type needs an identifier conforming to Hashable and Sendable. If your model is Identifiable with a UUID, you already meet that, which means the Sendable requirement is really just the Swift 6 strict concurrency rules showing up on your model types again. There's no delegate to register and no coordinate space to name.
The move closure does not give you two indices
This is the part that confuses you for ten minutes if you're coming from onMove. You don't get a from-index and a to-index. You get a ReorderDifference, and it speaks in identifiers.
difference.sources is an array of the IDs being moved. difference.destination.position is either .before(someID) or .end.
That design is deliberate, and once you see why, you stop wanting indices. A drag can take a second or two. During that window your array can change underneath you: a sync pulls in a new photo, a background task inserts a row. Had the system handed you index 4 and index 9, those numbers might mean something different by the time you apply them. IDs don't drift.
extension Array where Element == Photo {
mutating func apply(
_ difference: ReorderDifference<UUID, ReorderableSingleCollectionIdentifier>
) {
let moving = filter { difference.sources.contains($0.id) }
guard !moving.isEmpty else { return }
removeAll { difference.sources.contains($0.id) }
switch difference.destination.position {
case .before(let target):
let index = firstIndex { $0.id == target } ?? endIndex
insert(contentsOf: moving, at: index)
case .end:
append(contentsOf: moving)
}
}
}
Remove first, then insert. If you insert before removing, every forward move lands one slot too far. I did that twice before I read my own diff properly.
Those two cases are the whole enum, incidentally. before and end. You don't need an after.
Things to know before you delete the old code
Every one of these symbols is iOS 27 and up. My deployment target is iOS 26, so the new path sits behind if #available(iOS 27, *) and the DropDelegate code stays for everyone else. Shipping two reorder implementations is a real cost, and I went through the same gate with the Liquid Glass rebuild last year. If your floor allows it, raise the floor.
The container modifier also takes an isEnabled flag, which the docs suggest flipping off while you sync. I wired mine to the same binding that drives my save spinner. That killed a class of "the tile snapped back" bug reports I'd been ignoring for months.
One limit worth planning around: reorderContainer only covers moves inside the container. Dragging a tile to another window or another app is a separate job, handled by dragContainer(for:in:_:) and dropDestination(for:isEnabled:action:), with the destination maths coming from reorderDestination(for:in:) on the drop session. I haven't needed any of that, and if you don't either, you can ignore those three modifiers entirely.
Multiple collections have their own pair. reorderable(collectionID:) plus reorderContainer(for:in:isEnabled:move:) is for moving items between sections, like photos between albums. Same idea, more plumbing.
And watch the name. Half the posts I read while working this out call it reorderableContainer. It's reorderContainer. Trust autocomplete over the internet.
What actually got deleted
GridReorder.swift is gone, along with the geometry helpers that came with it. The part I care about is the accessibility win. With a framework modifier doing the work, reordering now functions under VoiceOver and Switch Control without a line of code from me. For three years my answer to "can I reorder this with VoiceOver?" was no, and there was a note in the backlog about it. The note is gone too.
I kept the availability gate and the array extension above in Snippet Ark, not because they're clever, but because I get the ordering wrong every time I rewrite that insert.
SwiftUI still isn't finished. But this was the widest remaining gap between "declarative" and "I need a workaround", and it closed on Monday.