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 2 of 4 of this article. Click here to view the first page.

Drag and Drop in the Same App

Still in CachesViewController.swift, go to collectionView(_:dropSessionDidUpdate:withDestinationIndexPath:) and replace the forbidden return statement with this code:

guard session.items.count == 1 else {
  return UICollectionViewDropProposal(operation: .cancel)
}

if collectionView.hasActiveDrag {
  return UICollectionViewDropProposal(operation: .move,
                                      intent: .insertAtDestinationIndexPath)
} else {
  return UICollectionViewDropProposal(operation: .copy,
                                      intent: .insertAtDestinationIndexPath)
}

If more than one item is selected, the code cancels the drop. For the single drop item you propose a move if you’re within the same collection view. Otherwise, you propose a copy.

In CachesDataSource.swift, add the following method to the extension:

func moveGeocache(at sourceIndex: Int, to destinationIndex: Int) {
  guard sourceIndex != destinationIndex else { return }

  let geocache = geocaches[sourceIndex]
  geocaches.remove(at: sourceIndex)
  geocaches.insert(geocache, at: destinationIndex)
}

This repositions a geocache in the data source.

Head back to CachesViewController.swift and in collectionView(_:performDropWith:) replace the destinationIndexPath assignment with the following:

let destinationIndexPath: IndexPath
if let indexPath = coordinator.destinationIndexPath {
  destinationIndexPath = indexPath
} else {
  destinationIndexPath = IndexPath(
    item: collectionView.numberOfItems(inSection: 0), 
    section: 0)
}   

Here, you check for an index path specifying where to insert the item. If none is found, the item inserts at the end of the collection view.

Add the following right before the .copy case:

case .move:
  print("Moving...")
  // 1
  if let sourceIndexPath = item.sourceIndexPath {
    // 2
    collectionView.performBatchUpdates({
      dataSource.moveGeocache(
        at: sourceIndexPath.item,
        to: destinationIndexPath.item)
      collectionView.deleteItems(at: [sourceIndexPath])
      collectionView.insertItems(at: [destinationIndexPath])
    })
    // 3
    coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)
  }

This block of code:

  1. Gets the source index path which you should have access to for drag and drops within the same collection view.
  2. Performs batch updates to move the geocache in the data source and collection view.
  3. Animates the insertion of the dragged geocache in the collection view.

Follow My Moves

Build and run the app. Verify that dragging and dropping a geocache across collection views creates a copy and logs the copy message:

Test that you can also move a geocache within the same collection view and see the move message logged:

You might have noticed some inefficiencies when dragging and dropping across collection views. You’re working in the same app yet you’re creating a low fidelity copy of the object. Not to mention, you’re creating a copy!

Surely, you can do better.

Optimizing the Drop Experience

You can make a few optimizations to improve the drop implementation and experience.

Using In-Memory Data

You should take advantage of your access to the full geocache structure in the same app.

Go to CachesDataSource.swift. Add the following to dragItems(for:), directly before the return statement:

dragItem.localObject = geocache

You assign the geocache to the drag item property. This enables faster item retrieval later.

Go to CachesViewController.swift. In collectionView(_:performDropWith:), replace the code inside the .copy case with the following:

if let geocache = item.dragItem.localObject as? Geocache {
  print("Copying from same app...")
  dataSource.addGeocache(geocache, at: destinationIndexPath.item)
  DispatchQueue.main.async {
    collectionView.insertItems(at: [destinationIndexPath])
  }
} else {
  print("Copying from different app...")
  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)
      dataSource.addGeocache(geocache, at: destinationIndexPath.item)
      DispatchQueue.main.async {
        collectionView.insertItems(at: [destinationIndexPath])
      }
    }
  }
}   

Here, the code that handles items dropped from a different app hasn’t changed. For items copied from the same app, you get the saved geocache from localObject and use it to create a new geocache.

Build and run the app. Verify that dragging and dropping across collections views now replicates the geocache structure:

Moving Items Across Collection Views

You now have a better representation of the geocache. That’s great, but you really should move the geocache across collection views instead of copying it.

Still in CachesViewController.swift, replace the collectionView(_:dropSessionDidUpdate:withDestinationIndexPath:) implementation with the following:

guard session.localDragSession != nil else {
  return UICollectionViewDropProposal(
      operation: .copy,
      intent: .insertAtDestinationIndexPath)
}
guard session.items.count == 1 else {
  return UICollectionViewDropProposal(operation: .cancel)
}
return UICollectionViewDropProposal(
    operation: .move,
    intent: .insertAtDestinationIndexPath)

You now handle drops within the same app as move operations.

Go to File ▸ New ▸ File… and choose the iOS ▸ Source ▸ Swift File template. Click Next. Name the file CacheDragCoordinator.swift and click Create.

Add the following at the end of the file:

class CacheDragCoordinator {
  let sourceIndexPath: IndexPath
  var dragCompleted = false
  var isReordering = false
  
  init(sourceIndexPath: IndexPath) {
    self.sourceIndexPath = sourceIndexPath
  }
}   

You’ve created a class to coordinate drag-and-drops within the same app. Here you set up properties to track:

  • Where the drag starts.
  • When it’s completed.
  • If the collection view items should be reordered after the drop.

 

Switch to CachesDataSource.swift and add the following method to the extension:

func deleteGeocache(at index: Int) {
  geocaches.remove(at: index)
}   

This method removes a geocache at the specified index. You’ll use this helper method when reordering collection view items.

Go to CachesViewController.swift. Add the following to collectionView(_:itemsForBeginning:at), directly before the return statement:

let dragCoordinator = CacheDragCoordinator(sourceIndexPath: indexPath)
session.localContext = dragCoordinator  

Here, you initialize a drag coordinator with the starting index path. You then add this object to the drag session property that stores custom data. This data is only visible to apps where the drag activity starts.

Are You My App?

Find collectionView(_:performDropWith:). Replace the code inside the .copy case with the following:

print("Copying from different app...")
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)
    dataSource.addGeocache(geocache, at: destinationIndexPath.item)
    DispatchQueue.main.async {
      collectionView.insertItems(at: [destinationIndexPath])
    }
  }
}

You’ve simplified the copy path to only handle drops from a different app.

Replace the code inside the .move case with the following:

// 1
guard let dragCoordinator =
  coordinator.session.localDragSession?.localContext as? CacheDragCoordinator
  else { return }
// 2
if let sourceIndexPath = item.sourceIndexPath {
  print("Moving within the same collection view...")
  // 3
  dragCoordinator.isReordering = true
  // 4
  collectionView.performBatchUpdates({
    dataSource.moveGeocache(at: sourceIndexPath.item, to: destinationIndexPath.item)
    collectionView.deleteItems(at: [sourceIndexPath])
    collectionView.insertItems(at: [destinationIndexPath])
  })
} else {
  print("Moving between collection views...")
  // 5
  dragCoordinator.isReordering = false
  // 6
  if let geocache = item.dragItem.localObject as? Geocache {
    collectionView.performBatchUpdates({
      dataSource.addGeocache(geocache, at: destinationIndexPath.item)
      collectionView.insertItems(at: [destinationIndexPath])
    })
  }
}
// 7
dragCoordinator.dragCompleted = true
// 8
coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)

Here’s a step-by-step breakdown of the what’s going on:

  1. Get the drag coordinator.
  2. Check if the source index path for the drag item is set. This means the drag and drop is in the same collection view.
  3. Inform the drag coordinator that the collection view will be reordered.
  4. Perform batch updates to move the geocache in the data source and in the collection view.
  5. Note that the collection view is not going to be reordered.
  6. Retrieve the locally stored geocache. Add it to the data source and insert it into the collection view.
  7. Let the drag coordinator know that the drag finished.
  8. Animate the insertion of the dragged geocache in the collection view.

Add the following method to your UICollectionViewDragDelegate extension:

func collectionView(_ collectionView: UICollectionView,
                    dragSessionDidEnd session: UIDragSession) {
  // 1
  guard 
    let dragCoordinator = session.localContext as? CacheDragCoordinator,
    dragCoordinator.dragCompleted == true,
    dragCoordinator.isReordering == false 
    else { 
      return 
    }
  // 2
  let dataSource = dataSourceForCollectionView(collectionView)
  let sourceIndexPath = dragCoordinator.sourceIndexPath
  // 3
  collectionView.performBatchUpdates({
    dataSource.deleteGeocache(at: sourceIndexPath.item)
    collectionView.deleteItems(at: [sourceIndexPath])
  })
}   

This method is called when either the drag is aborted or the item is dropped. Here’s what the code does:

  1. Check the drag coordinator. If the drop is complete and the collection view isn’t being reordered, it proceeds.
  2. Get the data source and source index path to prepare for the updates.
  3. Perform batch updates to delete the geocache from the data source and the collection view. Recall that you previously added the same geocache to the drop destination. This takes care of removing it from the drag source.

Build and run the app. Verify that moving across collection views actually moves the item and prints Moving between collection views... in the console: