What You Will Build
A color grid where items can be dragged and dropped to rearrange their positions with smooth bouncy animations. A toolbar button toggles between 2-column and 3-column layouts. This uses SwiftUI's .draggable and .dropDestination modifiers for native drag-and-drop without UIKit bridging.
Why This Pattern Matters
Reorderable grids are essential for home screens, photo galleries, dashboard widgets, and any interface where users customize layout. The iOS 16+ drag-and-drop APIs make this dramatically simpler than the old NSItemProvider approach, with automatic preview generation and smooth insertion animations.
Key SwiftUI Concepts
- .draggable() — makes a view a drag source with a Transferable payload.
- .dropDestination() — makes a view a drop target that receives Transferable items.
- isTargeted closure — fires when a dragged item hovers over this target, enabling live reordering during drag.
- LazyVGrid with dynamic column count.
The Complete Implementation
struct ReOrderingGridDemo: View {
@State private var colors: [Color] = [
.red, .blue, .purple, .yellow, .black,
.indigo, .cyan, .brown, .mint, .orange
]
@State private var draggingItem: Color?
@State private var dualGrid: Bool = false
var body: some View {
ScrollView(.vertical) {
let columns = Array(
repeating: GridItem(spacing: 10),
count: dualGrid ? 2 : 3
)
LazyVGrid(columns: columns, spacing: 10) {
ForEach(colors, id: \.self) { color in
GeometryReader {
let size = $0.size
RoundedRectangle(cornerRadius: 10)
.fill(color.gradient)
.draggable(color) {
// Drag preview
RoundedRectangle(cornerRadius: 10)
.fill(.ultraThinMaterial)
.frame(width: size.width, height: size.height)
.onAppear { draggingItem = color }
}
.dropDestination(for: Color.self) { _, _ in
draggingItem = nil
return false
} isTargeted: { status in
if let draggingItem, status,
draggingItem != color {
if let sourceIndex = colors.firstIndex(of: draggingItem),
let destinationIndex = colors.firstIndex(of: color) {
withAnimation(.bouncy) {
let item = colors.remove(at: sourceIndex)
colors.insert(item, at: destinationIndex)
}
}
}
}
}
.frame(height: dualGrid ? 180 : 100)
}
}
.padding(15)
}
.navigationTitle("Movable Grid")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
withAnimation(.bouncy) { dualGrid.toggle() }
} label: {
Image(systemName: dualGrid
? "square.grid.3x2" : "square.grid.2x2")
.font(.title3)
}
}
}
}
}
How Live Reordering Works
The magic is in the isTargeted closure. When a dragged item hovers over a grid cell, status becomes true. At that moment, the code finds both the source and destination indices in the array, removes the source item, and inserts it at the destination. The withAnimation(.bouncy) wrapping ensures all other items smoothly shift to accommodate the move. This happens continuously as the user drags across cells, creating a fluid reordering experience.
Tips and Pitfalls
- Transferable conformance:
Colorconforms toTransferableon iOS 16+. For custom model objects, you need to addTransferableconformance with aProxyRepresentation. - Drag preview: The
.ultraThinMaterialrectangle used as the drag preview is intentionally semi-transparent so the user can see where they are dropping. - dropDestination returns false: Returning false from the drop handler means "do not accept a foreign drop." The reordering already happened in
isTargeted, so no further action is needed on drop. - GeometryReader for size: The drag preview needs to match the cell size. GeometryReader captures the cell dimensions at render time.
- id: \.self caveat: Using
id: \.selfwith Color works for this demo but would break with duplicate colors. In production, use an Identifiable model.
iOS Version: iOS 16+ (for .draggable/.dropDestination)