WWDC.ai

Code-along: Build powerful drag and drop in SwiftUI

Build SwiftUI drag-and-drop interactions with reorderable content, multi-item drag containers, preview formations, and move-aware drop configuration.

Watch on Apple Developer

TL;DR

  • SwiftUI adds a reordering API built around reorderable and reorderContainer, letting views move items within and across collections by applying a ReorderDifference.
  • dragContainer can customize what data is lifted for a drag, enabling multi-item drags such as moving a stack of Solitaire cards from a single gesture.
  • dragPreviewsFormation and dropPreviewsFormation control how multiple dragged items are visually grouped, with options such as stack-like previews.
  • dragConfiguration, dropDestination, and dropConfiguration let sources and destinations negotiate move/copy behavior and reject invalid drops based on app rules.

Context: SwiftUI drag and drop expands beyond Transferable

The session builds a Solitaire game to demonstrate new SwiftUI drag-and-drop capabilities available in the 2027 releases. It assumes the existing model where app data conforms to Transferable and views use draggable and dropDestination to move content through the system.

The new pieces focus on app-local interactions: reordering ordered content, dragging multiple items from a single gesture, controlling drag/drop preview formation, and configuring whether a transfer should be a move or a copy.

  • Use Transferable for data that participates in drag and drop.
  • Use the new reordering APIs when content has an order that people should be able to rearrange.
  • Compose lower-level drag/drop modifiers with reorderContainer when the default behavior needs app-specific rules.

Enable reordering with reorderable and reorderContainer

The basic reordering model has two parts: mark the items that can be reordered with reorderable, then scope the interaction with reorderContainer. At the end of an operation, SwiftUI provides a difference that the app applies to its model.

For a single collection preview, the closure can directly apply the difference to an array. For the Solitaire board, one reorderContainer wraps all piles and uses a collection identifier type, Card.Group, so cards can move across multiple piles.

  • Apply reorderable() to the ForEach or item collection that should participate in reordering.
  • Apply reorderContainer(for:) around the shared container and update model state from the provided difference.
  • When multiple reorderable collections share a container, pass a unique collectionID for each collection.
  • Exclude non-draggable content, such as face-down cards, by rendering it in a separate ForEach without reorderable.

Simple reorderable preview

Marks the card views reorderable and applies the resulting reorder difference to the backing array.

#Preview {
    @Previewable @State var cards = [
        CardValue(rank: .ace, suit: .clubs),
        CardValue(rank: .ace, suit: .diamonds),
        CardValue(rank: .ace, suit: .hearts),
        CardValue(rank: .ace, suit: .spades)
    ]

    HStack {
        ForEach(cards) { card in
            CardFaceView(card: card)
        }
        .reorderable()
    }
    .reorderContainer(for: CardValue.self) { difference in
        cards.apply(difference: difference)
    }
}

Multiple piles in one reorder container

Scopes reordering across all Solitaire piles and lets game logic handle moves between collections.

HStack(alignment: .top, spacing: spacing) {
    ForEach(0..<7) { index in
        PileView(game: game, index: index)
            .frame(width: cardWidth)
    }
}
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
    game.moveCards(difference: difference)
}

Model reorderable subsets correctly

The session avoids advanced filtering by changing the view structure: face-down cards are rendered separately from face-up cards. Only the face-up slice receives reorderable, so dragging a face-down card starts no reordering interaction.

  • Use view composition to express what is reorderable instead of accepting every item and later rejecting some of them.
  • Use stable item identity for reorderable views; the sample uses id: \.value for the face-up cards.
  • Provide the collection identifier with reorderable(collectionID:) when a container manages multiple collections.

Only face-up cards are reorderable

Splits the pile into non-reorderable face-down cards and reorderable face-up cards.

PileLayout {
    let index = firstFaceUpIndex

    ForEach(cards[..<index]) { card in
        CardView(card: card)
    }

    ForEach(cards[index...], id: \.value) { card in
        CardView(card: card)
    }
    .reorderable(collectionID: Card.Group.pile(index))
}

Drag multiple items with dragContainer and preview formations

A reorderContainer implicitly provides drag-container and drop-destination behavior, but the app can add its own dragContainer to customize what transferable data is included in a drag. In the Solitaire sample, dragging a card can lift that card plus the cards stacked above it.

The visual grouping of dragged items is configurable. dragPreviewsFormation(.stack) changes the lifted preview, and dropPreviewsFormation(.stack) keeps the same appearance when the drag is over destinations.

  • Place dragContainer(for:) near the reorderContainer when customizing reorder drags.
  • Use the same item type for the reorderContainer and dragContainer so they work together.
  • Return all transferable items that should participate in the drag from the dragContainer closure.
  • Use dragPreviewsFormation and dropPreviewsFormation to make multi-item drags visually match the app's interaction model.

Lift a stack of cards from one dragged card

Customizes the reorder drag so one card identifier expands into a stack of cards, then displays those previews as a stack.

.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
    game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)

Keep drop previews consistent

Applies the same stacked preview formation over drop destinations.

VStack {
    // playing area
}
.dropPreviewsFormation(.stack)

Configure move semantics and validate drops

The remaining Solitaire interaction moves a card from the remainder deck into a pile. By default, SwiftUI suggests copy semantics for a separate drag source and drop destination. The source uses dragConfiguration(DragConfiguration(allowMove: true)) to express that a move is allowed, but the destination has the final say.

The destination adds dropDestination so the reorder container can accept inserted cards from outside the container, then adds dropConfiguration to compute the intended reorder destination, require .move, and reject invalid Solitaire moves with .forbidden.

  • Use dragConfiguration on the source to express transfer intent, such as allowing move.
  • Use dropDestination when a reorder container should accept new items, not only moves already inside the container.
  • Use session.reorderDestination(for:in:) to read the destination computed for inserted reorder items.
  • Use dropConfiguration to choose the DropOperation, provide a destination, and enforce app-specific validation.

Allow moving a card from the remainder deck

Configures the source card so a drag can represent a move instead of only a copy.

CardFaceView(card: currentCard.value)
    .draggable(containerItemID: currentCard.value)

.dragContainer(for: CardValue.self) { cardID in
    [cardID]
}
.dragConfiguration(DragConfiguration(allowMove: true))

Accept and validate inserted cards

Lets the destination insert outside cards only when the session supports move and the game rules allow the computed pile destination.

.dropDestination(for: CardValue.self) { newCards, session in
    if let destination = session.reorderDestination(
        for: CardValue.self,
        in: Card.Group.self
    ) {
        game.insertCards(newCards, to: destination)
    }
}
.dropConfiguration { session in
    let alignedX = session.location.x - 0.5 * spacing
    let pile = Int(alignedX / (cardWidth + spacing))
    let destination = ReorderDifference<CardValue, Card.Group>.Destination(
        position: .end,
        collectionID: .pile(pile)
    )

    let allowed = session.suggestedOperations.contains(.move)
        && game.validateMove(session: session, destination: destination)
    let operation: DropOperation = allowed ? .move : .forbidden

    return DropConfiguration(operation: operation, destination: destination)
}

Resources

Unofficial, not associated with Apple. BySuperwall

On this page

Ask AI