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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Drag and Drop Tutorial for iOS
30 mins
- Getting Started
- Drag and Drop Overview
- Adding Drag Support
- Adding Drop Support
- Responding to Drops
- Drag and Drop in the Same App
- Follow My Moves
- Optimizing the Drop Experience
- Using In-Memory Data
- Moving Items Across Collection Views
- Are You My App?
- Adding a Placeholder
- Multiple Data Representations
- Reading and Writing Geocaches
- Back to My App
- Adding Drag Support to a Custom View
- Adding Drop Support to a Custom View
- Where to Go From Here?
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:
- Gets the source index path which you should have access to for drag and drops within the same collection view.
- Performs batch updates to move the geocache in the data source and collection view.
- 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:
- Get the drag coordinator.
- Check if the source index path for the drag item is set. This means the drag and drop is in the same collection view.
- Inform the drag coordinator that the collection view will be reordered.
- Perform batch updates to move the geocache in the data source and in the collection view.
- Note that the collection view is not going to be reordered.
- Retrieve the locally stored geocache. Add it to the data source and insert it into the collection view.
- Let the drag coordinator know that the drag finished.
- 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:
- Check the drag coordinator. If the drop is complete and the collection view isn’t being reordered, it proceeds.
- Get the data source and source index path to prepare for the updates.
- 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: