Reorder Items in SwiftUI LazyVStack

Replicate UIKit Drag and Drop in SwiftUI

Avi Tsadok
5 min readJul 10, 2021

--

Those of you who are familiar with UIKit iOS 11 Drag and Drop API probably know that it dramatically improves UITableView and UICollectionView reordering user experience.

“Drag and Drop” become super straightforward, and the API handles most of the cases itself.

But what happens with SwiftUI? Do we have the same simple API like UIKit?

Main Difference Between SwiftUI and UIKit

Both frameworks are remarkable, but they work differently. While in UIKit, we are expected to synchronize the changes between the data and the UI, in SwiftUI, the framework does this job for us. The reason is that SwiftUI is a declarative framework — we describe how the UI behaves and how it is connected to the data, and the magic happens only by updating the data (“State”).

But this approach creates new challenges and changes to how we work.

In UIKit we:

- Attach a dragging delegate to a UICollectionView or UITableView.

- The API handles the animation and UI updates for us.

- We update the data at the end.

In SwiftUI we:

- Declare what views can be draggable and what views can receive drops.

- Detect dragging updates and update the state according to the changes.

- Watch how the UI updates by itself.

Reordering items with drag and drop is relevant not only to LazyVStack but to any other layout such as LazyHStack, List, and even VStack and HStack.

It doesn’t matter what layout we choose — we can apply it to any layout since the updates are derived from data changes.

Start with simple LazyVStack

Let’s create a simple LazyVStack, with three items only:

For the simplicity of the tutorial, the items list is an array of strings, and I also colored the views with red.

Enabling Dragging

Now, because SwiftUI is a declarative framework, we need to add a view modifier for each view that handle the dragging. This view modifier is called onDrag(). Let’s do it:

LazyVStack(spacing : 15) {
ForEach(items, id:\.self) { item in
Text(item)
.frame(minWidth:0, maxWidth:.infinity, minHeight:50)
.border(Color.black).background(Color.red)
.onDrag({
self.draggedItem = item
return NSItemProvider(item: nil, typeIdentifier: item)
})
}
}

Adding onDrag() to a view “turns on” the dragging feature, and now it’s possible to drag this view around the screen. Notice that the view also has a background — touching a transparent area does not start the dragging, so a background color is a must here.

onDrag() has a parameter closure that expects to return an NSItemProvider object. In short, this is how we pass on the item’s data we are dragging, so we can know what to do with it when the user drops it somewhere else.

Handle Drops

“Dragging” doesn’t mean “reordering”. We need to detect a dragging movement above other views, and change data correspond to add a reordering behavior.

To do that, we need to add onDrop() to the views to accept dropping. Now, onDrop() is a little bit more complicated because this is the place where we need to respond to the dragging movement.

struct ContentView: View {

@State var items = ["1", "2", "3"]
@State var draggedItem : String?

var body: some View {
LazyVStack(spacing : 15) {
ForEach(items, id:\.self) { item in
Text(item)
.frame(minWidth:0, maxWidth:.infinity, minHeight:50)
.border(Color.black).background(Color.red)
.onDrag({
self.draggedItem = item
return NSItemProvider(item: nil, typeIdentifier: item)
}) .onDrop(of: [UTType.text], delegate: MyDropDelegate(item: item, items: $items, draggedItem: $draggedItem))
}
}
}
}

Notice I added a new state named “draggedItem”. This is the variable that stores the dragged item. We are going to need it later on.

Let’s examine our onDrop() function:

.onDrop(of: [UTType.text], delegate: MyDropDelegate(item: item, items: $items, draggedItem: $draggedItem))

In this case, the onDrop() view modifier accepts two parameters — the type of content allowed to be dragged — (UTType.text), and a delegate (conforms to DropDelegate protocol) describes the behavior of the drop.

Note: If Xcode doesn’t recognize UTType, you need to import the UniformTypeIdentifiers framework.

DropDelegate Protocol

If you read the code carefully, you probably noticed that, unlike UIKit drop delegate, in SwiftUI, we create a drop delegate object for every row in the stack, again, because of the declarative nature of the framework.

Let’s look at our delegate:

struct MyDropDelegate : DropDelegate {    let item : String
@Binding var items : [String]
@Binding var draggedItem : String?
func performDrop(info: DropInfo) -> Bool {
return true
}
func dropEntered(info: DropInfo) {
guard let draggedItem = self.draggedItem else {
return
}
if draggedItem != item {
let from = items.firstIndex(of: draggedItem)!
let to = items.firstIndex(of: item)!
withAnimation(.default) {
self.items.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to)
}
}
}
}

DropDelegate protocol has more functions, but those two are mandatory to implement.

The magic of the reordering happens on the dropEntered function:

func dropEntered(info: DropInfo) {
guard let draggedItem = self.draggedItem else {
return
}
if draggedItem != item {
let from = items.firstIndex(of: draggedItem)!
let to = items.firstIndex(of: item)!
withAnimation(.default) {
self.items.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to)
}
}
}

dropEntered() is being called when an item being dragged into the view frame. The function starts with a condition we need to validate — that it’s not the same row being dragged right now. This is why we need to pass the draggedItem variable.

The next step is probably the most important one — the items in the array are being switched. Since the items array is bind to the view state, it updates the UI instantly!

I also wrapped it with animation so that it will look nicer.

This is how it looks:

Here is the full code:

Things to notice:

You can disable dragging on specific rows — you can do it easily in the dropEntered function. Remember that the item is being passed to the drop delegate, so the delegate can decide what to do.

Beware of dragging of other items — Currently, it is possibles to drag any “text” to the view so that the user can drag other elements to your list, even from other apps (on iPad, for example). You need to check what item is being dragged before and data manipulation.

Drop Delegate has more features baked in — the “reordering” is not a DropDelegate feature, it’s just a use case. You can do many things with it such as change colors of views being hovered, drag into a view and not just replace with, and many more. Explore the protocol by reading SwiftUI documents or find many examples online.

Summary

The key for recording lists and stacks is to understand how SwiftUI updates its UI. UI Events update the state, and the result of that is the UI changes. In UIKit, the UI changes are done for us, and we need to update the “state”.

--

--

Avi Tsadok

Head of Mobile at Melio, Author of “Pro iOS Testing”, “Mastering Swift Package Manager” and “Unleash Core Data”