Drag-and-Drop with SwiftUI
Posted onOne of the requirements I decided on early on in the development of Aspen was a completely customizable dashboard. Aspen ships bundled with several different tools for calculating exposure, however not all photographers use every one of these tools. It was important to me that the app allowed users to add and remove tiles as well as reorder their position on the dashboard.
There are several ways to accomplish this behavior. My initial implementation was to use native Gesture recognition. However, the amount of overhead required to allow the additional tiles to recognize the current gesture was more than I wanted to try and implement as part of the app's MVP. With that in mind, I turned to .onDrag
and .onDrop
handlers. While the handlers themselves didn't have what I needed out of the box, defining a custom DropDelegate
was an easy enough solution.
This post provides an overview of that implementation. It's not a step-by-step tutorial, but it should suffice for those with even a passing knowledge of Swift and SwiftUI.
Let's start with a simple data model:
struct DashboardTile: Identifiable, Equatable {
static func ==(lhs: DashboardTile, rhs: DashboardTile) -> Bool {
return lhs.id == rhs.id
}
var id: String {
self.key
}
var key: String
var label: String
}
This model provides a simple data structure with two properties, key
and label
. With our data structure in hand, we need to define our DroppableTileDelegate
that will handle override the default drag-and-drop handler's behavior.
struct DroppableTileDelegate<DashboardTile: Equatable>: DropDelegate {
let tile: DashboardTile
var listData: [DashboardTile]
@Binding var current: DashboardTile?
@Binding var hasLocationChanged: Bool
var moveAction: (IndexSet, Int) -> Void
func dropEntered(info: DropInfo) -> Void {
guard tile != current, let current = current else { return }
let from = listData.firstIndex(of: current)
let to = listData.firstIndex(of: tile) else { return }
hasLocationChanged = true
if listData[to] != current {
moveAction(
IndexSet(integer: from), to > from ? to + 1 : to
)
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
hasLocationChanged = false
current = nil
return true
}
}
With the DroppableTileDelegate
defined, we can define the view that our layout will live in. Note that the tiles and the binding representing the currently dragged tiles are passed into this view from the parent view. This allows data to be shared with other views if necessary. The default drag preview behavior is overwritten by the preview
closure that follows the definition of the .onDrag
handler. If we don't override this with our UI, the default hard-edged previews will be shown. For some apps that might be all that is necessary, but since my dashboard tiles had rounded edges, it required that a custom overlay that used contentShape
to trim those corners be used.
struct DashboardTileView<Content: View, DashboardTile: Identifiable & Equatable>: View {
@Binding var tiles: [DashboardTile]
@Binding var draggingTile: DashboardTile?
let content: (DashboardTile) -> Content
let moveAction: (IndexSet, Int) -> Void
@State private var hasLocationChanged: Bool = false
init(
tiles: Binding<[DashboardTile]>,
draggingTile: Binding<DashboardTile?>,
@ViewBuilder content: @escaping (DashboardTile) -> Content,
moveAction: @escaping (IndexSet, Int) -> Void
) {
self._tiles = tiles
self.content = content
self.moveAction = moveAction
self._draggingTile = draggingTile
}
let screenWidth = UIScreen.main.bounds.width
var body: some View {
VStack {
ForEach(tiles) { tile in
VStack {
content(tile)
.overlay(
draggingTile == tile
? RoundedRectangle(cornerRadius: 17).fill(.thinMaterial)
: nil
)
.onDrag {
draggingTile = tile
return NSItemProvider(object: "\(tile.id)" as NSString)
} preview: {
content(tile)
.frame(minWidth: screenWidth - 20, minHeight: 80)
.contentShape(.dragPreview, RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.onDrop(
of: [UTType.text],
delegate: DroppableTileDelegate(
tile: tile,
listData: tiles,
current: $draggingTile,
hasLocationChanged: $hasLocationChanged
) { from, to in
withAnimation {
moveAction(from, to)
}
}
)
}
}
}
}
}
The custom drop delegate that we defined above is passed into the delegate
parameter of the .onDrop
handler. A trailing closure also allows for the view to be animated when the moveAction
is invoked. From there, all we need to do is define the action itself.
func moveTile(_ from: IndexSet, _ to: Int) -> Void {
layout.move(fromOffsets: from, toOffset: to)
}
With the above all defined (and compiling without error), all we have to do is reference the DashboardTileView
and pass in the associated bindings.
DashboardTileView(tiles: $layout, draggingTile: $draggingTile) { tile in
SimpleTile(tile: tile)
} moveAction: { from, to in
moveTile(from, to)
}
Voila!