Using Drag and Drop on UITableView for reorder Jul 26 2019

If we use custom cells for a UITableView, and we want to re-order using drag and drop, we need to make our cell model conform to NSItemProviderWriting, NSItemProviderReading and NSObject. To move an object using drag and drop, we need to to be able to serialize and deserialize it. Also, we need to have a drag/drop delegate. Usually, this could be our ViewController.

First, let’s set up our delegate.

UITableViewDragDelegate and UITalbeViewDropDelegate

If we are using our ViewController as the delegate for the drag and drop we need to conform to the protocols UITableViewDragDelegate and UITableViewDropDelegate. To comply with the protocols, we need the following methods:

itemsForBeginning - This method returns an array. The elements on the array represent the cells being dragged. As you see (in the code below), they come from an NSItemProvider. In turn, the NSItemProvider expects an object that implements NSItemProviderWriting.

1
2
3
4
5
6
7
8
9
10
11
// MARK: Drag and Drop Delegate

func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {

  let counter = counters[indexPath.row]
    let itemProvider = NSItemProvider(object: counter)

    let dragItem = UIDragItem(itemProvider: itemProvider)

    return [dragItem]
}

dropSessionDidUpdate - This method indicates that we want to “accomplish” (or our “intent”) for the drop action.

1
2
3
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
  return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}

perforDropWith - This is the method is in charge of the drop action. Note that the drop action could occur asynchronously, also remember that we always use the main queue when handling the UI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
  let insertionIndex: IndexPath
    if let indexPath = coordinator.destinationIndexPath {
      insertionIndex = indexPath
    } else {
      let section = tableView.numberOfSections - 1
        let row = tableView.numberOfRows(inSection: section)
        insertionIndex = IndexPath(row: row, section: section)
    }

  for item in coordinator.items {
    guard let sourceIndexPathRow = item.sourceIndexPath?.row else { continue }
    item.dragItem.itemProvider.loadObject(ofClass: Counter.self) { (object, error) in
      DispatchQueue.main.async {
        if let counter = object as? Counter {
          self.counters.remove(at: sourceIndexPathRow)
            self.counters.insert(counter, at: insertionIndex.row)
            tableView.reloadData()
        } else {
          return
        }
      }
    }
  }
}

That’s all we are going to do on our drag and drop delegate.

Let’s now review the changes we need to make to our model. Start by making sure we are implementing the following protocols: NSItemProviderWriting, NSItemProviderReading and NSObject.

1
2
public class Counter: NSObject, Codable, NSItemProviderWriting, NSItemProviderReading {
//...

To comply with NSItemProviderReading we will need to implement the following method:

1
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {

In the NSItemProviderReading protocol definition, the method returns Self (capital S). Self is used to represent the Class of the object implementing the protocol. When we implement the protocol, we replace the Self with our current model’s class (Counter in my case).

1
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Counter {

The class should be final because the compiler needs to make sure that it can statically (using the information in the code, not dynamically at runtime) infer what the Self object is. If the class were not final, and it is inherited, the child class will have a conflicting method. The child class will have one method inherited from the parent with the Self being the parent class and its method where Self is the child class. To avoid that problem, we need to make our class final. (see this StackOverflow thread)

1
2
public final class Counter: NSObject, Codable, NSItemProviderWriting, NSItemProviderReading {
//...

In my case I’ll be serializing and deserializing the Counter object using Codable, that is the reason you saw Codable in the class signature.

To implement the NSItemProviderWriting protocol, we need to indicate the format that the serialization supports. In our case, we are going to serialize the object into JSON and then transmitting it as a String. We could use other formats, but “text” is simple enough. So we need to have the following property.

1
2
3
public static var writableTypeIdentifiersForItemProvider: [String] {
  return [(kUTTypeUTF8PlainText) as String]
}

When the data is going to be sent, the loadData method is called. This method is in charge of preparing the model to be sent. The loadData method returns a Progress object. We use a Progress object to indicate the status of loading the data. In our case, the loading of data is “immediate”, that is why we go straight to 100.

1
2
3
4
5
6
7
8
9
10
11
12
public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
  let progress = Progress(totalUnitCount: 100)
    do {
      //Here the object is encoded to a JSON data object and sent to the completion handler
      let data = try JSONEncoder().encode(self)
        progress.completedUnitCount = 100
        completionHandler(data, nil)
    } catch {
      completionHandler(nil, CounterError.encodeFailure)
    }
  return progress
}

That’s all we need to comply with NSItemProviderWriting. Now to comply with NSItemProviderReading, we also need to make clear the data type we support. We do this by setting the following property:

1
2
3
public static var readableTypeIdentifiersForItemProvider: [String] {
  return [(kUTTypeUTF8PlainText) as String]
}

When we receive the data, we use the method object(withItemProviderData:typeIdentifier) to unarchive the data and build an instance of our model object.

1
2
3
4
5
6
7
8
9
10
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Counter {
  let decoder = JSONDecoder()
    do {
      //Here we decode the object back to it's class representation and return it
      let counter = try decoder.decode(Counter.self, from: data)
        return counter
    } catch {
      throw CounterError.decodeFailure
    }
}

That should be enough to implement the drag and drop reordering for TableView.

Final Thoughts

If we are already implementing drag and drop on our app, we can also use the capability to reorder our table view. It might seem complicated at first, but once you’ve done it a couple of times, you’ll see the logic behind it.

Remember to import MobileCoreServices so we can have access to the data types identifiers (i.e. kUTTypeUTF8PlainText)

1
import MobileCoreServices

To better identify the possible errors, we can create an enum with the following errors:

1
2
3
enum CounterError: Error {
    case invalidDataType, decodeFailure, encodeFailure
}

Resources / Notes


** There is no comment system yet, but you can send me a message on twitter @rderik or send me an email: derik[at]rderik[dot]com.