Drag and Drop Tutorial for SwiftUI

Learn how to use the drag and drop API in SwiftUI by building your own simple iPadOS and iOS bug reporting app. By Warren Burton.

4.4 (8) · 1 Review

Download materials
Save for later
Share

Drag and drop is the concept of picking up a UI element and dropping it somewhere else. Drag and drop supports the metaphor of your app as a surface to be manipulated and touched and not just a set of buttons to be poked at.

You’re not required to implement drag and drop, but doing so enhances the native feeling of your app and increases integration with iOS as a whole. You should provide drag and drop wherever you’d expect to be able to move an object.

SwiftUI provides first-class support for drag and drop in the List collection, as well as the API for any view to support drag and drop.

In this tutorial, you’ll build a simple bug-reporting iPad app that helps you track detected bugs and export a list of them in a shareable format. Along the way, you’ll learn how to:

  • Use the implicit collection drag and drop API.
  • Implement explicit drag and drop in any SwiftUI view.
  • Render a view to an image to allow inter-app dragging.
Note: You should be comfortable with reading and creating SwiftUI code to do this tutorial. If this is your first experience with SwiftUI, you might like to try another starter tutorial first, such as SwiftUI: Getting Started.

Getting Started

Download the starter material using the Download Materials button at the top or bottom of the tutorial. In the starter folder, locate and open PointyBug.xcodeproj. PointyBug is an app for you to log UI/UX bugs against any app you might be building or testing.

Demo simulator window with field for describing bugs

The starter project is a semi-complete app to which you’ll add drag and drop actions. To keep you focused on the drag and drop elements, the CRUD logic is complete and resides in a class called BugController.

Project files navigator

BugController manages a model array of Bug objects and associated DisplayElement objects. The BugController class conforms to @ObservableObject and broadcasts model changes to the SwiftUI view stack. A single BugController is instantiated in AppMain and injected into the view hierarchy as an @EnvironmentObject, allowing all the views to access the instance.

Select the Simulator iPad Pro (9.7-inch) in the active scheme settings:

Select run target

Build and run. Then, rotate the simulated device to landscape. You can see a Master-Detail split view:

Master-Detail split view of application

Tap Add Bug to add a new bug to the list. You can edit the description text, but tapping the photo button in the navigation bar doesn’t do anything. That seems like a bug!

Take a screenshot by selecting Device ▸ Trigger Screenshot from the menu. You’ll use that image later in the tutorial.

Now, you’ll learn how to add drag and drop to a SwiftUI collection view.

Adding Drop Support to a Collection

SwiftUI has a List collection view. You use ForEach in a List to iterate over a collection of data items. ForEach conforms to DynamicViewContent which provides three methods to deal with drop, move and delete operations.

In this section, you’ll update the master List to accept images and text from any dragging source.

In the Project navigator, inside the PointyBug/Views group, open BugListView.swift. Next, open the SwiftUI Preview Canvas with the key combination Command-Option-Return. Resume the preview with the key combination Command-Option-P. You have a List and a Button at the bottom of the screen. Each item in the list is a NavigationLink that opens an EditorView.

Activate SwiftUI preview update

In BugListView.swift inside List, add this modifier to the closing brace of ForEach:

.onInsert(of: [.plainText, .image]) { index, itemProviders in
  // insert here later 
}

You can make it easier to see where to add the code by “folding” the ForEach statement. To do this, click shaded area to the left of the line containing ForEach. Or, place your cursor anywhere inside the ForEach closure and select Editor ▸ Code Folding ▸ Fold from the menu.

click in the ribbon to fold the code

Although the word drop isn’t mentioned, you told List to accept pasted content of UTType.image and UTType.plainText. When the content is dropped, you run a closure with parameters of index that describe where in the list to put the content and itemProviders that serves as an array of NSItemProvider objects.

What are these UTType types? Before you finish your list drop implementation, you’ll go down a side road for a while.

What Are Uniform Type Identifiers?

A Uniform Type Identifier (UTI) is a way of describing the types of data your system uses. UTI’s are a class hierarchy describing data formats:

Organizational chart of UTI types

By using UTType.image, you told your list to accept any pasteboard content that looks like image data, such as a JPEG (UTType.jpeg) or a PNG (UTType.png). Both these types conform to UTType.image. There’s a UTType declared for most data types you’ll encounter during iOS development.

Later in the tutorial, you’ll create a new UTType for private use in the app.

Decoding Dropped Data

In this section, you’ll find out how to extract the dropped data and add that data to your model. The array of NSItemProvider objects wraps the data that’s passed to the closure in onInsert via the itemProviders parameter.

The first thing to do is create an object to deal with this unwrapping task. You never want to place model management logic inside your SwiftUI views.

Create a new Swift file called ContentDropController.swift in the Controller group.

First, define the class by placing this declaration at the top of ContentDropController.swift:

import SwiftUI

class ContentDropController {
  let bugController: BugController
  let bugID: BugID?

  init(bugID: BugID?, bugController: BugController) {
    self.bugID = bugID
    self.bugController = bugController
  }
}

ContentDropController talks to BugController. You want to know about the specific bug you’re updating, so you pass the bugID.

Next, you’ll create some utility methods that unwrap an NSItemProvider and ask BugController to create or update an existing bug.

Now, add this extension to the end of the file:

extension ContentDropController {
  private func unwrapImage(
    from provider: NSItemProvider,
    completion: @escaping (UIImage?) -> Void
  ) {
    _ = provider.loadObject(ofClass: UIImage.self) { image, error in
      var unwrappedImage: UIImage?
      defer {
        completion(unwrappedImage)
      }
      if let error = error {
        print("image drop failed -", error.localizedDescription)
      } else {
        unwrappedImage = image as? UIImage
      }
    }
  }
}

In unwrapImage(from:completion:), you ask an NSItemProvider that holds a UIImage to load that image. You then call completion with that optional image.

Next, add this method to the same extension:

private func createBugWithImage(
  from provider: NSItemProvider,
  at dropIndex: Int
) {
  // 1
  unwrapImage(from: provider) { image in
    if let image = image {
      // 2
      let imageName = ImageController.store(image)
      // 3
      var bug = self.bugController.createBug()
      bug.imageName = imageName
      // 4
      DispatchQueue.main.async {
        self.bugController.insert(bug, at: dropIndex)
      }
    }
  }
}

createBugWithImage(from:at:) creates a bug that has an image:

  1. First, you use unwrapImage(from:completion:) to get the image from NSItemProvider.
  2. Store image as Data in the app documents folder and receive the storage name back.
  3. Create a new Bug and set the image’s name.
  4. Ask BugController to add the new bug to the model list at the requested index. Because the drop request occurs off the main queue, you need to dispatch the call to BugController back to the main queue.

Finally, add these two methods to the extension:

private func updateBugWithImage(
  from provider: NSItemProvider,
  at dropIndex: Int
) {
  guard 
    let bugID = bugID,
    let selectedBug = self.bugController.bug(withID: bugID) 
  else {
    return
  }

  unwrapImage(from: provider) { image in
    guard 
      let image = image,
      let imageName = ImageController.store(image) 
    else {
      return
    }
    var selectedBug = selectedBug
    selectedBug.imageName = imageName
    DispatchQueue.main.async {
      self.bugController.update(selectedBug)
    }
  }
}

updateBugWithImage(from:at:) handles the case where you’re adding an image to a bug that already exists. You also store the dropped image, but instead of creating a new bug, you update an existing one.

Finally, add the following method to the extension:

private func createBugWithString(
  from provider: NSItemProvider,
  at dropIndex: Int
) {
  _ = provider.loadObject(ofClass: String.self) { text, _ in
    var newBug = self.bugController.createBug()
    newBug.text = text ?? ""
    DispatchQueue.main.async {
      self.bugController.insert(newBug, at: dropIndex)
    }
  }
}

createBugWithString(from:at:) is like createBugWithImage(from:at:). You ask an NSItemProvider that holds a String to load that string. Then you create a new bug with that string.

You now have a way to create and update bugs with dropped images. Next, you’ll add a way to handle a list of different item providers and create or update bugs accordingly.