Get me seven, or D’n’D in iOS

  1. Implement sorting of elements within a section.

  2. Add the ability to interact objects between and within sections.

  3. Highlight the area into which the moved object will “fall”.

The assigned tasks repeat the requirements of a real project. For example, screenshots and code snippets from a test application will be presented. It can be viewed at github

Simple implementation

“The same along the Nevsky march.
The same glove le perdu.
Same as her – more, more!
Spat, and march again. “

The verse with which the schoolgirls learned French verbs perfectly describes the essence of the question. In the process of dragging, the object loses its place inside the section. Our task is to determine its new position and re-arrange the elements.

The obvious solution is to use the standard re-sorting mechanism. For its minimal implementation, you only need a UITableViewDataSource. The DataSource determines the ability of the object to move, and also informs about the need to change the data after the end of the user action. To do this, you need to implement the following methods:

func tableView(
	_ tableView: UITableView, 
	canMoveRowAt indexPath: IndexPath
) -> Bool

func tableView(
    _ tableView: UITableView,
    moveRowAt sourceIndexPath: IndexPath,
    to destinationIndexPath: IndexPath
)

To implement movement restriction only within a section, you can define one more method:

func tableView(
	_ tableView: UITableView, 
	targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, 
	toProposedIndexPath proposedDestinationIndexPath: IndexPath
) -> IndexPath

At the output, we get the following result:

The main advantage of this approach is its very fast to set up… However, it imposes very specific restrictions. First, it only works in table editing mode. You can use a couple of dirty hacks to get around this limitation, but you have to bring the owl closer to the globe.

And secondly, and this is the main thing, this implementation makes it possible to fulfill only the first point of our requirements stated at the beginning of the article – to implement sorting of objects within a section. Only. In order to add the ability to customize the process of transferring and interacting objects between sections and inside, the poor bird will have to be pulled on the globe.

Greenpeace is against it and, in general, is not our method, so let us assign the status of “failed” to this decision and move on to the next option.

Drag and Drop in UIKit

The Drag and Drop API allows you to sort elements without turning on the editing mode and opens up great possibilities for customizing the process itself – from changing the preview of a moved object to choosing a method and place of interaction with the place of “drop”. In order to start using this API, you need to study the DragDelegate, DropDelegate classes and their methods. But first, let’s connect these classes and activate drag and drop.

tableView.dragDelegate = dragDelegate
tableView.dropDelegate = dropDelegate
tableView.dragInteractionEnabled = true

DragDelegate

DragDelegate determines what kind of objects we are transferring, their payload, and much more. In order to initiate the transfer process, we will set the following method:

func tableView(
  _ tableView: UITableView,
  itemsForBeginning session: UIDragSession,
  at indexPath: IndexPath
) -> [UIDragItem] {
  let item = UIDragItem(itemProvider: NSItemProvider())
  item.localObject = indexPath
  return [item]
}

In it, we define a list of UIDragItem objects that we carry and their payload, and therefore this method is required in any DragDelegate. UIDragItem has important properties: itemProvider and localObject.

The ItemProvider allows you to move objects between apps, which is especially useful for the iPad in multi-window mode. If we work within the framework of one application, then localObject will be enough for us, into which we can place any object, without thinking about its serialization to Data and back.

DropDelegate

DropDelegate is used to define behavior when an object “falls” into a certain area. It also has only one mandatory method.

func tableView(
  _ tableView: UITableView,
  performDropWith coordinator: UITableViewDropCoordinator
) {
	let destinationIndexPath = coordinator.destinationIndexPath
  coordinator.drop(item, toRowAt: sourceIndexPath)
}

The main entity here is the UITableViewDropCoordinator object. It allows access to the final position and the entire payload. Also, thanks to the coordinator, we can beautifully complete the action by using the drop (: toRowAt :). This will animate all the objects in their new places. Please note that without calling the drop (: toRowAt 🙂 method, the object preview will return to the starting position

After we implement the required methods from DragDelegate and DropDelegate, we can perform the simplest movement of objects. However, to fully comply with the first and second points of our requirements, we need to slightly modify the code.

UITableViewDropProposal

To control the behavior of floating objects through the API, we need the DropDelegate method, which returns UITableViewDropProposal.

func tableView(
  _ tableView: UITableView,
  dropSessionDidUpdate session: UIDropSession,
  withDestinationIndexPath destinationIndexPath: IndexPath?
) -> UITableViewDropProposal {
  guard
    let item = session.items.first,
    let fromIndexPath = item.localObject as? IndexPath,
    let toIndexPath = destinationIndexPath
  else {
    backdrop.frame = .zero
    return UITableViewDropProposal(operation: .forbidden)
  }

	if fromIndexPath.section == toIndexPath.section {
    return UITableViewDropProposal(
			operation: .move, 
			intent: .automatic
		)
  }
  return UITableViewDropProposal(
    operation: .move,
    intent: .insertIntoDestinationIndexPath
  )
}

It is worth dealing with this in a little more detail. The returned object has fields – operation and intent, on the basis of which the display of the preview and the place of the objects “falling” changes.

Operation – determines what to do with the dragged object at the end point (when dropping). 4 different situations are possible:

  • cancel – drag-and-drop is prohibited, no data is transferred, the drag-and-drop operation is canceled

  • forbidden – dragging is generally allowed, but in this particular scenario is impossible, the drag-and-drop operation is canceled

  • move – the payload from the dragged object should be moved to the target view

  • copy – the payload from the dragged object should be copied to the target view

Intent – tells where exactly the object will “fall”. There are 4 options to choose from:

  • unspecified – the behavior is not defined

  • insertAtDestinationIndexPath – the object will be placed side by side, causing the offset of adjacent objects.

  • insertIntoDestinationIndexPath – the object will be placed on the object below it without causing displacement of adjacent objects.

  • automatic – the behavior will be determined automatically between insertAtDestinationIndexPath and insertIntoDestinationIndexPath depending on the location of the finger.

By combining these properties, we can implement the tasks set in the TOR: depending on the section of the initial and final positions, we can define different actions through UITableViewDropProposal. And everything is solved at the level of a combination of literally a couple of parameters.

If sorting is required, then we return:

operation = .move 
intent = .insertAtDestinationIndexPath

If we want to interact between objects, then the combination is suitable for us

operation = .move 
intent = .insertIntoDestinationIndexPath

We should also take a closer look at intent = .automatic – a very useful option that allows you to support both sorting and interaction within one section.

UITableViewDropProposal makes it possible to more accurately determine what we want to do with objects, and therefore to make an intuitive animation for the user of his drag-action.

Handling UITableViewDropProposal in performDropWith

Above, we told the application what we expect from a drop action at this particular location. It’s time to process this action. For this, the coordinator has a proposal property. Using its fields, we can call the correct method of business logic. Let’s change our required DropDelegate method a bit.

switch coordinator.proposal.intent {
  case .insertAtDestinationIndexPath:
    move(from: sourceIndexPath, to: destinationIndexPath)
    coordinator.drop(item, toRowAt: destinationIndexPath)

  case .insertIntoDestinationIndexPath:
    interact(from: sourceIndexPath, to: destinationIndexPath)
    coordinator.drop(item, toRowAt: sourceIndexPath)

  default:
    break
}

Please note that we only process 2 possible intent options, but what about the rest? This is where the fun happens! When defining interactions within the section, we returned the intent property equal to automatic, which means that the automatically calculated action is passed to the coordinator method. That is, one of two options: insertAtDestinationIndexPath or insertIntoDestinationIndexPath. Just super!

Customization

Left just a little bit. Do we want our D’n’D implementation to be visually pleasing to the user? To do this, let’s slightly change the preview and highlight the place where the object fell.

To implement the first point, we just need to implement the dragPreviewParametersForRowAt method on the DragDelegate. The basic preview looks exactly like the cell itself. The same method allows you to slightly change its geometry, color and shadows. In our example, we will slightly round the corners of the preview.

func tableView(
  _ tableView: UITableView,
  dragPreviewParametersForRowAt indexPath: IndexPath
) -> UIDragPreviewParameters? {
  guard
    let cell = tableView.cellForRow(at: indexPath)
  else {
    return nil
  }
  let preview = UIDragPreviewParameters()
  preview.visiblePath = UIBezierPath(roundedRect: cell.bounds.insetBy(dx: 5, dy: 5), cornerRadius: 12)
  return preview
}

Unfortunately, there are no ready-made implementations in the D’n’D API for highlighting the crash site, but there are fairly simple ways to achieve the desired result. I solved this problem as follows:

  1. Created a backdropView and put it in a tableView

  2. While defining UITableViewDropProposal, I calculate where the cell was dropped. For this I use destinationIndexPath and the rectForRow (at: IndexPath) table method

  3. Animated backdropView frame to just computed

  4. I hide the backdropView when the drop session ends.

if let destinationIndexPath = destinationIndexPath {
	let newFrame = tableView.rectForRow(at: destinationIndexPath)

  if backdropView.frame == .zero {
    backdropView.frame = newFrame
  } else {
    UIView.animate(withDuration: 0.15) { [backdropView] in
      backdropView.frame = newFrame
    }
  }
} else {
  backdropView.frame = .zero
}

And what about the collections?

That’s all. The basic mechanics have been studied, all tasks have been completed. But what if, in the future, our screen is changed from UITableView to UICollectionView? And nothing will change. Almost. We just have to replace the delegate names, and change all references to tables to collections. The list and signatures of the required methods are the same.

SwiftUI

We could have finished with this, but … Building interfaces using SwiftUI assumes a declarative approach. This leaves its mark on the use of previously familiar technologies, changes and Drag and Drop were affected. Well, let’s figure this out as well.

As well as in UIKit we have 2 ways to implement this mechanic. This is the usual sorting of the list and a full-fledged Drag and Drop.

Using onMove

All that is needed to implement the standard sort is to add the onMove modifier:

func onMove(
	perform action: Optional<(IndexSet, Int) -> Void>
) -> some DynamicViewContent

This modifier sets the action to be taken as a closure after the move is complete. In it, you refer to the business logic and physically change the sorting.

ForEach(group.teams) { team in
  TeamView(team: team)
  Divider()
}
.onMove(perform: { indices, newOffset in
  // do smth
})

This method has the same advantages as in UIKit, namely: simplicity and speed of implementation. However, the disadvantages are significant. So, in SwiftUI, we can use this modifier only for objects of type DynamicViewContent. This is a special kind of view generated by collection containers such as ForEach. Also, we cannot apply sorting between sections, and also – the View must be in edit mode.

Using onDrag and onDrop

Unlike sorting, DnD is available not only for DynamicViewContent. We can start moving any objects and drop them onto any supported View. As in UIKit, the implementation of DnD in SwiftUI is divided into 2 stages – handling the raising of the element and its falling. Let’s analyze them.

In order for the object to be available for lifting, a modifier must be applied by calling the onDrag method:

func onDrag(_ data: @escaping () -> NSItemProvider) -> some View

Inside the method, we must return an NSItemProvider for a specific item. This data structure has been used since UIKit and is used as a transport of data between and within processes.

TeamView(team: team)
.onDrag({
  let draggedItem = DropItem(division: group.division, team: team)
  self.draggedItem = draggedItem
  return NSItemProvider(
    object: draggedItem
  )
})

Hooray, now our object can be moved. Next, you need to handle its movement and fall. For this we need the onDrop method and the DropDelegate delegate.

Disclaimer: Watch your hands carefully.

When it comes to delegates in UIKit, we envision a standard implementation of the delegation pattern. In it, we have one instance of the delegate class, for example, for a table. And all the information sufficient to make a certain decision is passed to its methods, regardless of the context of the call. It usually looks like this:

tableView.delegate = delegate
tableView.dragDelegate = dragDelegate
tableView.dropDelegate = dropDelegate

SwiftUI is a different story. One instance of the delegate implies binding to a specific fall zone. When creating a delegate, we pass it all the necessary data to calculate the logic and content of this session.

TeamView(team: team)
.onDrag(...)
.onDrop(
  of: DropItem.writableTypeIdentifiersForItemProvider,
  delegate: TeamsDropDelegate(
    droppedItem: DropItem(division: group.division, team: team),
    draggedItem: $draggedItem,
    items: $viewModel.groups
  )
)

This completes the processing in the View. Our object can rise and fall. It remains to implement the call to the business logic. To do this, let’s turn to the DropDelegate we created.

DropDelegate

DropDelegate in SwiftUI performs the same functions as in UIKit, but with one difference – it is created for each View that takes over objects with D’n’D. Usually, it stores all the information necessary for making a decision, either statically or through the Binding mechanism. An example of our delegate fields is below:

struct TeamsDropDelegate: DropDelegate {
  private let uuid = UUID()
  let droppedItem: DropItem
  @Binding var draggedItem: DropItem?
  @Binding var items: [Group]
}

The delegate also has a list of required methods. They all relate to the lifecycle of the dropSession. Some of them have a default implementation, so the minimum set includes the definition of the performDrop method (info: DropInfo).

func performDrop(info: DropInfo) -> Bool {
  draggedItem = nil
  return true
}

Here it turns out one very important the nuance of D’n’D in SwiftUI is that we ourselves are responsible for everything. In general, for everything. Even for sorting objects, when we are just moving the object and do not drop it.

UIKit did it for us depending on DropProposal, and in SwiftUI we have to catch the re-sorting ourselves. Cancel, of course, too. For example like this:

func dropEntered(info: DropInfo) {
  guard let draggedItem = self.draggedItem else {
    return
  }

  guard
    draggedItem.team != droppedItem.team,
    draggedItem.division.id == droppedItem.division.id,
    let divisionIndex = items.firstIndex(where: { $0.division.id == draggedItem.division.id }),
    let from = items[divisionIndex].teams.firstIndex(of: draggedItem.team),
    let to = items[divisionIndex].teams.firstIndex(of: droppedItem.team)
  else {
    return
  }

  withAnimation(.default) {
    self.items[divisionIndex].teams.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to)
  }
}

Instead of titles

Perhaps this is all that I wanted to tell you about Drag and Drop in this article. This is a really rich and useful API that can be used outside of tables and collections as well. It makes life much easier for both developers and end users.

If you have any questions – ask them in the comments or directly to the mail – anikitin@65apps.com

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *