
Implementing drag and drop with the SwiftUI modifiers
Learn how to implement drag and drop features within your SwiftUI apps.
When you find yourself wanting users to move data between different parts of your app, like dragging photos into albums, moving tasks between lists, or reorganizing items in a collection, you quickly realize that simple tap gestures aren't enough. Users expect to be able to grab objects and physically move them around the screen, just as they do in the real world.
This is where SwiftUI’s onDrag(_:)
and onDrop(of:isTargeted:perform:)
modifiers come in. While they may look like simple view-moving tools, they’re really about data transfer: each draggable item is encoded into an NSItemProvider
, and drop zones decode it.
Example of onDrag and onDrop usage
This technology gives you:
- System-wide consistency: your app behaves like Files, Photos, and other native apps
- Cross-app compatibility: works automatically with Mail, Notes, Finder and other apps
- Natural user experience: users don't need to learn new interaction patterns
And overall, you should prefer onDrag(_:)
and onDrop(of:isTargeted:perform:)
over other alternatives when:
- You need data to be transferred, not just visual movement
- You want system-standard behavior
- You’re adding drag and drop to an existing app
- You work with complex data structures
Let's explore how to implement these modifiers in your apps, starting with simple examples.
The onDrag(_:)
modifier
Here's what a draggable view looks like:
Text("Drag me!")
.onDrag {
// We'll explain this step by step
}
In these brackets, the real implementation goes. Let's look at an example of it:
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct DraggableItemView: View {
let item: DragItem
var body: some View {
Text(item.text)
.padding()
.background(item.color.opacity(0.3))
.cornerRadius(8)
.onDrag {
// Return NSItemProvider with the item data
let encoder = JSONEncoder()
guard let data = try? encoder.encode(item) else {
return NSItemProvider()
}
let itemProvider = NSItemProvider()
itemProvider.registerDataRepresentation(
forTypeIdentifier: UTType.json.identifier,
visibility: .all
) { completion in
completion(data, nil)
return nil
}
return itemProvider
}
}
}
That might look overwhelming at first, but let's break it down piece by piece.
The Uniform Type Identifiers framework
First, notice we are importing the UniformTypeIdentifiers
framework. You'll need it in every file that uses drag-and-drop. It's used to provide uniform type identifiers (UTTypes
) that describe file types for storage or transfer.
You can think of UTTypes
as "data passports", they tell the system exactly what kind of data you're carrying. Typical examples can be:
.text
: plain text.image
: images.json
: structured data.url
: web links
When you drag something, the system needs to know if this data is safe to be transferred, which apps can handle this type of data and how it should be processed when dropped.
In our example, we use UTType.json.identifier
because we're sending structured data that's been converted to the JSON format.
NSItemProvider
Think of NSItemProvider
as a secure box that carries your data. You can't simply hand Swift objects directly between views; they must be packaged properly for the system to handle them safely.
let itemProvider = NSItemProvider()
itemProvider.registerDataRepresentation(
forTypeIdentifier: UTType.json.identifier,
visibility: .all
) { completion in
completion(data, nil)
return nil
}
return itemProvider
It's not possible to pass Swift objects directly because they live in the app's memory space. When you drag between views (or even between apps), the system needs a universal format that's safe and compatible everywhere, and that's where NSItemProvider
comes in.
In the example above, we converted data to JSON. That's because DragItem
is a custom object and it can't travel directly. So JSON acts as the common language that any part of the system can understand.
The conversion process looks like:
- Swift Object → JSON (using
JSONEncoder
) - JSON → NSItemProvider (for system transfer)
- NSItemProvider → JSON (when dropped)
- JSON → Swift Object (using
JSONDecoder
)
let encoder = JSONEncoder()
guard let data = try? encoder.encode(item) else {
return NSItemProvider()
}
This is the part of the code that operates the conversion in JSON.
Now that the code is able to make items draggable, we need somewhere to drop them.
The onDrop(of:isTargeted:perform:)
modifier
Here's what the modifier looks like:
.onDrop(of: [.json], isTargeted: $isTargeted) { providers, location in
// Handle the drop
}
Here's a complete example of implementation:
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct DropZoneView: View {
@Binding var droppedItems: [DragItem]
@Binding var isTargeted: Bool
var body: some View {
VStack {
if droppedItems.isEmpty {
Text("Drop items here")
.foregroundColor(.secondary)
} else {
ForEach(droppedItems) { item in
Text(item.text)
.padding(.horizontal)
.background(item.color.opacity(0.3))
.cornerRadius(6)
}
}
}
.frame(width: 200, height: 300)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isTargeted ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.strokeBorder(
isTargeted ? Color.blue : Color.gray,
lineWidth: isTargeted ? 2 : 1,
antialiased: true
)
)
.onDrop(of: [.json], isTargeted: $isTargeted) { providers, location in
handleDrop(providers: providers)
}
}
}
The isTargeted
parameter
Users need to know where they can drop items. The isTargeted
binding automatically becomes true
when a dragged item is over the drop zone.
Notice how it's used in the background:
.fill(isTargeted ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.strokeBorder(
isTargeted ? Color.blue : Color.gray,
lineWidth: isTargeted ? 2 : 1
)
When isTargeted
is true, the drop zone changes color and gets a thicker border, giving users clear visual feedback that this is where they can drop their item.
The Drop Process
.onDrop(of: [.json], isTargeted: $isTargeted) { providers, location in
handleDrop(providers: providers)
}
Let's break down the parameters:
of: [.json]
: Declares that only JSON data is accepted;isTargeted: $isTargeted
: Binding for visual feedback;providers
: Array ofNSItemProviders
;location
: Exact drop coordinates (useful for positioning).
The function handleDrop
is the one that converts the dropped data back into Swift objects. Here is an example of how it can be implemented:
private func handleDrop(providers: [NSItemProvider]) -> Bool {
guard let provider = providers.first else { return false }
provider.loadDataRepresentation(forTypeIdentifier: UTType.json.identifier) { data, error in
guard let data = data,
let item = try? JSONDecoder().decode(DragItem.self, from: data) else {
return
}
DispatchQueue.main.async {
droppedItems.append(item)
}
}
return true
}
Let's see this code in more detail:
loadDataRepresentation
: Opens the "box" (NSItemProvider);JSONDecoder().decode
: Converts JSON back to Swift object;DispatchQueue.main.async
: Updates UI on main thread.
This is asynchronous because loading data from NSItemProvider can take time, especially with large objects or cross-app transfers. The system handles this asynchronously to keep your UI responsive.
Structuring your data to support drag and drop
To make the objects draggable, we need to work with custom objects, which requires a proper data structure.
In the code above, we used DragItem
. Let's see how it's created.
import SwiftUI
struct DragItem: Identifiable, Codable {
let id: Int
let text: String
let color: Color
enum CodingKeys: String, CodingKey {
case id, text, colorName
}
init(id: Int, text: String, color: Color) {
self.id = id
self.text = text
self.color = color
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
text = try container.decode(String.self, forKey: .text)
let colorName = try container.decode(String.self, forKey: .colorName)
color = Color(colorName) ?? .blue
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(text, forKey: .text)
try container.encode(colorName, forKey: .colorName)
}
private var colorName: String {
switch color {
case .blue: return "blue"
case .green: return "green"
case .orange: return "orange"
default: return "blue"
}
}
}
And now, let's break it down:
Identifiable
: SwiftUI needs this for list management and animations;Codable
: Enables JSON conversion for data transfer;- Custom
init
: Clean way to create objects; CodingKeys
enum: Controls how properties map to JSON field names;- Custom
encode
/decode
: Handles special cases like Color.
About the last point, SwiftUI’s Color
can’t be directly encoded to JSON, so we convert it to a string on encode and back to Color
on decode.
private var colorName: String {
switch color {
case .blue: return "blue"
case .green: return "green"
case .orange: return "orange"
default: return "blue"
}
}
And to do so, we need a helper extension:
extension Color {
init?(_ name: String) {
switch name {
case "blue": self = .blue
case "green": self = .green
case "orange": self = .orange
default: return nil
}
}
}
The complete implementation
Here's how you'd use both draggable items and drop zones in a complete view:
struct ContentView: View {
@State private var sourceItems: [DragItem] = [
DragItem(id: 1, text: "Drag me!", color: .blue),
DragItem(id: 2, text: "Move me!", color: .green),
DragItem(id: 3, text: "Transfer me!", color: .orange)
]
@State private var droppedItems: [DragItem] = []
@State private var isTargeted = false
var body: some View {
HStack(spacing: 40) {
// Source area
VStack(alignment: .leading) {
Text("Source Items")
.font(.headline)
VStack(spacing: 10) {
ForEach(sourceItems) { item in
DraggableItemView(item: item)
}
}
}
// Drop zone
VStack(alignment: .leading) {
Text("Drop Zone")
.font(.headline)
DropZoneView(
droppedItems: $droppedItems,
isTargeted: $isTargeted
)
}
}
.padding()
}
}
To recap, the data flow follows this schema when using onDrag
and onDrop
modifiers:
- User starts drag ->
onDrag
creates NSItemProvider with JSON data. - System handles drag -> Visual feedback and drag preview.
- User drops over target ->
onDrop
receives NSItemProvider array. - App decodes JSON -> Converts back to Swift objects.
- UI updates -> New data appears in drop zone.
Other advanced modifiers
Beyond the basics, SwiftUI also provides advanced options for customizing drag previews and handling complex drop logic.
The onDrag(_:preview:)
modifier
If you want a custom drag appearance, use the preview version:
.onDrag {
// return NSItemProvider
} preview: {
Text(item.text)
.padding()
.background(item.color)
.cornerRadius(8)
.scaleEffect(1.1) // Slightly larger preview
}
This modifier can be used when the default preview (snapshot of your view) isn't what you want users to see while dragging.
The onDrop(of:delegate:)
modifier
For more complex drop behavior, you can use a delegate:
.onDrop(of: [.json], delegate: MyDropDelegate())
The delegate pattern gives you finer control: validation before accepting, custom animations, and handling multiple phases (entered
, exited
, updated
). Use it for complex drop behaviors.
And that’s it! With SwiftUI’s drag and drop, you can make interactions more intuitive and engaging. By combining drag-and-drop items, validating targets, and providing clear feedback, you create a smooth experience that feels natural to users and brings your interface to life.