Enabling drag reordering in SwiftUI lazy grids and stacks
While SwiftUI’s List
component allows users to drag to reorder items within the list, the LazyVGrid
, LazyHGrid
, LazyVStack
and LazyHStack
components currently lack this functionality. Let’s take a look at how to implement it from scratch.
The solution in this post comes from this post, and builds upon the amazing work of ramzesenok. I’ve just componentified it a bit and added the possibility to provide a custom preview.
What to expect
To give you an idea of what to expect, it will let us add drag reordering to LazyVGrid
and LazyHGrid
:
as well as to LazyVStack
and LazyHStack
, using the exact same component and configuration:
We will be able to use any content views for the list items, and will be able to customize the preview as well. The solution will also handle ending the drag gesture outside of the list.
Building the component
Before we start building, we need to specify the capabilities that a reorderable item needs to have. Let’s call it Reorderable
and require it to be both Identifiable
and Equatable
:
public typealias Reorderable = Identifiable & Equatable
In this example, let’s use a very basic model for our list items, that just has a numeric identifier:
struct GridData: Identifiable, Equatable {
let id: Int
}
Since we want to be able to use this new component in all kind of lazy collection views, let’s create it as a replacement to the regular ForEach
view. Let’s call it ReorderableForEach
:
public struct ReorderableForEach<Item: Reorderable, Content: View, Preview: View>: View {
...
}
We want the view to take a binding to the collection items
(so that we can change the order), a binding of the currently active
item (so that we can control it), a content
view builder for the list items, an optional preview
view builder to customize the drag preview and a moveAction
to perform the move:
public struct ReorderableForEach<Item: Reorderable, Content: View, Preview: View>: View {
public init(
_ items: [Item],
active: Binding<Item?>,
@ViewBuilder content: @escaping (Item) -> Content,
@ViewBuilder preview: @escaping (Item) -> Preview,
moveAction: @escaping (IndexSet, Int) -> Void
) {
self.items = items
self._active = active
self.content = content
self.preview = preview
self.moveAction = moveAction
}
public init(
_ items: [Item],
active: Binding<Item?>,
@ViewBuilder content: @escaping (Item) -> Content,
moveAction: @escaping (IndexSet, Int) -> Void
) where Preview == EmptyView {
self.items = items
self._active = active
self.content = content
self.preview = nil
self.moveAction = moveAction
}
@Binding
private var active: Item?
@State
private var hasChangedLocation = false
private let items: [Item]
private let content: (Item) -> Content
private let preview: ((Item) -> Preview)?
private let moveAction: (IndexSet, Int) -> Void
public var body: some View {
... // Coming up
}
}
We can now create a view with and without a custom preview. Let’s now iterate over the collection and render a content view for each item in the collection:
public struct ReorderableForEach<Item: Reorderable, Content: View, Preview: View>: View {
...
public var body: some View {
ForEach(items) { item in
if let preview {
contentView(for: item)
.onDrag {
dragData(for: item)
} preview: {
preview(item)
}
} else {
contentView(for: item)
.onDrag {
dragData(for: item)
}
}
}
}
private func contentView(for item: Item) -> some View {
content(item)
.opacity(active == item && hasChangedLocation ? 0.5 : 1)
.onDrop(
of: [.text],
delegate: ReorderableDragRelocateDelegate(
item: item,
items: items,
active: $active,
hasChangedLocation: $hasChangedLocation
) { from, to in
withAnimation {
moveAction(from, to)
}
}
)
}
private func dragData(for item: Item) -> NSItemProvider {
active = item
return NSItemProvider(object: "\(item.id)" as NSString)
}
}
In the code above, we use a plain ForEach
to iterate over the items, then render a contentView
for each item, with an onDrag
modifier applied to let us drag the items.
The drag modifier differs a bit depending on if we want to use a custom preview or not. We thus perform an if check and render the same content view with two different modifiers.
As you can see in the contentView
builder, we change the opacity
when the item becomes active. We also apply an onDrop
modifier with a custom delegate that looks like this:
struct ReorderableDragRelocateDelegate<Item: Reorderable>: DropDelegate {
let item: Item
var items: [Item]
@Binding var active: Item?
@Binding var hasChangedLocation: Bool
var moveAction: (IndexSet, Int) -> Void
func dropEntered(info: DropInfo) {
guard item != active, let current = active else { return }
guard let from = items.firstIndex(of: current) else { return }
guard let to = items.firstIndex(of: item) else { return }
hasChangedLocation = true
if items[to] != current {
moveAction(IndexSet(integer: from), to > from ? to + 1 : to)
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
hasChangedLocation = false
active = nil
return true
}
}
This delegate will actually try to perform the move as soon as dropEntered
is triggered. This is why the items reorder as we drag, as you can see in the gifs above.
This is actually all we need, but we do want some additional handling as well. For instance, we want to be able to terminate the move operation if an item is dropped outside the list.
To do this, let’s create another delegate:
struct ReorderableDropOutsideDelegate<Item: Reorderable>: DropDelegate {
@Binding
var active: Item?
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
active = nil
return true
}
}
All this does is to reset the active state whenever it performs a drop. We can now create a view extension to make it easy to bind this delegate to any view:
public extension View {
func reorderableForEachContainer<Item: Reorderable>(
active: Binding<Item?>
) -> some View {
onDrop(of: [.text], delegate: ReorderableDropOutsideDelegate(active: active))
}
}
This modifier should be applied to the outermost container, to make sure that ending the drag gesture outside the list will still reset the active state.
And with that, we’re done. To use this, just apply the reorderableForEachContainer
to the container view, then use a ReorderableForEach
instead of a ForEach
:
struct ContentView: View {
@State
private var items = (1...100).map { GridData(id: $0) }
@State
private var active: GridData?
var body: some View {
ScrollView(.vertical) {
LazyVGrid(columns: .adaptive(minimum: 100, maximum: 200)) {
ReorderableForEach(items, active: $active) { item in
shape
.fill(.white.opacity(0.5))
.frame(height: 100)
.overlay(Text("\(item.id)"))
.contentShape(.dragPreview, shape)
} preview: { item in
Color.white
.frame(height: 150)
.frame(minWidth: 250)
.overlay(Text("\(item.id)"))
.contentShape(.dragPreview, shape)
} moveAction: { from, to in
items.move(fromOffsets: from, toOffset: to)
}
}.padding()
}
.background(Color.blue.gradient)
.scrollContentBackground(.hidden)
.reorderableForEachContainer(active: $active)
}
var shape: some Shape {
RoundedRectangle(cornerRadius: 20)
}
}
Remember to apply a .contentShape(.dragPreview, ...)
modifier if your list items need a shape when they’re being lifted up, as well as to any custom preview you may want to use.
Happy reordering!
Discussion
If you found this post interesting and would like to share your thoughts, ideas, feedback etc. please comment in the Disqus section below or reply to this tweet or this toot.