Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 094 / 100

Drag and drop in SwiftUI: a hands-on guide for engineers

Move content between views and apps.

Expona Admin8 min read

Drag and drop capabilities transform how users interact with your iOS app, especially on iPad where multi-touch gestures shine. We'll walk through building a practical drag and drop system in SwiftUI that moves content between views and even across app boundaries. By the end of this guide, you'll have the patterns needed to handle real-world scenarios like reordering lists and accepting drops from external sources.

Why drag and drop matters in modern iOS development

Users expect fluidity. Whether they're reorganising a task list, moving items between categories, or dragging files into your app from Files or Mail, drag and drop has become table stakes for polished applications. SwiftUI's .draggable and .dropDestination modifiers, introduced in iOS 16, give us a declarative way to implement these interactions without wrestling with UIKit's UIDragInteraction and UIDropInteraction.

On iPad especially, drag and drop unlocks powerful multitasking workflows. Users can have your app in split view alongside Mail, drag a file or link into your content, and continue working seamlessly. Desktop-class features like this justify the investment in proper implementation.

.draggable and .dropDestination

The foundation of drag and drop in SwiftUI rests on two modifiers. .draggable marks a view as a source; .dropDestination marks a view as a target. Together, they handle the choreography of moving data around your interface.

Let's start with a concrete example: a task management app where users drag tasks between "To Do" and "Done" sections.

swift
import SwiftUI

struct Task: Identifiable, Codable {
    let id: UUID
    var title: String
    var isDone: Bool
}

struct TaskListView: View {
    @State private var todoTasks: [Task] = [
        Task(id: UUID(), title: "Review PR", isDone: false),
        Task(id: UUID(), title: "Update docs", isDone: false)
    ]
    @State private var doneTasks: [Task] = []

    var body: some View {
        HStack(spacing: 20) {
            TaskColumn(title: "To Do", tasks: $todoTasks, targetTasks: $doneTasks)
            TaskColumn(title: "Done", tasks: $doneTasks, targetTasks: $todoTasks)
        }
        .padding()
    }
}

struct TaskColumn: View {
    let title: String
    @Binding var tasks: [Task]
    @Binding var targetTasks: [Task]

    var body: some View {
        VStack(alignment: .leading) {
            Text(title)
                .font(.headline)

            VStack(spacing: 12) {
                ForEach($tasks) { $task in
                    TaskRow(task: $task)
                        .draggable(task)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(8)
            .dropDestination(
                for: Task.self,
                action: { droppedTasks in
                    for droppedTask in droppedTasks {
                        if let index = tasks.firstIndex(where: { $0.id == droppedTask.id }) {
                            let task = tasks.remove(at: index)
                            targetTasks.append(task)
                        }
                    }
                    return true
                }
            )
        }
    }
}

struct TaskRow: View {
    @Binding var task: Task

    var body: some View {
        HStack {
            Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
            Text(task.title)
                .strikethrough(task.isDone)
        }
        .padding()
        .background(Color.white)
        .cornerRadius(4)
    }
}

Notice how .draggable(task) tells SwiftUI what data to pass when this view is dragged. The .dropDestination modifier's action closure receives an array of dropped items and returns a Bool indicating success. We check the dragged task's ID, remove it from the source list, and add it to the target.

One key detail: by default, .draggable uses the view's appearance as a preview during the drag. If you need a custom drag preview, use the variant that accepts a preview parameter.

Transferable types: making your data drag-friendly

For drag and drop to work, SwiftUI needs to know how to serialize and deserialize your data. This is where the Transferable protocol enters the picture. It defines how your type converts to and from a format that can be dragged between apps or persisted.

In Swift 5.9+, conforming to Transferable is straightforward. The protocol requires you to define a transferRepresentation property that describes the formats your type supports.

swift
import SwiftUI

struct Article: Identifiable, Transferable {
    let id: UUID
    var title: String
    var url: URL
    var summary: String

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .init(filenameExtension: "article", conformingTo: .json))
        ProxyRepresentation(String.self) { article in
            "\(article.title)\n\(article.url)\n\(article.summary)"
        }
    }
}

struct ArticleListView: View {
    @State private var articles: [Article] = []

    var body: some View {
        VStack {
            List(articles) { article in
                VStack(alignment: .leading) {
                    Text(article.title)
                        .font(.headline)
                    Text(article.url.absoluteString)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                .draggable(article)
            }
            .dropDestination(
                for: Article.self,
                action: { droppedArticles in
                    articles.append(contentsOf: droppedArticles)
                    return true
                }
            )
        }
    }
}

Here, we define two transfer representations. The CodableRepresentation handles structured data between our app instances. The ProxyRepresentation creates a plain text fallback, so users can drag articles into Mail or Notes as readable text.

For even richer integration, you might add an .uniformType("com.myapp.article") representation, declaring a custom UTType that other apps can query and respond to. This is how drag and drop becomes truly interoperable.

Reorder lists with onMove

Reordering a list is such a common pattern that SwiftUI provides a dedicated modifier: .onMove. This works beautifully on iOS (with edit mode) and even better on iPad where drag reordering is more discoverable.

Here's how we'd add reordering to our article list:

swift
struct ArticleListView: View {
    @State private var articles: [Article] = []
    @State private var editMode = EditMode.inactive

    var body: some View {
        NavigationStack {
            List {
                ForEach(articles) { article in
                    VStack(alignment: .leading) {
                        Text(article.title)
                            .font(.headline)
                        Text(article.url.absoluteString)
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                }
                .onMove(perform: moveArticles)
            }
            .navigationTitle("Articles")
            .toolbar {
                EditButton()
            }
            .environment(\.editMode, $editMode)
        }
    }

    private func moveArticles(from source: IndexSet, to destination: Int) {
        articles.move(fromOffsets: source, toOffset: destination)
    }
}

When edit mode is active, users see the drag handles (three horizontal lines) on the left. Dragging reorders the array directly. On iPad, the drag handles appear even in normal mode, making reordering a discoverable feature that doesn't require entering edit mode.

The onMove modifier handles all the gesture plumbing for you. The closure receives source and destination index sets, and you update your state accordingly. SwiftUI then animates the list changes automatically.

Great on iPad

iPad is where drag and drop truly shines in SwiftUI. The larger screen, external keyboard and trackpad support, and multitasking capabilities (split view, slide over, stage manager) mean users expect desktop-class interactions.

Several patterns elevate your iPad experience. First, always show drag handles or make drag sources visually obvious. Second, test your app in split view: run your app alongside another, and verify that dragging between them works as expected. Third, ensure your drop targets provide clear visual feedback—a destination highlight or a drop area indicator.

swift
struct ContentView: View {
    @State private var items: [Item] = []
    @State private var isDropTargeted = false

    var body: some View {
        VStack {
            Text("Drag content here")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(isDropTargeted ? Color.blue.opacity(0.2) : Color(.systemGray6))
                .overlay(
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(isDropTargeted ? Color.blue : Color.clear, lineWidth: 2)
                )
                .dropDestination(
                    for: Item.self,
                    action: { droppedItems in
                        items.append(contentsOf: droppedItems)
                        return true
                    },
                    isTargeted: { isTargeted in
                        isDropTargeted = isTargeted
                    }
                )
        }
        .padding()
    }
}

Notice the isTargeted parameter in .dropDestination. This closure fires as the user drags over your target, letting you highlight the drop zone. Users immediately understand where they can drop and what will happen.

On iPad, also consider the context. If your app appears in split view or slide over, your drop zones might be constrained. Design them with smaller screens in mind, and always provide enough feedback so users know they're in the right place.

Common mistakes

Forgetting to conform to Transferable. If your custom type doesn't conform to Transferable, the .draggable and .dropDestination modifiers won't compile. Always define transferRepresentation; if your type is already Codable, this is often one line.

Not handling drop action return values correctly. The drop action closure must return a Bool. Returning false signals failure, which can confuse users if they think the drag succeeded. Return true only after you've successfully updated your state.

Ignoring the isTargeted callback. Without visual feedback, users don't know if they're dropping in the right place. Always use the isTargeted parameter to highlight drop zones.

Testing only on iPhone. Drag and drop workflows differ on iPhone (where gesture space is constrained) and iPad (where they're celebrated). Test both form factors, especially if your app supports both.

Overcomplicating Transferable representations. Start simple: if your type is Codable, use CodableRepresentation. Add other formats only if you have a concrete reason—like interoperability with Mail or rich text editing.

Key takeaways

  • .draggable and .dropDestination are your primary tools; they handle the declarative layer of drag and drop in SwiftUI.
  • Conform your data types to Transferable and define transferRepresentation to control how data serializes during drag operations.
  • Use .onMove for list reordering; it provides native edit mode integration and drag handle UI automatically.
  • Provide visual feedback via the isTargeted parameter so users know where they can drop.
  • iPad is the platform where drag and drop really matters; test in split view and design drop zones that work on larger screens.
  • Return true from your drop action only when the operation succeeds, and always handle state updates synchronously.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles