Chapters

Hide chapters

Catalyst by Tutorials

Third Edition · iOS 15 · Swift 5.6 · Xcode 13.3

Section I: Making a Great iPad App

Section 1: 7 chapters
Show chapters Hide chapters

3. Drag & Drop
Written by Marin Bencevic

On macOS, dragging and dropping files is such an integral feature that we typically just take its presence for granted. It’s so useful that Apple decided to bring it over to the iPad in iOS 11. With Catalyst, you can go full circle and bring the iPad’s drag and drop back to macOS. Everything old is new again, right?

In this chapter, you’ll update the app so your users can add new photos to their diary entry by dragging them from other apps and dropping them into the entry view. You’ll also update the entry photos collection view to allow reordering the photos by dragging and dropping.

How do Drag and Drop Work?

Before you start working, you need to get familiar with the basics of drag and drop. Now, theory can be a drag, however, quite useful in understanding the concept.

Each drag and drop action has two sides: The dragging side and the dropping side. The objects on both of these sides are UIViews. To enable dragging, you need to add a drag interaction to a view. When iOS detects that a drag is being attempted on a view, the drag interaction uses a drag interaction delegate to determine which drag items will be dragged. This delegate can also customize the look of these items as they’re being dragged.

The whole point of drag and drop is to share data between apps. The drag items being passed around are not the actual data. Instead, they contain a way to get the data once the drop happens.

On the other side, a drop interaction uses a drop interaction delegate to determine what will happen when the item is dropped into the destination view. The drop interaction delegate can also animate the destination view whenever the dragged items enter the destination view or change position inside the view.

Overseeing all of this is a drop session. Drop session is a manager, but the good kind of manager: It lets you work without getting in your way, but always has answers ready to any pressing questions you might have. The session knows which items are being dragged, how to get their contents, where they’re being dropped to and other useful contextual information.

To enable drag and drop on your views, all you need to do is add the interactions and implement the delegate methods. Once you do that for your iPad app, it will work on macOS automatically. It also works on iOS, but only inside your app.

OK, that’s the theory. Let’s drop right in and get into some code!

Dropping New Photos

Open the starter project from the provided materials of this chapter. Build and run on an iPad. Take a look at the entry photos collection view. Currently, you can only add new photos by clicking the camera icon and choosing a photo. On macOS and the iPad, it’s common to select a bunch of photos from the Photos app and just drop them into the destination app. You’ll make this happen.

Before you can start working on drag and drop, there are a few project-specific changes you need to make. When the app loads, it first launches the main table view controller, which in turn creates a new entry table view controller with a new entry. At the time of writing, in some cases, the split view controller will only load the entry table view controller, without loading the master. In those cases, the entry will be nil. To work around this issue, you’ll force the app to always perform some setup after it launches.

Open MainTableViewController.swift and add a new method to the class:

func prepareForPresentation() {
  loadViewIfNeeded()
  populateMockData()
}

This method performs the necessary setup actions that populate your app with an empty entry.

Next, open AppDelegate.swift and add the following code at the end of the if block inside application(_:didFinishLaunchingWithOptions:), right after where you set primaryBackgroundStyle:

if let mainNavigationController
     = splitViewController.viewController(for: .primary)
     as? UINavigationController,
   let mainViewController
     = mainNavigationController.viewControllers.first
     as? MainTableViewController {
  mainViewController.prepareForPresentation()
}

The above code fetches the main table view controller and calls the method you added earlier every time the app launches. This makes sure entry table view controller always has an entry to work with.

With that out of the way, you can get started on dropping photos into the app!

Open EntryTableViewController.swift and at the bottom of viewDidLoad add the following code:

let interaction = UIDropInteraction(delegate: self)
textView.interactions.append(interaction)

As mentioned before, for dropping to work you need to add a UIDropInteraction to a view. In this case, you’ll allow dropping photos inside the text view of the entry screen.

Next, at the bottom of the file, add the following extension to implement UIDropInteractionDelegate:

extension EntryTableViewController:
  UIDropInteractionDelegate {
  func dropInteraction(
    _ interaction: UIDropInteraction,
    canHandle session: UIDropSession
  ) -> Bool {
    session.canLoadObjects(ofClass: UIImage.self)
  }
}

As you enable drag and drop, you need to first declare the kinds of things that can be dropped inside the view. In this case, you’ll only accept the drop if the session has at least one drag item that’s a UIImage.

Now implement the following method inside the extension:

func dropInteraction(
  _ interaction: UIDropInteraction,
  sessionDidUpdate session: UIDropSession
) -> UIDropProposal {
  UIDropProposal(operation: .copy)
}

As items are dragged inside a view, this method is called repeatedly. This method returns a proposal of what could happen if the user were to drop the items right now. In the proposal, you define what kind of drop operation this would be with a value from the UIDropOperation enumeration. In this case, you return .copy which means that the dropped item will be copied from the source app to your app, and the user will see a + sign while dropping.

Other possible values of this enumeration include:

  • .move: Tells the user that the item will be moved.
  • .cancel: Says that the drag will be canceled.
  • .forbidden: Used when the drop is usually enabled for this view, but there’s something about the combination of dragged items and destination view’s state forbidding this in a specific case. For instance, there might already be an existing item there which needs to be deleted for the drop to work.

Note: During a drag interaction, dropInteraction(_:sessionDidUpdate:) gets called many, many times. Make sure you’re not doing any long-lasting work in there.

The last piece of the puzzle is to define what happens once the user drops the dragged items. Add the following method inside the UIDropInteractionDelegate extension:

func dropInteraction(
  _ interaction: UIDropInteraction,
  performDrop session: UIDropSession
) {
  session.loadObjects(ofClass: UIImage.self) {
    [weak self] imageItems in
    guard let self = self else { return }    
  }
}

This method gets called when the user releases the dragged items into the view. To get the dragged photos, you can ask the session to load all the dragged items of type UIImage.

Once the images are loaded you can add them to the entry and update the collection view. Add the following code inside the closure:

if let images = imageItems as? [UIImage] {
  self.entry?.images.insert(contentsOf: images, at: 0)
}
self.reloadSnapshot(animated: true)

Here, you’re casting the received array to [UIImage]. Once you have the photos, you prepend them to the entry and add a number of items at the start of the collection view equal to the number of photos that were just dropped. reloadSnapshot will automatically animate the changes you make to the collection view’s data source.

Note: loadObjects can only be called inside this delegate method. You can’t access a drag item’s data before or after this point. The completion handler is always called on the main queue, so there’s no need to use dispatch here.

Build and run the project on an iPad. The easiest devices to test on are 11-inch or larger iPad Pros. Enter split-screen with Photos by dragging its icon from the dock to the right-hand side of the screen. Find some nice vacation photos and drag the photos over to the text view of the entry screen:

Drag a photo from Photos to the text view.
Drag a photo from Photos to the text view.

You see the photos appear in the collection view:

Photo appears in the collection view.
Photo appears in the collection view.

Dropping photos like this is much easier than going through an image picker and looking for the perfect photo. Now that the user can drop photos into the text view, you’ll next allow them to drop photos directly into the collection view.

Dropping Inside UICollectionView

Dropping photos in the text view is convenient when you want to add a bunch of photos to the entry, but it would be nice to let the users drag photos into specific positions directly inside the collection view. You’ll also show an animation of the existing photos parting to allow space for the newly dropped photos.

Thankfully, UIKit includes drag and drop APIs specific to table and collection views. While you will work on adding drag and drop to a collection view, the same process is valid for table views with minor method name changes.

Still in EntryTableViewController.swift, start by adding the following line to the end of viewDidLoad:

collectionView.dropDelegate = self

UICollectionViewDropDelegate is very similar to the delegate you implemented earlier in this chapter, except it has additional features specific to collection views.

Add the following extension to the bottom of the file to implement the delegate:

extension EntryTableViewController:
  UICollectionViewDropDelegate {
  func collectionView(
    _ collectionView: UICollectionView,
    canHandle session: UIDropSession
  ) -> Bool {
    session.canLoadObjects(ofClass: UIImage.self)
  }
}

This is the same implementation as before. You’re only interested in UIImage instances.

Next, implement the following method in the extension:

func collectionView(
  _ collectionView: UICollectionView,
  dropSessionDidUpdate session: UIDropSession,
  withDestinationIndexPath destinationIndexPath: IndexPath?
) -> UICollectionViewDropProposal {
  UICollectionViewDropProposal(
    operation: .copy,
    intent: .insertAtDestinationIndexPath
  )
}

This method, like the one you implemented before, returns a proposal. For collection views, there’s one additional piece of information: The drop proposal intent. The intent defines what would happen to the collection view if the user dropped the items right now.

You return .insertAtDestinationIndexPath which makes the collection view part the items to the left and right of the cursor, and show an empty space where the dragged items will be dropped. This signals to the user that the items will be dropped at that specific position.

Another possible value of this enumeration is .insertIntoDestinationIndexPath. This tells the user that the dropped items will be dropped inside the cell. This is useful for things like nested cells or cells that contain items inside them.

Finally, implement the method that performs the actual drop:

func collectionView(
  _ collectionView: UICollectionView,
  performDropWith coordinator:
  UICollectionViewDropCoordinator
) {
  let destinationIndex = coordinator.destinationIndexPath ??
  IndexPath(item: 0, section: 0)
}

A difference between the delegate you implemented earlier and this one is that the collection view delegate gets a collection view drop coordinator. This coordinator has a reference to the drop session, but also includes collection view specific contextual information, like which index path the user is dropping to. You’ll use this information to add photos to specific indices in the entries array.

Add the following code to the method:

// 1
coordinator.session.loadObjects(ofClass: UIImage.self) {
  [weak self] imageItems in
  guard let self = self else { return }
  if let images = imageItems as? [UIImage] {
    // 2
    self.entry?.images.insert(
      contentsOf: images,
      at: destinationIndex.item
    )
  }
  // 3
  self.reloadSnapshot(animated: true)
}

This code is similar to the one you wrote earlier from dropping into the text view. Here’s what’s going on:

  1. First, ask the session to load all UIImage objects.
  2. Then, insert those objects into the destination index you fetched earlier.
  3. Once you update the array, just like before, you call reloadSnapshot to make sure that the collection view animates those changes.

Build and run. Drag over a few photos to the collection view.

Once you have at least one photo, when dragging another one across the collection view you’ll notice the items move to show an empty space where the item will be dropped.

Dropping a photo inside collection view.
Dropping a photo inside collection view.

If you drop the item, it will animate into position. That’s a pretty cool effect with no manual animation code. Thanks, UIKit!

Now that you have dropping in your collection view, why not add dragging as well? Read on to find out how!

Reordering Collection View Items

Speaking of the work UIKit does for you, if you implement both dragging and dropping for a collection or table view, it will automatically support reordering the elements. That’s why your next task is adding drag support to the collection view. By the end of this section, you’ll be a real drag expert!

Add the following line to viewDidLoad:

collectionView.dragDelegate = self

Just like there’s a collection view specific drop delegate, there’s also a drag delegate. Begin the implementation of the protocol with the following extension at the bottom of the file:

extension EntryTableViewController:
  UICollectionViewDragDelegate {
  func collectionView(
    _ collectionView: UICollectionView,
    itemsForBeginning session: UIDragSession,
    at indexPath: IndexPath
  ) -> [UIDragItem] {
    guard let entry = entry, !entry.images.isEmpty else {
      return []
    }
    let image = entry.images[indexPath.item]
    let provider = NSItemProvider(object: image)
    return [UIDragItem(itemProvider: provider)]
  }
}

For dragging to work, there’s only one required method you need to implement. This method gets called when UIKit detects a dragging interaction is about to begin, and it asks you for the items that are being dragged. Thankfully, the delegate method includes the dragged index path as a parameter, making the task of finding the image much easier.

You check that there is an image to drag, and then get the image at that index path. If there is no image, you return an empty array, which stops the drag interaction and lets UIKit interpret the drag as a regular touch or mouse event.

The item providers define how data will be transferred from one app to the other. Some existing UIKit classes like UIImage already implement the required methods for this, so you can easily create a provider just by passing the image.

With the drag delegate implemented, you need to make a few changes to your existing code to support rearranging items. Change the implementation of collectionView(_:dropSessionDidUpdate:withDestinationIndexPath:) to the following code:

if session.localDragSession != nil {
  return UICollectionViewDropProposal(
    operation: .move,
    intent: .insertAtDestinationIndexPath)
} else {
    return UICollectionViewDropProposal(
      operation: .copy,
      intent: .insertAtDestinationIndexPath
    )
}

A local drag session is a drag session that was started inside your app. If the session is local, you return a .move drop operation because the items will be moved from one index path to another. If it’s not a local session, you return .copy like before.

Next, when the user is rearranging items, you first need to delete the original item and then add it to its new position. Add the following lines to collectionView(_:performDropWith:), right before the loadObjects call:

if coordinator.session.localDragSession != nil {
  for item in coordinator.items {
    guard let sourceIndex = item.sourceIndexPath else {
      continue
    }
    self.entry?.images.remove(at: sourceIndex.item)
  }
}

If this is a local drag session, go through each item to be dropped. Each item contains a source index path, i.e. the index path it was dragged from. You remove the dragged image from the source index in the images array. This ensures users don’t accidentally duplicate items when trying to rearrange them.

Build and run. Like before, add a few photos to the collection view. Then try to drag one of the photos from the collection view to another position. You’ll notice when you drag, it disappears from its original position, and a parting animation happens when you move over a different position. Once you drop it, the collection view animates it into that position:

Re-ordering photos in the collection view.
Re-ordering photos in the collection view.

You just made your app’s user experience much better by allowing users to drag, drop and rearrange photos with a smooth look and feel, and all with minimal code.

And guess what, this works on macOS without any code changes. If you don’t believe, set the active scheme to My Mac and run the app. Find some vacation photos in the Finder and drag them to the app.

Drag and drop works on the Mac.
Drag and drop works on the Mac.

As a developer, there’s nothing better than features working without any extra code. Even better, you can now also rearrange the collection view items in your iOS app!

Now you know how to implement drag and drop! Hope this chapter didn’t drag on for too long.

Key Points

  • Add drag and drop to custom views by adding a drag or drop interaction to the view and implementing the necessary delegate methods.
  • Use a drag session to get the dragged items and load the dragged data.
  • For table and collection views, it’s enough to implement the collection or table view specific drop and drag delegate methods.
  • An iPadOS implementation of drag and drop works on macOS and iOS, but on iOS, it only works within one app.

Where to Go From Here?

In this chapter, you implemented a basic implementation of drag and drop. You can go even further by including custom animations while the user is dragging items across your views. You can also customize how item previews look while they’re being dragged. You can see an implementation of that in the WWDC 2017 session called Mastering Drag and Drop which you can find here.

For collection and table views, you can make the user interface smoother by utilizing placeholder cells. When the user is dragging over the table or collection view, you can show placeholder cells with loading indicators. Once the items are loaded, the cell gets updated. You can see this in action in the WWDC 2017 session called Drag and Drop with Collection and Table View, which you can find here.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.