An iOS Collection View Drag and Drop Tutorial

From Techotopia
Revision as of 17:13, 27 March 2018 by Neil (Talk | contribs)

Jump to: navigation, search

The previous chapter demonstrated the use of drag and drop involving views contained in a standard view controller. Drag and drop support is also available for the TableView and CollectionView classes. Many of the concepts are the same as those used previously, though there are some differences in terms of the delegate methods. The techniques for CollectionView based drag and drop handling is largely the same as that for the TableView. This chapter will demonstrate the key drag and drop features in relation to a CollectionView example.


Contents


The Example Application

In this chapter, the CollectionDemo project created in the chapter entitled An iOS Storyboard-based Collection View Tutorial will be extended to integrate drag and drop support. At the end of this chapter the app will allow multiple images to be selected from the collection view layout and dragged to another app. It will also be possible to drag and drop multiple images from outside the app onto the collection view. Finally, code will be added to allow an existing image within the collection view to be moved to another position in the layout using drag and drop.

Begin by locating the CollectionDemo project and loading it into Xcode. If you have yet to create this project, a pre-prepared copy can be found in the source code archive accompanying this book within the CollectionDemo folder.

Declaring the Drag Delegate

As with the previous chapter, the first step in implementing drop handling is to declare and implement a drop delegate class. When working with CollectionViews, the UICollectionViewDragDelegate must be implemented.

For this example, the MyCollectionViewController class will serve as both the drop and drag delegates. Since drop support will not be added until later in the chapter, only the drag delegate declaration needs to be added to the MyCollectionViewController.swift file as follows. Note also that code has been added to the viewDidLoad method to assign the class as the drag delegate:

.
.
class MyCollectionViewController: UICollectionViewController, 
   UICollectionViewDelegateFlowLayout, UICollectionViewDragDelegate {
    
    var imageFiles = [String]()
    var images = [UIImage]()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let myLayout = MyFlowLayout()
        self.collectionView?.setCollectionViewLayout(myLayout,
                                                     animated: true)
        
        collectionView?.dragDelegate = self
        initialize()
    }
.
.

Implementing Drag Support

The first delegate method that needs to be added to the class is the itemForBeginning method. As with the example in the previous chapter, this method returns an array of UIDragItem objects representing the items to be dragged to the destination. The itemsForBeginning method for the UICollectionViewDragDelegate differs slightly from the UIDragInteractionDelegate used in the previous chapter in that it is also passed an IndexPath object indicating the item within the collection view layout that is being dragged. This IndexPath instance contains both the index value of the dragged item and the section within the collection view to which it belongs. Since the layout in this example contains a single section, only the item index is needed. Implement this method now in the MyCollectionViewController.swift file so that it reads as follows:

func collectionView(_ collectionView: UICollectionView, itemsForBeginning 
    session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {

    let provider = NSItemProvider(object: images[indexPath.row])
    let dragItem = UIDragItem(itemProvider: provider)
    return [dragItem]
}

The code in the delegate method identifies the item being dragged, uses that value as an index into the array of car images and uses that image when creating an NSItemProvider instance. This item provider is subsequently used to create a UIDragItem instance which is returned to the drag and drop system.

Compile and run the app on a device or simulator and display the Photos app in a split view panel alongside the CollectionDemo app. Touch and hold over a car image until the preview image lifts from the screen and drag and drop it onto the Photo app where it should appear under the Today heading.

Now that dragging single items from the collection view layout is working, the next step is to add support for dragging multiple items.

Dragging Multiple Items

Support for adding additional items to a drag session is enabled by adding the itemsForAddingTo method to the delegate class. This method is called each time an item in the collection view layout is tapped while a drag session is already in progress. When called, it is passed the IndexPath for the selected item which can, in turn, be used to extract the corresponding image from the image array and to construct and return a UIDragItem object. Add this method to the MyCollectionViewController.swift file as follows:

func collectionView(_ collectionView: UICollectionView, itemsForAddingTo 
  session: UIDragSession, at indexPath: IndexPath, point: CGPoint) ->
    [UIDragItem] {
    
    let provider = NSItemProvider(object: images[indexPath.row])
    let dragItem = UIDragItem(itemProvider: provider)
    return [dragItem]
}

Compile and run the app once again, start a drag session and tap other images in the layout (when using the simulator, begin the drag and then hold down the Ctrl key while selecting additional images to add to the session). As each image is tapped, the counter in the top right-hand corner of the drag preview increments and the preview images for each item are stacked together as illustrated in Figure 75-1:


Ios 11 collection drag multiple.png

Figure 75 1


With multiple images selected, drag and drop the items onto the Photos app. All of the selected images will now appear in the Photos app.

With dragging now implemented, the next steps is to begin adding drop support to the project.

Adding the Drop Delegate

The first step in adding drop support is to declare and assign the UICollectionViewDropDelegate class. Remaining in the MyCollectionViewController.swift file, make the following changes:

class MyCollectionViewController: UICollectionViewController, 
     UICollectionViewDelegateFlowLayout, UICollectionViewDragDelegate,  
         UICollectionViewDropDelegate {
    
    var imageFiles = [String]()
    var images = [UIImage]()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let myLayout = MyFlowLayout()
        
        self.collectionView?.setCollectionViewLayout(myLayout,
                                                     animated: true)
        
        collectionView?.dragDelegate = self
        collectionView?.dropDelegate = self
        initialize()
    }
.
.
}

Implementing the Delegate Methods

At a minimum, the delegate class must implement the canHandle and performDropWith methods, each of which should be familiar from the previous chapter. Begin by adding the canHandle method to notify the system that the app can handle image drop items:

.
.
import MobileCoreServices
.
.
func collectionView(_ collectionView: UICollectionView, canHandle 
                           session: UIDropSession) -> Bool {

    return session.hasItemsConforming(toTypeIdentifiers: 
					[kUTTypeImage as String])
}

Next, add the performDrop method so that it reads as follows:

func collectionView(_ collectionView: UICollectionView, performDropWith 
                         coordinator: UICollectionViewDropCoordinator) {
    
    let destinationIndexPath = 
       coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)
    
    switch coordinator.proposal.operation {
        case .copy:
            
            let items = coordinator.items
            
            for item in items {
                item.dragItem.itemProvider.loadObject(ofClass: UIImage.self, 
			completionHandler: {(newImage, error)  -> Void in
                    
                if var image = newImage as? UIImage {
                    if image.size.width > 200 {
                        image = self.scaleImage(image: image, width: 200)
                    }
                
                    self.images.insert(image, at: destinationIndexPath.item)
                    
                    DispatchQueue.main.async {
                        collectionView.insertItems(
                                at: [destinationIndexPath])
                    }
                }
            })
        }
        default: return
    }
}

The performDrop method is called at the point at which the user drops the item or items associated with a drag operation. When called, the method is passed a drop coordinator object which contains, among other properties, the destination index path referencing the index position and section of the collection view layout at which the drop is to be performed. The method begins by obtaining this path information. If the drop was performed outside the range of items in the layout, the index path and section are both set to 0:

let destinationIndexPath = 
       coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)

The drop coordinator also includes a UIDropProposal object which, in turn, contains an operation property. This property tells the delegate method whether this is a cancel, copy or move operation. The method uses a switch statement to handle a copy operation (the switch statement will be extended later in the chapter to handle move operations):

switch coordinator.proposal.operation {
    case .copy:

Within the code for the copy case, the array of dropped items is extracted from the drop coordinator object before a for-in loop is used to iterate through each of the items.

let items = coordinator.items
                
                for item in items {

For each item, the code accesses the item provider instance via the drag item object and calls the loadObject method. The completion handler of the loadObject method is passed the image associated with the current item.

item.dragItem.itemProvider.loadObject(ofClass: UIImage.self, 
                completionHandler: {(newImage, error)  -> Void in

Within the completion handler code, the image is scaled down if necessary to more closely match the size of the existing images:

if var image = newImage as? UIImage {
    if image.size.width > 200 {
        image = self.scaleImage(image: image, width: 200)
    }

Finally, the new image is added to the image array before being inserted into the collection view layout at the position defined by the destination path. Since the insertion of the image into the collection view is a user interface operation and this completion handler is running in a different thread, the insertion has to be dispatched to the main thread of the app:

self.images.insert(image, at: destinationIndexPath.item)

DispatchQueue.main.async {
    collectionView.insertItems(at: [destinationIndexPath])
}

Before testing this new code, add the scaleImage method to the class as follows:

func scaleImage (image:UIImage, width: CGFloat) -> UIImage {
    let oldWidth = image.size.width
    let scaleFactor = width / oldWidth
    
    let newHeight = image.size.height * scaleFactor
    let newWidth = oldWidth * scaleFactor
    
    UIGraphicsBeginImageContext(CGSize(width:newWidth, height:newHeight))
    image.draw(in: CGRect(x:0, y:0, width:newWidth, height:newHeight))
    let newImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return newImage!
}

Compile and run the app and drag and drop an image from the Photos app onto the collection view. The dropped image should appear within the layout at the position at which it is dropped. Repeat this step, this time dragging and dropping multiple images from the Photos app onto the collection view so that all the images are inserted.

Adding Drag and Drop Animation

As currently configured, there is no animation within the collection view layout while the drag is being performed. This makes it less obvious to the user that the item will be inserted between existing cells when the drop is performed. The cells rearranging to reveal a blank space for the dropped item would provide a much improved user experience. This can be achieved with just a few lines of code via the dropSessionDidUpdate delegate method. This method is called each time the session updates (typically whenever the user moves the drop item over a destination) and is required to return a drop proposal object in the form of a UICollectionViewDropProposal object.

The proposal object contains values that indicate how the drop is to be handled by the destination. The first value is the operation type which, as described in the previous chapter, can be set to either move, copy, cancel or forbidden. A forbidden setting, for example, will display the forbidden icon in the drop preview indicating that the destination does not support the drop. The second setting is the intent value which must be set to one of the following options:

  • unspecified – The default behavior. No special animation will be performed within the collection view during the drag operation
  • insertAtDestinationIndexPath – Indicates that the dropped item will be inserted between two existing items within the layout. This causes the layout to rearrange to make space for the new item.
  • insertIntoDestinationIndexPath – The item is placed into the item over which the drop is performed.
  • automatic – The system will decide which intent mode to use depending on how the drop is positioned.

In this example the insertAtDestinationIndexPath intent will be used. Before doing that, however, it is worth exploring the insertIntoDestinationIndexPath intent a little more.

The insertIntoDestinationIndexPath intent could be used, for example, to replace an existing item with the dragged item. Alternatively, when using a TableView in which an item in the view displays a subview, this option can be used when the dropped item is to be added in some way to the subview. Consider, for example, a table view containing a list of photo categories. Selecting a category from the table view displays a collection view controller containing photos for that category. When using insertIntoDestinationIndexPath, dragging the item over a row in the table view will cause that cell to highlight indicating that the drop will add the photo to that category. After the item is dropped it can be added to the collection view for the corresponding category so that it appears next time the user displays it.

Within the MyCollectionViewController.swift file, implement the dropSessionDidUpdate delegate method as follows:

func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate 
 session: UIDropSession, withDestinationIndexPath destinationIndexPath: 
 IndexPath?) -> UICollectionViewDropProposal {

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

The method begins by identifying if this drag originated within the local collection view, or if the drag is from an outside application. A local drag is considered a move operation, but since moving of an image has not yet been implemented in this project, the operation and intent are set to forbidden and unspecified respectively. If, on the other hand, this is an image from another app, the proposal is returned as a copy operation using the insertAtDestinationIndexPath intent.

After making these changes, run the app and drag an image from the Photos app over the collection. As the preview image moves, the layout will rearrange to make room for the item as shown in Figure 75-2:


Ios 11 collection drag insert.png

Figure 75-2


Next, drag an existing image from within the collection view and note that the forbidden icon appears indicating that the item cannot be dropped and that the layout does not rearrange.


Ios 11 collection drag forbidden.png

Figure 75-3


Adding the Move Behavior

The final task in this chapter is to implement the ability for the user to move items within the layout using drag and drop. The first step is to change the code in the dropSessionDidUpdate delegate method to indicate that moving is now supported and to select the insertAtDestinationIndexPath intent:

func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate 
  session: UIDropSession, withDestinationIndexPath destinationIndexPath: 
   IndexPath?) -> UICollectionViewDropProposal {
    
    if session.localDragSession != nil {
        return UICollectionViewDropProposal(operation: .move, 
				intent: .insertAtDestinationIndexPath)
    } else {
        return UICollectionViewDropProposal(operation: .copy, 
				intent: .insertAtDestinationIndexPath)
    }
} 

Now that the dropSessionDidUpdate method will be returning a move value for local operations, the performDrop method needs to be updated to handle this setting in addition to the copy operation. Edit the method now to add an additional case construct to the switch statement:

func collectionView(_ collectionView: UICollectionView, performDropWith 
  coordinator: UICollectionViewDropCoordinator) {
    
    
    let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)
    
    switch coordinator.proposal.operation {
        case .copy:
.
.
.
        case .move:
           
            let items = coordinator.items
            
            for item in items {
                
                guard let sourceIndexPath = item.sourceIndexPath 
							else { return }
                
                collectionView.performBatchUpdates({
                    
                    let moveImage = images[sourceIndexPath.item]
                    images.remove(at: sourceIndexPath.item)
                    images.insert(moveImage, at: destinationIndexPath.item)
                    
                    collectionView.deleteItems(at: [sourceIndexPath])
                    collectionView.insertItems(at: [destinationIndexPath])
                })
                coordinator.drop(item.dragItem, 
				toItemAt: destinationIndexPath)
            }
            default: return
        }
}

As was the case with the copy operation, the new code gets a reference to the items contained in the drop and iterates through each one. For each valid item, the source index path is used to extract the corresponding image from the images array. The source image is then removed from the array and inserted at the index position referenced by the destination index path. Next, the image is deleted from its old position in the collection view layout and inserted at the drop location index. Finally, the drop operation is performed via a call to the drop method of the coordinator instance.

Run the app one last time and verify that it is now possible to move an item within the collection view layout using drag and drop.

TableView Drag and Drop

Many of the steps and delegate methods covered in this chapter apply equally to TableView-based drag and drop with the following changes:

  • TableView drag and drop uses UITableViewDragDelegate and UITableViewDropDelegate delegates.
  • The performDrop coordinator is an instance of UITableViewDropCoordinator.
  • The dropSessionDidUpdate method returns a UITableViewProposal object.

Summary

This chapter has demonstrated the use of drag and drop when working with collection views including support for dragging and dropping multiple items in a single session. The example created in this chapter has introduced some delegate methods that are specific to the Collection View drag and drop delegates and shown how implementing animation within the layout causes gaps to form for the dropped item. Most of the steps in this chapter also apply to the TableView class.