2026 App ServicesSwiftUI & UI Frameworks
WWDC26 · 15 min · App Services / SwiftUI & UI Frameworks
Code-along: Build powerful drag and drop in SwiftUI
Follow along as we build a game of Solitaire to explore the latest drag-and-drop capabilities in SwiftUI. We’ll show you how to use the new reordering API to let people arrange content, implement drag containers to move multiple items at once, and customize the drag-and-drop lifecycle to fit your app’s rules. To get the most out of this session, watch “Meet Transferable” from WWDC22.
Watch at developer.apple.com ↗Chapters
Code shown on screen · 8 snippets
Add reorderable to the preview
#Preview {
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()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.reorderContainer(for: CardValue.self) { difference in
cards.apply(difference: difference)
}
.padding()
.background(.green.gradient)
} Add reorder container to the GameView
struct GameView: View {
var game: Game
var body: some View {
GeometryReader { proxy in
let spacing: CGFloat = 10
let cardWidth = (proxy.size.width - 6 * spacing) / 7
VStack {
HStack(alignment: .top, spacing: spacing) {
Group {
RemainderView(game: game)
CardBackView()
.hidden()
ForEach(CardValue.Suit.allCases) { suit in
DestinationView(game: game, suit: suit)
}
}
.frame(width: cardWidth)
}
.padding(.bottom, 20)
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<7) { index in
PileView(game: game, index: index)
.frame(width: cardWidth)
}
}
.frame(maxHeight: .infinity, alignment: .top)
// Add the reorder container modifier.
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
game.moveCards(difference: difference)
}
}
}
.padding()
}
} Add reorderable to PileView
struct PileView: View {
var game: Game
var index: Int
var cards: [Card]
var body: some View {
ZStack(alignment: .topLeading) {
CardPlaceholderView()
PileLayout {
let index = firstFaceUpIndex
// Iterates over the face down cards.
ForEach(cards[..<index]) { card in
CardView(card: card)
}
// Iterates over the face up cards.
ForEach(cards[index...], id: \.value) { card in
CardView(card: card)
}
.reorderable(collectionID: Card.Group.pile(index))
}
}
}
var firstFaceUpIndex: Int {
cards.firstIndex { !$0.isFaceDown } ?? cards.endIndex
}
} Add dragContainer to customize the reorderContainer modifier.
struct GameView: View {
var game: Game
var body: some View {
GeometryReader { proxy in
let spacing: CGFloat = 10
let cardWidth = (proxy.size.width - 6 * spacing) / 7
VStack {
HStack(alignment: .top, spacing: spacing) {
Group {
RemainderView(game: game)
CardBackView()
.hidden()
ForEach(CardValue.Suit.allCases) { suit in
DestinationView(game: game, suit: suit)
}
}
.frame(width: cardWidth)
}
.padding(.bottom, 20)
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<7) { index in
PileView(game: game, index: index)
.frame(width: cardWidth)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
game.moveCards(difference: difference)
}
// Add dragContainer to customize reorderContainer.
.dragContainer(for: CardValue.self) { cardID in
game.cardStack(startingAt: cardID)
}
}
}
.padding()
}
} Add dragPreviewsFormation to customize how the dragged cards appear
struct GameView: View {
var game: Game
var body: some View {
GeometryReader { proxy in
let spacing: CGFloat = 10
let cardWidth = (proxy.size.width - 6 * spacing) / 7
VStack {
HStack(alignment: .top, spacing: spacing) {
Group {
RemainderView(game: game)
CardBackView()
.hidden()
ForEach(CardValue.Suit.allCases) { suit in
DestinationView(game: game, suit: suit)
}
}
.frame(width: cardWidth)
}
.padding(.bottom, 20)
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<7) { index in
PileView(game: game, index: index)
.frame(width: cardWidth)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
game.cardStack(startingAt: cardID)
}
// Have dragged cards appear as a stack.
.dragPreviewsFormation(.stack)
}
}
.padding()
}
} Add dropPreviewsFormation to customize how dragged cards appear over a destination
struct GameView: View {
var game: Game
var body: some View {
GeometryReader { proxy in
let spacing: CGFloat = 10
let cardWidth = (proxy.size.width - 6 * spacing) / 7
VStack {
HStack(alignment: .top, spacing: spacing) {
Group {
RemainderView(game: game)
CardBackView()
.hidden()
ForEach(CardValue.Suit.allCases) { suit in
DestinationView(game: game, suit: suit)
}
}
.frame(width: cardWidth)
}
.padding(.bottom, 20)
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<7) { index in
PileView(game: game, index: index)
.frame(width: cardWidth)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.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)
}
// Have a consistent appearance over drop destinations.
.dropPreviewsFormation(.stack)
}
.padding()
}
} Add a drag configuration to allow move.
struct RemainderView: View {
var cards: [Card]
var game: Game
var body: some View {
Button {
incrementCardIndex()
} label: {
ZStack {
CardPlaceholderView()
CardBackView()
.opacity(cards.isEmpty ? 0 : 1)
}
}
.buttonStyle(.plain)
.disabled(cards.isEmpty)
ZStack {
CardPlaceholderView()
if let currentCard {
CardFaceView(card: currentCard.value)
.draggable(containerItemID: currentCard.value)
.opacity(currentCard.value == hiddenCard ? 0 : 1)
}
}
.dragContainer(for: CardValue.self) { cardID in
[cardID]
}
// Add the drag configuration to allow me.
.dragConfiguration(DragConfiguration(allowMove: true))
}
} Add a drop destination modifier and configure it
struct GameView: View {
var game: Game
var body: some View {
GeometryReader { proxy in
let spacing: CGFloat = 10
let cardWidth = (proxy.size.width - 6 * spacing) / 7
VStack {
HStack(alignment: .top, spacing: spacing) {
Group {
RemainderView(game: game)
CardBackView()
.hidden()
ForEach(CardValue.Suit.allCases) { suit in
DestinationView(game: game, suit: suit)
}
}
.frame(width: cardWidth)
}
.padding(.bottom, 20)
HStack(alignment: .top, spacing: spacing) {
ForEach(0..<7) { index in
PileView(game: game, index: index)
.frame(width: cardWidth)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.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)
.dragConfiguration(DragConfiguration(allowMove: true))
// Add a drop destination to accept inserts
.dropDestination(for: CardValue.self) { newCards, session in
if let destination = session.reorderDestination(
for: CardValue.self, in: Card.Group.self) {
game.insertCards(newCards, to: destination)
}
}
// Configure where cards will go when reordering,
// and accept them by move.
.dropConfiguration { session in
// Calculate which pile is being dragged over.
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))
// Check if the move is allowed.
let allowed = session.suggestedOperations.contains(.move)
&& game.validateMove(session: session, destination: destination)
let operation: DropOperation = allowed ? .move : .forbidden
return DropConfiguration(operation: operation, destination: destination)
}
}
.dropPreviewsFormation(.stack)
}
.padding()
}
} Resources
Related sessions
-
14 min