Build Reorderable Drag And Drop In SwiftUI

SwiftUI drag and drop has been useful for a while, but it has often felt like two different stories.

Simple transfer between views? Pretty nice. Reordering real app content outside List, dragging multiple related items, and controlling whether a drop moves or copies data? That used to take more custom behavior than I wanted.

The 2027 SwiftUI releases add the missing pieces:

  • .reorderable() marks views that can move.
  • .reorderContainer(for:) scopes and applies the reorder operation.
  • .dragContainer(for:) lets one drag lift multiple transferable items.
  • .dragPreviewsFormation and .dropPreviewsFormation control how those items look while moving.
  • .dragConfiguration and .dropConfiguration let source and destination agree on move vs copy.

The API is especially nice because reordering is no longer a List trick. Apple says the new reorderable container APIs work across containers like List, LazyVGrid, and more.

Start with a small reorderable grid

Here is the smallest mental model. Your data has a stable identity. Your views are created from that data. The ForEach is reorderable. A parent container receives the final difference and updates the source of truth.

import CoreTransferable
import SwiftUI
import UniformTypeIdentifiers

struct PhotoTile: Identifiable, Transferable, Hashable, Codable {
    let id: UUID
    var title: String
    var hue: Double

    var color: Color {
        Color(hue: hue, saturation: 0.75, brightness: 0.8)
    }

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .data)
    }
}

struct ReorderablePhotoGrid: View {
    @State private var photos: [PhotoTile] = [
        PhotoTile(id: UUID(), title: "One", hue: 0.47),
        PhotoTile(id: UUID(), title: "Two", hue: 0.08),
        PhotoTile(id: UUID(), title: "Three", hue: 0.72)
    ]

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

    var body: some View {
        LazyVGrid(columns: columns, spacing: 12) {
            ForEach(photos) { photo in
                RoundedRectangle(cornerRadius: 8)
                    .fill(photo.color.gradient)
                    .frame(height: 120)
                    .accessibilityLabel(photo.title)
            }
            .reorderable()
        }
        .reorderContainer(for: PhotoTile.self) { difference in
            difference.apply(to: &photos)
        }
        .padding()
    }
}

The difference.apply(to:) line is the smallest possible model update. In Apple’s Solitaire sample, that logic lives in the app’s game model. In a real app I would rather keep it there too. The view should not need to know how a board, outline, playlist, or card stack persists its ordering.

Scope reordering across multiple groups

The more interesting version is moving items between groups: Kanban columns, folders, piles, sections, or lanes.

That is where the in: parameter becomes useful. You identify the collection as well as the item:

import CoreTransferable
import SwiftUI
import UniformTypeIdentifiers

enum BoardColumn: String, CaseIterable, Identifiable, Hashable, Sendable {
    case ideas
    case building
    case shipped

    var id: String { rawValue }
}

struct TaskCard: Identifiable, Transferable, Hashable, Codable {
    let id: UUID
    var title: String

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .data)
    }
}

struct BoardView: View {
    @Bindable var board: BoardModel

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            ForEach(BoardColumn.allCases) { column in
                VStack(alignment: .leading, spacing: 8) {
                    Text(column.rawValue.capitalized)
                        .font(.headline)

                    ForEach(board.cards(in: column)) { card in
                        Text(card.title)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding()
                            .background(.background, in: RoundedRectangle(cornerRadius: 8))
                    }
                    .reorderable(collectionID: column)
                }
                .frame(width: 220, alignment: .top)
            }
        }
        .reorderContainer(for: TaskCard.self, in: BoardColumn.self) { difference in
            board.moveCards(difference: difference)
        }
    }
}

That shape is the part I like. The view declares what can be moved and where the reorder operation is scoped. The model receives the difference and applies the rules.

For example, your model might reject moving a shipped task back to ideas, limit work-in-progress, or persist a fractional sort key instead of rewriting every row.

Drag multiple items as one interaction

Reordering one item is not enough for a lot of real apps. In a Kanban board, the user might select three cards and move them together. In a photo organizer, they might drag a whole burst of related shots. In a card game, dragging one card might also drag the cards stacked on top of it.

That is what dragContainer is for:

struct BoardView: View {
    @Bindable var board: BoardModel

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            // Columns and reorderable cards...
        }
        .reorderContainer(for: TaskCard.self, in: BoardColumn.self) { difference in
            board.moveCards(difference: difference)
        }
        .dragContainerSelection(board.selectedCardIDs)
        .dragContainer(for: TaskCard.self) { cardIDs in
            board.cardsToDrag(ids: cardIDs)
        }
        .dragPreviewsFormation(.stack)
        .dropPreviewsFormation(.stack)
    }
}

The closure receives the selected item identifiers, or a one-item collection when there is no active selection. You return the transferable data that should travel with the drag.

That is a nice separation of concerns. SwiftUI owns the interaction. Your model owns the meaning of a multi-item drag. It might be “all selected cards”, “this card and everything below it”, or “the parent item plus its children.”

Move vs copy is part of the design

The last piece is easy to miss. Drag and drop is not always a move. Between apps, copy is often the right default. Inside a board or game, copying may be wrong.

On macOS 26 and newer, SwiftUI also lets the source express its intent with drag and drop configuration modifiers:

#if os(macOS)
CardView(card: card)
    .draggable(containerItemID: card.id)
    .dragConfiguration(DragConfiguration(allowMove: true))
#endif

The destination still has the final say:

#if os(macOS)
.dropDestination(for: TaskCard.self) { newCards, session in
    if let destination = session.reorderDestination(
        for: TaskCard.self,
        in: BoardColumn.self
    ) {
        board.insert(newCards, at: destination)
    }
}
.dropConfiguration { session in
    let destination: ReorderDifference<TaskCard.ID, BoardColumn>.Destination =
        board.destination(for: session.location)
    let canMove = session.suggestedOperations.contains(.move)
    let allowed = canMove && board.canInsert(at: destination)

    return DropConfiguration(
        operation: allowed ? .move : .forbidden,
        destination: destination
    )
}
#endif

That pattern is exactly what I want in app code. The drag source says “I can move.” The destination says “I accept a move here, and only if the app rules allow it.”

It also prevents a subtle class of bugs where a drop looks successful but duplicates the underlying data. On iOS, iPadOS, and visionOS, keep the same product rule in your reorderContainer handler or in an iOS-safe drop destination; do not paste the macOS-only dragConfiguration and dropConfiguration modifiers into an iOS target.

What I would test

For a real app, I would not try to unit test SwiftUI’s drag gesture. I would test the model operations that the drag gesture triggers.

At minimum:

  • Applying a reorder difference keeps every item exactly once.
  • Moving between groups updates group IDs and sort order.
  • Multi-item drags preserve the selected order.
  • Invalid drops leave the source data unchanged.
  • Persistence writes the same ordering the UI displays.

The SwiftUI APIs make the interaction easier. They do not remove the need for a boring, reliable ordering model underneath.

Availability and fallback

The reordering APIs are documented for iOS, iPadOS, macOS, visionOS, and watchOS 27. Apple also says the same reorderable container APIs work across containers such as List, stacks, grids, and custom layouts.

The newer dragContainer API is available on iOS, iPadOS, and visionOS 27. On macOS, Apple calls out dragContainer, dragPreviewsFormation, and dropPreviewsFormation as available on macOS 26 and newer. The DragConfiguration and DropConfiguration modifiers shown above are macOS APIs; use them for Mac drag/drop polish, not as cross-platform reorder-container code.

For older OS versions, I would pick one of two fallbacks:

  • Use List with onMove when the layout can be list-shaped.
  • Keep the content static and offer explicit move buttons or menus.

That second fallback is not glamorous, but it is often better than building a half-custom drag system that never quite matches platform behavior.

The nice thing about the new API is that the modern path can now be the simple path. Mark the items as reorderable, scope the container, let the model apply the difference, and layer on multi-item behavior only when your product needs it.

Further reading: