SwiftUI Drag To Reorder ForEach / Stack / Grid
Reading time: 3 min
This recipe shows how to implement drag & drop to reorder items in SwiftUI. It offers a recipe for a generic ReorderableForEach which can then be plugged into any layout, such as VStack
, LazyVGrid
, etc.
The end result looks like this:
This code works starting with SwiftUI 3 (iOS 15, macOS 12).
This component is available as a Swift Package in this repo.
Implementing drop to reorder functionality requires us to build on the drag & drop recipe. The action is pretty much the same:
- Use
onDrag
on eachForEach
item to make it draggable. The resultingNSItemProvider
content isn't that important, but it should be unique for the list. Since we'll support onyHashable
data, each item should have a uniquehashValue
. - Trigger the reordering using a
DropDelegate
inonDrop
:- We'll show a preview of the drop using
dropEnter
. This also reorders the data binding automatically, meaning any reorder is automatically reflected in the data array. - When a drop happens (in
performDrop
), we just clear the current state.
- We'll show a preview of the drop using
On top of this, we can easily add a binding to dynamically toggle reorder functionality. We can also pass an extra argument in the content ViewBuilder
to indicate if an item is the one being dragged, so that it can be rendered differently than other items.
Here's the code for ReoderableForEach
:
import SwiftUI
import UniformTypeIdentifiers
public struct ReorderableForEach<Data, Content>: View
where Data : Hashable, Content : View {
@Binding var data: [Data]
@Binding var allowReordering: Bool
private let content: (Data, Bool) -> Content
@State private var draggedItem: Data?
@State private var hasChangedLocation: Bool = false
public init(_ data: Binding<[Data]>,
allowReordering: Binding<Bool>,
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
_data = data
_allowReordering = allowReordering
self.content = content
}
public var body: some View {
ForEach(data, id: \.self) { item in
if allowReordering {
content(item, hasChangedLocation && draggedItem == item)
.onDrag {
draggedItem = item
return NSItemProvider(object: "\(item.hashValue)" as NSString)
}
.onDrop(of: [UTType.plainText], delegate: ReorderDropDelegate(
item: item,
data: $data,
draggedItem: $draggedItem,
hasChangedLocation: $hasChangedLocation))
} else {
content(item, false)
}
}
}
struct ReorderDropDelegate<Data>: DropDelegate
where Data : Equatable {
let item: Data
@Binding var data: [Data]
@Binding var draggedItem: Data?
@Binding var hasChangedLocation: Bool
func dropEntered(info: DropInfo) {
guard item != draggedItem,
let current = draggedItem,
let from = data.firstIndex(of: current),
let to = data.firstIndex(of: item)
else {
return
}
hasChangedLocation = true
if data[to] != current {
withAnimation {
data.move(fromOffsets: IndexSet(integer: from),
toOffset: (to > from) ? to + 1 : to)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
hasChangedLocation = false
draggedItem = nil
return true
}
}
}
Then, you can easily plug it in into any layout. Here's an example with VStack
:
struct ReorderingVStackTest: View {
@State private var data = ["Apple", "Orange", "Banana", "Lemon", "Tangerine"]
@State private var allowReordering = false
var body: some View {
VStack {
Toggle("Allow reordering", isOn: $allowReordering)
.frame(width: 200)
.padding(.bottom, 30)
VStack {
ReorderableForEach($data, allowReordering: $allowReordering) { item, isDragged in
Text(item)
.font(.title)
.padding()
.frame(minWidth: 200, minHeight: 50)
.border(Color.blue)
.background(Color.red.opacity(0.9))
.overlay(isDragged ? Color.white.opacity(0.6) : Color.clear)
}
}
}
}
}
Or, you can use it with a LazyVGrid
to immitate UIKit UICollectionView
:
struct ReorderingVGridTest: View {
@State private var data = ["Apple", "Orange", "Banana", "Lemon", "Tangerine"]
@State private var allowReordering = false
var body: some View {
VStack {
Toggle("Allow reordering", isOn: $allowReordering)
.frame(width: 200)
.padding(.bottom, 30)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
]) {
ReorderableForEach($data, allowReordering: $allowReordering) { item, isDragged in
Text(item)
.font(.title)
.padding()
.frame(minWidth: 150, minHeight: 50)
.border(Color.blue)
.background(Color.red.opacity(0.9))
.overlay(isDragged ? Color.white.opacity(0.6) : Color.clear)
}
}
}
.padding()
}
}