|
| 1 | +We'll learn how to reorder cells, drag and drop multiple cells, move cells between collections, and even between applications. |
| 2 | + |
| 3 | +In this part, we'll cover dragging and dropping for collections and tables. In the next part, we'll see how to drag any views anywhere and handle resetting them. Before we dive, let's break down how the drag and drop lifecycle is designed. |
| 4 | + |
| 5 | + |
| 6 | + |
| 7 | +## Models |
| 8 | + |
| 9 | +Drag is responsible for moving the object, while the drop is responsible for dropping the object and a new position. There is no service responsible for starting a drag. When a finger with a cell crawls across the screen, the delegate method is called. Very similar to `UIScrollViewDelegate` with the `scrollViewDidScroll` method. |
| 10 | + |
| 11 | +The `UIDragSession` and `UIDropSession` become available when the delegate methods are called. These are wrappers with information about finger position, objects for which actions were taken, custom context, and others. Before starting the action, provide the `UIDragItem` object. `UIDragItem` is a wrapper over the data. Literally, that's what we want to drag. |
| 12 | + |
| 13 | +```swift |
| 14 | +let itemProvider = NSItemProvider.init(object: yourObject) |
| 15 | +let dragItem = UIDragItem(itemProvider: itemProvider) |
| 16 | +dragItem.localObject = action |
| 17 | +return dragItem |
| 18 | +``` |
| 19 | + |
| 20 | +Implement the `NSItemProviderWriting` protocol so that the provider can «eat» any object: |
| 21 | + |
| 22 | +```swift |
| 23 | +extension YourClass: NSItemProviderWriting { |
| 24 | + |
| 25 | + public static var writableTypeIdentifiersForItemProvider: [String] { |
| 26 | + return ["YourClass"] |
| 27 | + } |
| 28 | + |
| 29 | + public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { |
| 30 | + return nil |
| 31 | + } |
| 32 | +} |
| 33 | +``` |
| 34 | + |
| 35 | +We're ready. |
| 36 | + |
| 37 | +## Drag |
| 38 | + |
| 39 | +We'll use a collection. It's better to use `UICollectionViewController`, it can do more from the box. A simple view will also do. |
| 40 | + |
| 41 | +Set up a drag delegate: |
| 42 | + |
| 43 | +```swift |
| 44 | +class CollectionController: UICollectionViewController { |
| 45 | + |
| 46 | + func viewDidLoad() { |
| 47 | + super.viewDidLoad() |
| 48 | + collectionView.dragDelegate = self |
| 49 | + } |
| 50 | +} |
| 51 | +``` |
| 52 | + |
| 53 | +Let's implement the `UICollectionViewDragDelegate` protocol. The first method will be `itemsForBeginning`: |
| 54 | + |
| 55 | +```swift |
| 56 | +func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { |
| 57 | + let itemProvider = NSItemProvider.init(object: yourObject) |
| 58 | + let dragItem = UIDragItem(itemProvider: itemProvider) |
| 59 | + dragItem.localObject = action |
| 60 | + return dragItem |
| 61 | + } |
| 62 | +``` |
| 63 | + |
| 64 | +You have already seen this code above. It wraps our item in `UIDragItem`. The method is called when we suspect that the user wants to start a drag. Do not use this method as the initial drag, since calling it only assumes that the drag is just about to start. |
| 65 | + |
| 66 | +Let's add two methods — `dragSessionWillBegin` and `dragSessionDidEnd`: |
| 67 | + |
| 68 | +```swift |
| 69 | +extension CollectionController: UICollectionViewDragDelegate { |
| 70 | + |
| 71 | + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { |
| 72 | + let itemProvider = NSItemProvider.init(object: yourObject) |
| 73 | + let dragItem = UIDragItem(itemProvider: itemProvider) |
| 74 | + dragItem.localObject = action |
| 75 | + return dragItem |
| 76 | + } |
| 77 | + |
| 78 | + func collectionView(_ collectionView: UICollectionView, dragSessionWillBegin session: UIDragSession) { |
| 79 | + |
| 80 | + } |
| 81 | + |
| 82 | + func collectionView(_ collectionView: UICollectionView, dragSessionDidEnd session: UIDragSession) { |
| 83 | + |
| 84 | + } |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +The first method is called when drag has been started. The second method is called when drag is over. Before `dragSessionWillBegin` the `itemsForBeginning` method is called. But it is not certain that if `itemsForBeginning` is called, the `dragSessionWillBegin` method will be also called. |
| 89 | + |
| 90 | +If you need to update the interface for the dragging time (hide the buttons), this is the right place. Now, let's see what we get at this point. |
| 91 | + |
| 92 | +[Drag Preview](https://cdn.ivanvorobei.by/websites/sparrowcode.io/drag-and-drop-part-1/drag-delegate.mov) |
| 93 | + |
| 94 | +The cell returns to its original position. We'll take care of the implementation of the drop below. |
| 95 | + |
| 96 | +## Drop |
| 97 | + |
| 98 | +Drag is half the story. Now we're going to learn how to drop a cell to the proper position. Implement the `UICollectionViewDropDelegate` protocol: |
| 99 | + |
| 100 | +```swift |
| 101 | +extension CollectionController: UICollectionViewDropDelegate { |
| 102 | + |
| 103 | + func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { |
| 104 | + |
| 105 | + } |
| 106 | + |
| 107 | + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { |
| 108 | + |
| 109 | + } |
| 110 | + |
| 111 | + func collectionView(_ collectionView: UICollectionView, dropSessionDidEnd session: UIDropSession) { |
| 112 | + |
| 113 | + } |
| 114 | +} |
| 115 | +``` |
| 116 | + |
| 117 | +The first method requires the `UICollectionViewDropProposal` object to be returned. This method is responsible for reviewing and updating the interface, it tells the user what will happen if the drop is done now. |
| 118 | + |
| 119 | +You can return one of several statuses, so let's analyze each one. |
| 120 | + |
| 121 | +```swift |
| 122 | +// The cell will return to default, without any visual indicators. The action doesn't displace other cells. |
| 123 | +return .init(operation: .cancel) |
| 124 | +// A gray icon will appear. This means that the operation is forbidden. |
| 125 | +return .init(operation: .forbidden) |
| 126 | +// A useful action will occur, the visual indicators will not appear. |
| 127 | +return .init(operation: .move) |
| 128 | +// Cells are moved for the proposed drop location, no visual indicators will appear. |
| 129 | +return .init(operation: .move, intent: .insertAtDestinationIndexPath) |
| 130 | +// A green plus appears that looks like a copy indicator. |
| 131 | +return .init(operation: .copy) |
| 132 | +``` |
| 133 | + |
| 134 | +In our example, if there is a predicted IndexPath, we allow the reset. If not, we deny it. It's better to put a cancel, but it will be more clear. |
| 135 | + |
| 136 | +```swift |
| 137 | +func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { |
| 138 | + |
| 139 | + guard let _ = destinationIndexPath else { return .init(operation: .forbidden) } |
| 140 | + return .init(operation: .move, intent: .insertAtDestinationIndexPath) |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +The `destinationIndexPath` is a system calculation where a cell can be dropped. It doesn't require anything, and you can drop it somewhere else. Moving on to the next `performDropWith` method. |
| 145 | + |
| 146 | +Now we move on to the important step. We change the data, rearrange the cells, and notify the system where the view was dropped so that the system draws the animation. |
| 147 | + |
| 148 | +```swift |
| 149 | +func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { |
| 150 | + |
| 151 | + // Stop execution if the system could not determine IndexPath. |
| 152 | + // Later we will learn how to determine the index, but now we will leave it that way. |
| 153 | + guard let destinationIndexPath = coordinator.destinationIndexPath else { return } |
| 154 | + |
| 155 | + for item in coordinator.items { |
| 156 | + // Get access to our object and cast a type. |
| 157 | + guard let yourObject = item.dragItem.localObject as? YourClass else { continue } |
| 158 | + // We move the object from one place to another. I use a pseudo function with custom logic: |
| 159 | + move(object: yourObject, to: destinationIndexPath) |
| 160 | + } |
| 161 | + |
| 162 | + // Don't forget to update collection. |
| 163 | + // If you are using a classic data source, make the changes in the `performBatchUpdates` block. |
| 164 | + // If you have a diffable data source, use the snapshot update. |
| 165 | + // The method below doesn't exist. |
| 166 | + collectionView.reloadAnimatable() |
| 167 | + |
| 168 | + // Notify where the element is dumped. |
| 169 | + // Implement the `getIndexPath` function yourself. |
| 170 | + for item in coordinator.items { |
| 171 | + guard let yourObject = item.dragItem.localObject as? YourClass else { continue } |
| 172 | + if let indexPath = getIndexPath(for: yourObject) { |
| 173 | + coordinator.drop(item.dragItem, toItemAt: indexPath) |
| 174 | + } |
| 175 | + } |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +Now the collection and data source are updated when you move it, and the cell is dropped at the new index. Let's see what happened: |
| 180 | + |
| 181 | +[Drag Preview](https://cdn.ivanvorobei.by/websites/sparrowcode.io/drag-and-drop-part-1/drop-delegate.mov) |
| 182 | + |
| 183 | +To make the cells split to drop another cell, use Drop Proposal with `.insertAtDestinationIndexPath`. Any other intent won't do this. Be careful, because sometimes bugs happen with the collection |
| 184 | + |
| 185 | +## Drag multiple cells |
| 186 | + |
| 187 | +In the `UICollectionViewDragDelegate` protocol, we implemented the `itemsForBeginning` method. It returned a drag object. To add more objects to the current drag, implement the `itemsForAddingTo` method: |
| 188 | + |
| 189 | +```swift |
| 190 | +func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { |
| 191 | + // Same code. |
| 192 | + // Create an `UIDragItem` based on object. |
| 193 | + let itemProvider = NSItemProvider.init(object: yourObject) |
| 194 | + let dragItem = UIDragItem(itemProvider: itemProvider) |
| 195 | + dragItem.localObject = action |
| 196 | + return dragItem |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +Now the cells will be collected in a stack and the group can be moved. |
| 201 | + |
| 202 | +[Drag Stack](https://cdn.ivanvorobei.by/websites/sparrowcode.io/drag-and-drop-part-1/drag-stack.mov) |
| 203 | + |
| 204 | +## Table View |
| 205 | + |
| 206 | +There are similar protocols for table `UITableViewDragDelegate` and `UITableViewDropDelegate`. The methods are repeated with a reference to the table. |
| 207 | + |
| 208 | +```swift |
| 209 | +public protocol UITableViewDragDelegate: NSObjectProtocol { |
| 210 | + |
| 211 | + optional func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] |
| 212 | + |
| 213 | + optional func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) |
| 214 | + |
| 215 | + optional func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +Drop works the same way. Note that drop is more stable in the table, because of missing layouts. |
| 220 | + |
| 221 | +The editing table has no effect on drop method calls. |
| 222 | + |
| 223 | +```swift |
| 224 | +tableView.isEditing = true |
| 225 | +``` |
| 226 | + |
| 227 | +You can have a system cell reorder and drop, for example, inside cells. |
| 228 | + |
| 229 | +[Table Drop](https://cdn.ivanvorobei.by/websites/sparrowcode.io/drag-and-drop-part-1/table-drop.mov) |
| 230 | + |
| 231 | +## DestinationIndexPath |
| 232 | + |
| 233 | +The system parameter `DestinationIndexPath` does not always determine the position perfectly. For example, if you go beyond the edge of the collection content, the system will not suggest resetting the cell as last. |
| 234 | + |
| 235 | +Let's write a function that can suggest its index if the system suggestion equals `nil`. |
| 236 | + |
| 237 | +```swift |
| 238 | +// We use the system index and the drop session as input parameters. |
| 239 | +// If the system index equals `nil`, then we will have two calculation systems. |
| 240 | +private func getDestinationIndexPath(system passedIndexPath: IndexPath?, session: UIDropSession) -> IndexPath? { |
| 241 | + |
| 242 | + // Try to get an index of the drop location. |
| 243 | + // Most often the result will be the same as the system result, but when the system result is not present, it may return a good value. |
| 244 | + let systemByLocationIndexPath = collectionView.indexPathForItem(at: session.location(in: collectionView)) |
| 245 | + |
| 246 | + // The code below is difficult to understand. |
| 247 | + // We take the location and look for the nearest cell within a radius of 100 points. |
| 248 | + var customByLocationIndexPath: IndexPath? = nil |
| 249 | + if systemByLocationIndexPath == nil { |
| 250 | + var closetCell: UICollectionViewCell? = nil |
| 251 | + var closetCellVerticalDistance: CGFloat = 100 |
| 252 | + let tapLocation = session.location(in: collectionView) |
| 253 | + |
| 254 | + for indexPath in collectionView.indexPathsForVisibleItems { |
| 255 | + guard let cell = collectionView.cellForItem(at: indexPath) else { continue } |
| 256 | + let cellCenterLocation = collectionView.convert(cell.center, to: collectionView) |
| 257 | + let verticalDistance = abs(cellCenterLocation.y - tapLocation.y) |
| 258 | + if closetCellVerticalDistance > verticalDistance { |
| 259 | + closetCellVerticalDistance = verticalDistance |
| 260 | + closetCell = cell |
| 261 | + } |
| 262 | + } |
| 263 | + |
| 264 | + if let cell = closetCell { |
| 265 | + customByLocationIndexPath = collectionView.indexPath(for: cell) |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + // Return the value in order of priority. |
| 270 | + return passedIndexPath ?? systemByLocationIndexPath ?? customByLocationIndexPath |
| 271 | +} |
| 272 | +``` |
| 273 | + |
| 274 | +We can also improve the code to update the interface: |
| 275 | + |
| 276 | +```swift |
| 277 | +func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { |
| 278 | + |
| 279 | + guard let _ = getDestinationIndexPath(system: destinationIndexPath, session: session) else { return .init(operation: .forbidden) } |
| 280 | + return .init(operation: .move, intent: .insertAtDestinationIndexPath) |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +Note: the method will only help with the drop. If you use `.insertAtDestinationIndexPath`, you can't override how cells are indented. |
| 285 | + |
| 286 | +## Issues |
| 287 | + |
| 288 | +Most of the problems are related to the collection, specifically to the layout. Of the known problems, when you try to drop a cell last FlowLayout will ask for nonexistent cell attributes. When cells are expanded, the layout draws a cell inside, and dropping it will result in more cells than the models in the Data Source. This can be solved by overriding the method in `UICollectionViewFlowLayout`: |
| 289 | + |
| 290 | +```swift |
| 291 | +override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { |
| 292 | + if let countItems = collectionView?.numberOfItems(inSection: indexPath.section) { |
| 293 | + if countItems == indexPath.row { |
| 294 | + // If ask layout cell which not isset, |
| 295 | + // shouldn't call super. |
| 296 | + return nil |
| 297 | + } |
| 298 | + } |
| 299 | + return super.layoutAttributesForItem(at: indexPath) |
| 300 | +} |
| 301 | +``` |
| 302 | + |
| 303 | +`.insertAtDestinationIndexPath' works poorly when pulling a cell from one collection to another. The application crashes when dragging outside of the first section, this is related to the layout. I haven't found any problems with the tables. |
| 304 | + |
| 305 | +We finished the first part. When the second is ready, I will add a link to it. If you need a video or still have questions write comments to the post in the [telegram](https://t.me/sparrowcode/55). |
0 commit comments