Drag and Drop Tutorial for iOS

In this drag and drop tutorial you will build drag and drop support into UICollectionViews and between two separate iOS apps. By Christine Abernathy.

Leave a rating/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.

Adding a Placeholder

Fetching items from an external app and loading them in the destination app could take time. It’s good practice to provide visual feedback to the user such as showing a placeholder.

Replace the .copy case in collectionView(_:performDropWith:) with the following:

print("Copying from different app...")
// 1
let placeholder = UICollectionViewDropPlaceholder(
  insertionIndexPath: destinationIndexPath, reuseIdentifier: "CacheCell")
// 2
placeholder.cellUpdateHandler = { cell in
  if let cell = cell as? CacheCell {
    cell.cacheNameLabel.text = "Loading..."
    cell.cacheSummaryLabel.text = ""
    cell.cacheImageView.image = nil
  }
}
// 3
let context = coordinator.drop(item.dragItem, to: placeholder)
let itemProvider = item.dragItem.itemProvider
itemProvider.loadObject(ofClass: NSString.self) { string, error in
  if let string = string as? String {
    let geocache = Geocache(
      name: string, summary: "Unknown", latitude: 0.0, longitude: 0.0)
    // 4
    DispatchQueue.main.async {
      context.commitInsertion(dataSourceUpdates: {_ in
        dataSource.addGeocache(geocache, at: destinationIndexPath.item)
      })
    }
  }
}

This is what’s going on:

  1. Create a placeholder cell for the new content.
  2. Define the block that configures the placeholder cell.
  3. Insert the placeholder into the collection view.
  4. Commit the insertion to exchange the placeholder with the final cell.

Build and run the app. Drag and drop an item from Reminders. Note the brief appearance of the placeholder text as you drop the item into a collection view:

Multiple Data Representations

You can configure the types of data that you can deliver to a destination app or consume from a source app.

When you create an item provider using init(object:), the object you pass in must conform to NSItemProviderWriting. Adopting the protocol includes specifying the uniform type identifiers (UTIs) for the data you can export and handling the export for each data representation.

For example, you may want to export a string representation of your geocache for apps that only take in strings. Or you might want to export an image representation for photo apps. For apps under your control that use geocaches, you may want to export the full data model.

To properly consume dropped items and turn them into geocaches, your data model should adopt NSItemProviderReading. You then implement protocol methods to specify which data representations you can consume. You’ll also implement them to specify how to coerce the incoming data based on what the source app sends.

Thus far, you’ve worked with strings when dragging and dropping geocaches between apps. NSString automatically supports NSItemProviderWriting and NSItemProviderReading so you didn’t have to write any special code.

To handle multiple data types, you’ll change the geocache data model. You’ll find this in the Geocache project, which is part of the Xcode workspace you have open..

In the Geocache project, open Geocache.swift and add the following after the Foundation import:

import MobileCoreServices

You need this framework to use predefined UTIs such as those representing PNGs.

Add the following right after your last import:

public let geocacheTypeId = "com.razeware.geocache" 

You create a custom string identifier that will represent a geocache.

Reading and Writing Geocaches

Add the following extension to the end of the file:

extension Geocache: NSItemProviderWriting {
  // 1
  public static var writableTypeIdentifiersForItemProvider: [String] {
    return [geocacheTypeId,
            kUTTypePNG as String,
            kUTTypePlainText as String]
  }
  // 2
  public func loadData(
    withTypeIdentifier typeIdentifier: String,
    forItemProviderCompletionHandler completionHandler:
    @escaping (Data?, Error?) -> Void) 
      -> Progress? {
    if typeIdentifier == kUTTypePNG as String {
      // 3
      if let image = image {
        completionHandler(image, nil)
      } else {
        completionHandler(nil, nil)
      }
    } else if typeIdentifier == kUTTypePlainText as String {
      // 4
      completionHandler(name.data(using: .utf8), nil)
    } else if typeIdentifier == geocacheTypeId {
      // 5
      do {        
        let archiver = NSKeyedArchiver(requiringSecureCoding: false)
        try archiver.encodeEncodable(self, forKey: NSKeyedArchiveRootObjectKey)
        archiver.finishEncoding()
        let data = archiver.encodedData
        
        completionHandler(data, nil)
      } catch {
        completionHandler(nil, nil)
      }
    }
    return nil
  }
}   

Here you conform to NSItemProviderWriting and do the following:

  1. Specify the data representations you can deliver to the destination app. You want to return a string array ordered from the highest fidelity version of the object to the lowest.
  2. Implement the method for delivering data to the destination app when requested. The system calls this when an item is dropped and passes in the appropriate type identifier.
  3. Return the geocache’s image in the completion handler if a PNG identifier is passed in.
  4. Return the geocache’s name in the completion handler if a text identifier is passed in.
  5. If the custom geocache type identifier is passed in, return a data object corresponding to the entire geocache.

Now, add the following enum right after geocacheTypeId is assigned:

enum EncodingError: Error {
  case invalidData
}

You’ll use this to return an error code when there are problems reading in data.

Next, add the following to the end of the file:

extension Geocache: NSItemProviderReading {
  // 1
  public static var readableTypeIdentifiersForItemProvider: [String] {
    return [geocacheTypeId,
            kUTTypePlainText as String]
  }
  // 2
  public static func object(withItemProviderData data: Data,
                            typeIdentifier: String) throws -> Self {
    if typeIdentifier == kUTTypePlainText as String {
      // 3
      guard let name = String(data: data, encoding: .utf8) else {
        throw EncodingError.invalidData
      }
      return self.init(
        name: name, 
        summary: "Unknown", 
        latitude: 0.0, 
        longitude: 0.0)
    } else if typeIdentifier == geocacheTypeId {
      // 4
      do {
        let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
        guard let geocache =
          try unarchiver.decodeTopLevelDecodable(
            Geocache.self, forKey: NSKeyedArchiveRootObjectKey) else {
              throw EncodingError.invalidData
        }
        return self.init(geocache)
      } catch {
        throw EncodingError.invalidData
      }
    } else {
      throw EncodingError.invalidData
    }
  }
}   

Here you conform to NSItemProviderReading to specify how to handle incoming data. This is what’s going on:

  1. Specify the types of incoming data the model can consume. The UTIs listed here represent a geocache and text.
  2. Implement the required protocol method for importing data given a type identifier.
  3. For a text identifier, create a new geocache with the name based on the incoming text and placeholder information.
  4. For the geocache identifier, decode the incoming data and use it to create a full geocache model.

Errors or unrecognized type identifiers throw the error you defined before.

Back to My App

Change the active scheme to Geocache and build the project. Then change the active scheme back to CacheMaker.

In CacheMaker, go to CachesDataSource.swift and inside dragItems(for:) change the itemProvider assignment to:

let itemProvider = NSItemProvider(object: geocache) 

Here you can initialize your item provider with a geocache since your model adopts NSItemProviderWriting to properly export data.

Open CachesViewController.swift and find collectionView(_:performDropWith:). In the .copy case, replace the item provider’s loadObject call with the following:

itemProvider.loadObject(ofClass: Geocache.self) { geocache, _ in
  if let geocache = geocache as? Geocache {
    DispatchQueue.main.async {
        context.commitInsertion(dataSourceUpdates: {_ in
          dataSource.addGeocache(geocache, at: destinationIndexPath.item)
      })
    }
  }
}

You’ve modified the drop handler to load objects of type Geocache. The completion block now returns a geocache that you can use directly.

Build and run the app. Place Reminders in Split View if necessary. Check that dragging and dropping items between Reminders and CacheMaker works as before:

Bring Photos in Split View to replace Reminders. Drag a geocache from your in-progress lane and drop it into Photos to verify that you can export an image representation of the geocache:

You can test the full data model export path with a temporary hack. Go to CachesViewController.swift and in collectionView(_:dropSessionDidUpdate:withDestinationIndexPath:) replace the line that returns the move operation with the following:

return UICollectionViewDropProposal(
  operation: .copy, 
  intent: .insertAtDestinationIndexPath)

You’re configuring drag-and-drops within the same app as copy operations. This triggers the code that should export and import the full data model.

Build and run the app. Test that moving an item within the app makes a proper copy of the geocache:

Revert your temporary hack in collectionView(_:dropSessionDidUpdate:withDestinationIndexPath:) so that in-app drag-and-drops executes a move operation:

return UICollectionViewDropProposal(
  operation: .move, 
  intent: .insertAtDestinationIndexPath)    

Build and run the app to get back to pre-hack conditions.