Drag and Drop Editable Lists: Tutorial for SwiftUI

Grow your SwiftUI List skills beyond the basics. Implement editing, moving, deletion and drag-and-drop support. Then, learn how custom views can support draggable content. By Bill Morefield.

3.6 (9) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Preparing for Drag Support

SwiftUI — and UIKit — use NSItemProvider to represent objects in a drag-and-drop session. While some built-in types, such as NSString, NSURL, UIImage and UIColor, support this out of the box, you’ll need to do a little bit of extra work to offer TodoItem to the drag-and-drop gods.

To start, you’ll use NSItemProviderWriting to convert the TODO item into a data representation upon request.

Open TodoItem.swift and add the following code to the bottom of the file:

extension TodoItem: NSItemProviderWriting {
  // 1
  static let typeIdentifier = "com.raywenderlich.DragTodo.todoItem"

  // 2
  static var writableTypeIdentifiersForItemProvider: [String] {
    [typeIdentifier]
  }

  // 3
  func loadData(
    withTypeIdentifier typeIdentifier: String,
    forItemProviderCompletionHandler completionHandler:
      @escaping (Data?, Error?) -> Void
  ) -> Progress? {
    // 4
    do {
      // 5
      let encoder = JSONEncoder()
      encoder.outputFormatting = .prettyPrinted
      completionHandler(try encoder.encode(self), nil)
    } catch {
      // 6
      completionHandler(nil, error)
    }

    // 7
    return nil
  }
}

In the code above, you:

  1. Define a new constant called typeIdentifier containing a string unique to your project. This becomes the item type used to distinguish your TODO item from other objects that NSItemProvider represents.
  2. Implement writableTypeIdentifiersForItemProvider for NSItemProviderWriting and return your unique type identifier.
  3. Implement loadData(withTypeIdentifier:forItemProviderCompletionHandler:). The framework will call this method to get the representation of this object, when necessary. In your implementation, typeIdentifier will always come from writableTypeIdentifiersForItemProvider. Because you only listed one type, you don’t need to worry about checking this argument.
  4. Define a do-catch statement to catch any errors while encoding.
  5. Create a JSONEncoder instance, set it to produce human-friendly output and then attempt to encode the current object. If encoding succeeds, you call the completion handler for the block and pass the encoded object to it. You’re able to pass the TodoItem into JSONEncoder because it already conforms to Codable.
  6. If anything went wrong during the encoding, call the completion handler, but use the error object instead.
  7. Return nil for Progress because your encode operation was not asynchronous. It doesn’t need to report any progress back to the caller because it has already finished by this point.
Note: For more information about Codable, see our tutorial, Encoding and Decoding in Swift.

Now that the system knows how to handle TodoItem, you’ll implement drag support.

Making a View Draggable

Once you have a supported model object, adding drag support to a SwiftUI view is simple. All you need to do is implement onDrag(_:) on the view you want to be draggable.

Open ActiveTodoView.swift and add the following modifier to TodoItemView:

.onDrag {
  NSItemProvider(object: item)
}

This lets TodoItemView support drag and drop gestures. The closure returns the draggable data.

Note: Unlike onDelete(perform:) and onMove(perform:), you can apply onDrag(_:) to any view. Take care to add this modifier to the TodoItemView within ForEach, rather than where the other two modifiers are located.

Build and run, but this time, use an iPad or an iPad simulator. In the Active list, hold your finger down on an item until you see it lift out of the List, then drag it around inside the app. It will look like this:

An item in the Drag Todo app being dragged around the screen

Why on an iPad?

Currently a SwiftUI List isn’t compatible with drag and drop on the iPhone. If you were to try this on an iPhone, unfortunately, nothing would happen.

Until a future version of SwiftUI fixes this problem, the simplest fix is to get rid of the List. Open ContentView.swift and replace List around the active and completed TODO items with a ScrollView.

Build and run back on the iPhone:

An item in Drag Todo being dragged around an iPhone screen

You can now drag items on an iPhone, but there’s a cost to this simple fix. You lost the editing functionality you built in the first part of this tutorial.

Tap Edit and notice the indicators for deleting and moving items no longer appear. That’s because only List supports onDelete(perform:) and onMove(perform:).

The Edit functionality no longer works after switching from List to ScrollView

Hopefully, future versions of SwiftUI will let you have both at the same time. For now, you’ll need to choose one or the other on the iPhone.

For the rest of this tutorial, you can ignore the loss of editing support in List.

Preparing for Drop Support

Since you’ve implemented NSItemProviderWriting to support dragging, you might have guessed you now need to implement NSItemProviderReading to implement dropping.

Open TodoItem.swift and add the following to the end of the file:

extension TodoItem: NSItemProviderReading {
  // 1
  static var readableTypeIdentifiersForItemProvider: [String] {
    [typeIdentifier]
  }

  // 2
  static func object(
    withItemProviderData data: Data,
    typeIdentifier: String
  ) throws -> TodoItem {
    // 3
    let decoder = JSONDecoder()
    return try decoder.decode(TodoItem.self, from: data)
  }
}

As you might expect, the implementation is similar to NSItemProviderWriting except it performs the conversion in the opposite direction. Here, you:

  1. Specify readableTypeIdentifiersForItemProvider, a list of the type identifiers this implementation can manage. Again, only specify the custom typeIdentifier from earlier.
  2. Let object(withItemProviderData:typeIdentifier:) receive the data along with the type identifier used when writing it. Since you only support one type of data, you can ignore this second parameter. This method returns TodoItem, which only works because TodoItem is a final class.
  3. Create a JSONDecoder instance and attempt to decode the provided data into the TodoItem type, then return it.

Unlike before, you have a bit more work to do before you can add drop support to a view. In the next section, you’ll look at what you need to do to offer a flexible approach to dropping an object.

Conforming to DropDelegate

The most flexible drop modifier for SwiftUI requires you provide a custom type conforming to DropDelegate.

Create a new file for the delegate. Highlight the Models group, then select File ▸ New ▸ File… ▸ Swift File and call it TodoDropDelegate.swift. Replace the contents with the following:

import SwiftUI

struct TodoDropDelegate: DropDelegate {
  // 1
  @Binding var focusId: Int?

  // 2
  func performDrop(info: DropInfo) -> Bool {
    // 3
    guard info.hasItemsConforming(to: [TodoItem.typeIdentifier]) else {
      return false
    }

    // 4
    let itemProviders = info.itemProviders(for: [TodoItem.typeIdentifier])
    guard let itemProvider = itemProviders.first else {
      return false
    }

    // 5
    itemProvider.loadObject(ofClass: TodoItem.self) { todoItem, _ in
      let todoItem = todoItem as? TodoItem

      // 6
      DispatchQueue.main.async {
        self.focusId = todoItem?.id
      }
    }

    return true
  }
}

Here’s what TodoDropDelegate does:

  1. Declares focusId as a binding of an optional Int, which you’ll use to pass the identifier of the dropped TODO item back up the view chain.
  2. Implements performDrop(info:), which the framework calls when the drop operation takes place. It receives a parameter of type DropInfo that contains information about the drop operation. It returns true if the drop is handled and false if it isn’t.
  3. Ensures something of the expected type is available with the guard statement. If not, return false.
  4. Returns a collection of items matching the specified type identifiers with itemProviders(for:). You only care about one type identifier, so you pass it as the sole element in the array. You’ll only process a single item, obtained using first on the results. If you wanted to deal with many items, you’d need to loop over them.
  5. You take the resulting NSItemProvider and call loadObject(ofClass:completionHandler:). It, in turn, will call through to the method you implemented as part of NSItemProviderReading.
  6. Inside the completion closure, you attempt to cast the object to TodoItem and assign its identifier to focusId. Be careful to update your binding only on the main thread by using DispatchQueue.