dropConfiguration in SwiftUI: Live Drag-and-Drop Targeting Without the Coordinate-Space Guesswork
Ever build a Trello-style board and hit the wall where dropDestination can only tell you "something got dropped here" — not which column the user's finger is hovering over right now? Live column targeting, the kind where a card visibly snaps to the column you're over before you even let go, meant rolling your own tracking on top of APIs that were never built for it.
dropDestination(for:action:) and the older onDrop(of:isTargeted:perform:) both resolve at one moment: the drop. Along the way, developers ran into DropInfo.location returning coordinates in an inconsistent space — sometimes reading like local coordinates, sometimes global, enough that Apple's own developer forums have threads dedicated to working around it. iOS 27 replaces the single accept-or-reject callback with something that runs continuously for the life of the drag: dropConfiguration.
What dropConfiguration actually does
nonisolated func dropConfiguration(
_ configuration: @escaping (DropSession) -> DropConfiguration
) -> some View
The closure hands you a DropSession, and — unlike the old DropInfo — its location: CGPoint is documented as being in the drop destination's local coordinate space, full stop. No more guessing whether you're getting global or local coordinates depending on view nesting. DropSession also exposes suggestedOperations: DropOperation.Set, telling you what the drag source is willing to do (move, copy, and so on), and itemsCount and size for the destination view.
You return a DropConfiguration, built from a DropOperation — .move, .copy, or .forbidden. That last case is the real shift: rejecting a drop used to mean returning nil or false from a boolean-flavored callback. Now it's an explicit operation, the same currency the system uses to draw the little plus-or-arrow badge under the user's finger.
dropConfiguration doesn't replace dropDestination — the two compose. dropDestination(for:action:) still declares what type you accept and what happens the instant a drop commits. dropConfiguration runs during the drag, continuously, to describe the live operation and (as a side effect) let you update whatever UI state reacts to "which column am I over right now."
Building live column targeting
Here's a Kanban board that highlights the column under the drag and blocks drops the game rules don't allow:
struct Card: Identifiable, Codable, Transferable {
let id: UUID
var title: String
var isLocked: Bool
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .cardItem)
}
}
struct BoardColumn: Identifiable {
let id: Int
var title: String
var cards: [Card]
}
struct KanbanBoard: View {
@State private var columns: [BoardColumn]
@State private var targetColumnID: Int?
private let columnWidth: CGFloat = 220
private let spacing: CGFloat = 16
var body: some View {
HStack(spacing: spacing) {
ForEach(columns) { column in
ColumnView(column: column, isTargeted: column.id == targetColumnID)
.frame(width: columnWidth)
}
}
.dropDestination(for: Card.self) { droppedCards, _ in
guard let targetColumnID else { return false }
move(droppedCards, into: targetColumnID)
return true
}
.dropConfiguration { session in
let index = columnIndex(for: session.location)
let column = columns.indices.contains(index) ? columns[index] : nil
targetColumnID = column?.id
let allowed = session.suggestedOperations.contains(.move)
&& column != nil
&& !column!.cards.contains { $0.isLocked }
return DropConfiguration(operation: allowed ? .move : .forbidden)
}
}
private func columnIndex(for location: CGPoint) -> Int {
let alignedX = location.x - spacing / 2
return max(0, Int(alignedX / (columnWidth + spacing)))
}
private func move(_ cards: [Card], into columnID: Int) {
// Remove `cards` from their source column, append to `columnID`.
}
}
The column-index arithmetic in columnIndex(for:) is nothing fancier than dividing an x-position by a fixed column width, but the interesting part is that it no longer has to defend against whatever coordinate space the view happens to sit in — session.location is guaranteed local. targetColumnID gets written on every call, which is exactly what drives the live highlight — dropConfiguration isn't just an operation picker, it's also your hook for "what should light up right now."
Edge cases and gotchas
The closure runs a lot. Apple's own documentation is blunt about it: it's "called frequently to allow specifying different operations for different drop locations," and warns not to do expensive work inside it. Treat it like a layout pass, not a network call — the column-index math above is cheap on purpose. If you need something heavier (fetching lock state from a database, say), cache it before the drag starts rather than computing it per-callback.
dropConfiguration alone doesn't move anything. It's easy to assume returning .move performs the move. It doesn't — it only sets the operation and cursor affordance during the drag. The actual data transfer still happens in dropDestination's action closure when the user releases. Skip that half and you'll get a nice live highlight over a board where nothing ever actually moves.
The destination: initializer isn't a general-purpose drop target. DropConfiguration also has init(operation:destination:), but that destination is typed as ReorderDifference<ItemID, CollectionID>.Destination — it's wired specifically to the new reorderable()/reorderContainer() reordering system (think: reordering cards within a single Solitaire pile), not a free-form "drop into any column" target. For cross-collection moves like this Kanban board, tracking the target in your own @State — as above — is the right level, not the reorder-specific initializer.
Summary
dropConfiguration turns drag-and-drop from a single end-of-drag verdict into a continuous signal you can read throughout the gesture, with coordinates that are finally guaranteed to be local. Pair it with dropDestination for the actual commit, keep the closure cheap since it fires on every movement, and reach for your own state — not the reorder-specific destination initializer — when you're targeting arbitrary drop zones instead of reordering a single collection.