Episode #215

Custom Dragging with UICollectionView

24 minutes
Published on April 1, 2016

This video is only available to subscribers. Get access to this video and 572 others.

In this episode we add a custom drag behavior to reorder collection view cells. UICollectionViewController gives us some of this behavior, but to add transforms, shadows, and animation we'll have to implement our own.

Episode Links

UICollectionViewController supports reordering with a long-press gesture, but it doesn’t offer much customization. We can write our own and have full control.

We can add this behavior using a custom layout.

Create a Custom Layout

We’ll create a custom layout for this and install our own gesture recognizer (only once) when prepareLayout is called.

import UIKit

class CustomFlowLayout : UICollectionViewFlowLayout {
    var longPress: UILongPressGestureRecognizer!

    override func prepareLayout() {
        super.prepareLayout()

        installGestureRecognizer()
    }

    func installGestureRecognizer() {
        if longPress == nil {
            longPress = UILongPressGestureRecognizer(target: self, action: #selector(CustomFlowLayout.handleLongPress(_:)))
            longPress.minimumPressDuration = 0.2
            collectionView?.addGestureRecognizer(longPress)
        }
    }

    fun handleLongPress(longPress: UILongPressGestureRecognizer) {
    }
}

Note the use of the new strongly-typed Swift 2.2 #selector syntax for the gesture recognizer target/action.

When we get a long press we’ll get it’s location and handle the state property:

    func handleLongPress(longPress: UILongPressGestureRecognizer) {
        let location = longPress.locationInView(collectionView!)
        switch longPress.state {
        case .Began: startDragAtLocation(location)
        case .Changed: updateDragAtLocation(location)
        case .Ended: endDragAtLocation(location)
        default:
            break
        }
    }

Handling the Start of a Drag

    func startDragAtLocation(location: CGPoint) {
        guard let cv = collectionView else { return }
        guard let indexPath = cv.indexPathForItemAtPoint(location) else { return }
        guard cv.dataSource?.collectionView?(cv, canMoveItemAtIndexPath: indexPath) == true else { return }
        guard let cell = cv.cellForItemAtIndexPath(indexPath) else { return }

        originalIndexPath = indexPath
    draggingIndexPath = indexPath
        draggingView = cell.snapshotViewAfterScreenUpdates(true)
        draggingView!.frame = cell.frame
        cv.addSubview(draggingView!)

        dragOffset = CGPointMake(draggingView!.center.x - location.x, draggingView!.center.y - location.y)

        draggingView?.layer.shadowPath = UIBezierPath(rect: draggingView!.bounds).CGPath
        draggingView?.layer.shadowColor = UIColor.blackColor().CGColor
        draggingView?.layer.shadowOpacity = 0.8
        draggingView?.layer.shadowRadius = 10

        invalidateLayout()

        UIView.animateWithDuration(0.4, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0, options: [], animations: {
            self.draggingView?.alpha = 0.95
            self.draggingView?.transform = CGAffineTransformMakeScale(1.2, 1.2)
        }, completion: nil)
    }

Handling Drag Updates

When the long-press gesture moves around, we'll have to move the view to match. Here we'll take account for the offset we saved earlier to avoid any snapping-to-center behavior.

If the drag ends up at a new indexPath, we'll superficially ask the collectionView to swap the rows. Note that we don't update the dataSource here. We want to wait until the drag has completed for that.

func updateDragAtLocation(location: CGPoint) {
        guard let view = draggingView else { return }
        guard let cv = collectionView else { return }

        view.center = CGPointMake(location.x + dragOffset.x, location.y + dragOffset.y)

        if let newIndexPath = cv.indexPathForItemAtPoint(location) {
                cv.moveItemAtIndexPath(draggingIndexPath!, toIndexPath: newIndexPath)
                draggingIndexPath = newIndexPath
        }
}

Finishing a Drag

When the user lifts their finger, we'll finish the drag. Here we'll update the dataSource to make sure the model is in sync with our changes. We'll also fade out the shadow we added earlier and animate the view to its final resting point.

func endDragAtLocation(location: CGPoint) {
        guard let dragView = draggingView else { return }
        guard let indexPath = draggingIndexPath else { return }
        guard let cv = collectionView else { return }
        guard let datasource = cv.dataSource else { return }

        let targetCenter = datasource.collectionView(cv, cellForItemAtIndexPath: indexPath).center

        let shadowFade = CABasicAnimation(keyPath: "shadowOpacity")
        shadowFade.fromValue = 0.8
        shadowFade.toValue = 0
        shadowFade.duration = 0.4
        dragView.layer.addAnimation(shadowFade, forKey: "shadowFade")

        UIView.animateWithDuration(0.4, delay: 0, usingSpringWithDamping: 0.4, initialSpringVelocity: 0, options: [], animations: { 
                dragView.center = targetCenter
                dragView.transform = CGAffineTransformIdentity

        }) { (completed) in

                if !indexPath.isEqual(self.originalIndexPath!) {
                        datasource.collectionView?(cv, moveItemAtIndexPath: self.originalIndexPath!, toIndexPath: indexPath)
                }

                dragView.removeFromSuperview()
                self.draggingIndexPath = nil
                self.draggingView = nil
                self.invalidateLayout()
        }
}