Writing a Custom UIViewController Transition

On a recent project, I had the opportunity to work closely with a talented designer on a custom UITableView transition.

The result was awesome, but I wanted to make the code reusable. After a couple of hours, I had SplitTransition.



How It Works

In a nutshell, SplitTransition works by taking a screen capture of the current window, cutting the screen capture into 2 slices, and applying a vertical transform to each one so that they slide away to reveal the destination view.

It’s not hard to generate a screen capture of a view controller’s view. But it’s often the case that we want the entire window, which might include a UITabBar that is outside the view controller’s view hierarchy. Fortunately UIWindow+screenshot, another helpful extension from NiceUtils, handles this for us.

Using a screen capture generated with UIWindow+screenshot, we create two UIImageViews. The first (topSplitImageView) extends from the top of the screen capture’s frame to a predefined splitLocation, and the second (bottomSplitImageView), extends from the same splitLocation down to the bottom:

/**
 * Y coordinate where top and bottom screen captures
 * should split
 */
public var splitLocation: CGFloat = 0.0

/**
 * Screen capture extending from split location
 * to top of screen
 */
lazy var topSplitImageView: UIImageView = {
    let imageView = UIImageView()
    imageView.image = self.screenCapture
    imageView.contentMode = .Top
    imageView.clipsToBounds = true
    return imageView
}()

/**
 * Screen capture extending from split location
 * to bottom of screen
 */
lazy var bottomSplitImageView: UIImageView = {
    let imageView = UIImageView()
    imageView.image = self.screenCapture
    imageView.contentMode = .Bottom
    imageView.clipsToBounds = true
    return imageView
}()

// Set bounds for top and bottom screen captures
let width = containerView.frame.size.width ?? 0.0
let height = containerView.frame.size.height ?? 0.0

// Top screen capture extends from split location to top of view
topSplitImageView.frame = CGRectMake(0.0, 0.0, width, splitLocation)

// Bottom screen capture extends from split location to bottom of view
bottomSplitImageView.frame = CGRectMake(0.0, splitLocation, width, height - splitLocation)

To keep track of whether the transition is a push or a pop, we store a property on the SplitTransition specifying the transition’s type:

public enum TransitionType {
    case Push
    case Pop
}

If the animation type is .Push, we add the topSplitImageView and bottomSplitImageView to the top of the view hierarchy, set the source viewController’s alpha to 0.0, and use CGAffineTransformMakeTranslation to animate the topSplitImageView and bottomSplitImageView’s transforms (topSplitImageView moves up, and bottomSplitImageView moves down). When the animation is finished, we remove topSplitImageView and bottomSplitImageView from the view hierarchy, revealing the destination view controller:

            // Add subviews
            containerView.addSubview(toViewController.view)
            containerView.addSubview(topSplitImageView)
            containerView.addSubview(bottomSplitImageView)

            // Set initial frames for screen captures
            setInitialScreenCaptureFrames(containerView)

            // source view controller is initially hidden
            fromViewController.view.alpha = 0.0
            toViewController.view.alpha = 1.0
            toViewController.view.transform = CGAffineTransformMakeTranslation(0.0, topSplitImageView.frame.size.height)

            UIView.animateWithDuration(transitionDuration, delay: 0.0, usingSpringWithDamping: 0.65, initialSpringVelocity: 1.0, options: .LayoutSubviews, animations: { [weak self] () -> Void in
                if let controller = self {
                    controller.topSplitImageView.transform = CGAffineTransformMakeTranslation(0.0, -controller.topSplitImageView.bounds.size.height)
                    controller.bottomSplitImageView.transform = CGAffineTransformMakeTranslation(0.0, controller.bottomSplitImageView.bounds.size.height)
                    toViewController.view.transform = CGAffineTransformIdentity
                }
                }) { [weak self] (Bool) -> Void in
                    // When the transition is finished, top and bottom
                    // split views are removed from the view hierarchy
                    if let controller = self {
                        controller.topSplitImageView.removeFromSuperview()
                        controller.bottomSplitImageView.removeFromSuperview()
                    }

                    // If a completion was passed as a parameter,
                    // execute it
                    if let completion = completion {
                        completion()
                    }
            }

If the animation type is .Pop, we do the same thing in reverse:


            // Add subviews
            containerView.insertSubview(toViewController.view, belowSubview: fromViewController.view)
            containerView.addSubview(toViewController.view)
            containerView.addSubview(topSplitImageView)
            containerView.addSubview(bottomSplitImageView)

            // Destination view controller is initially hidden
            toViewController.view.alpha = 0.0

            // Set initial transforms for top and bottom split views
            topSplitImageView.transform = CGAffineTransformMakeTranslation(0.0, -topSplitImageView.bounds.size.height)
            bottomSplitImageView.transform = CGAffineTransformMakeTranslation(0.0, bottomSplitImageView.bounds.size.height)

            UIView.animateWithDuration(transitionDuration, delay: 0.0, usingSpringWithDamping: 0.65, initialSpringVelocity: 1.0, options: .LayoutSubviews, animations: { [weak self] () -> Void in
                if let controller = self {

                    // Restore the top and bottom screen
                    // captures to their original positions
                    controller.topSplitImageView.transform = CGAffineTransformIdentity
                    controller.bottomSplitImageView.transform = CGAffineTransformIdentity

                    // Restore fromVC's view to its original position
                    fromViewController.view.transform = CGAffineTransformMakeTranslation(0.0, controller.topSplitImageView.bounds.size.height)
                }
                }) { [weak self] (Bool) -> Void in
                    // When the transition is finished, top and bottom
                    // split views are removed from the view hierarchy
                    if let controller = self {
                        controller.topSplitImageView.removeFromSuperview()
                        controller.bottomSplitImageView.removeFromSuperview()
                    }

                    // Make destination view controller's view visible again
                    toViewController.view.alpha = 1.0

                    // If a completion was passed as a parameter,
                    // execute it
                    if let completion = completion {
                        completion()
                    }
            }

To see the full source code for SplitTransition, check out NiceUtils here.

Usage

Usage is simple. In your UITableViewController, create storage for a SplitTransitionController and for a variable that can be used to keep track of the currently selected cell.

class TableViewController: UITableViewController {
  var currentTransition: SplitTransitionController?
  var currentCell: UICollectionViewCell?
}

Then, extend your TableViewController to implement the UINavigationControllerDelegate protocol. In your implementation of animationControllerForOperation, create two code paths:

If the operation is a Push, then create a new SplitTransitionController instance and store it. Then, set a splitLocation where you’d like the tableview to part, and make sure to set the transitionType to .Push. You can optionally set a duration here too.

If the operation is a Pop, all you need to do is set the SplitTransitionController’s transitionType to .Pop.


extension TableViewController: UINavigationControllerDelegate {

    func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {

        if (operation == .Push && fromVC == self) {
            let splitTransition = SplitTransitionController()
            splitTransition.transitionDuration = 2.0
            splitTransition.transitionType = .Push
            splitTransition.splitLocation = CGPointMake(0.0, CGRectGetMidY(currentCell?.frame))
            currentTransition = splitTransition
        }
        else if (operation == .Pop && toVC == self) {
            currentTransition?.transitionType = .Pop
        }

        return currentTransition
    }

}

In didSelectRowAtIndexPath, set the navigationController’s delegate, create an instance of your destination view controller, set currentCell to the selected cell, and push your destination view controller onto the nav stack:

    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        navigationController?.delegate = self
        let cell = tableView.dequeueReusableCellWithIdentifier("myReuseIdentifier", forIndexPath: indexPath)
        let destinationViewController = DestinationViewController()
        navigationController?.pushViewController(destinationViewController, animated: true)
    }

And that’s it!

To see a demo project with SplitTransitionController in action, follow this link to the GitHub repository.