Implementing drag and drop with the SwiftUI modifiers

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.

0:00
/0:08

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:

  1. Swift Object → JSON (using JSONEncoder)
  2. JSON → NSItemProvider (for system transfer)
  3. NSItemProvider → JSON (when dropped)
  4. 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 of NSItemProviders;
  • 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:

  1. User starts drag -> onDrag creates NSItemProvider with JSON data.
  2. System handles drag -> Visual feedback and drag preview.
  3. User drops over target -> onDrop receives NSItemProvider array.
  4. App decodes JSON -> Converts back to Swift objects.
  5. 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 (enteredexitedupdated). 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.