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 Source Code LXReorderableCollectionViewFlowLayout - This project has an implementation of scrolling while dragging that is with a look. Basically it defines a region on the edges of the collection view that trigger scrolling. 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() } }