Adding Interactivity to a Custom UIViewController Transition

I wrote in my last post about SplitTransition, a custom animated transition for UITableView. This week, I wanted to add interactivity to SplitTransition so that a user could control animation progress with a drag gesture.

Animated transitions are fairly straightforward. To write one, we can just subclass NSObject and implement UIViewControllerAnimatedTransitioning. Interactive transitions are more complicated. At minimum, we need to implement UIViewControllerInteractiveTransitioning, but beyond that there is a lot of additional logic that we need to write in order to manage the progress of the transition.

Going Interactive

Instead of subclassing NSObject, we subclass UIPercentDrivenInteractiveTransition, which implements both NSObject and UIViewControllerInteractiveTransitioning. UIPercentDrivenInteractiveTransition exposes 3 additional functions that give us a high degree of control over the transition:

    // Set a value for the progress of the current transition 
    public func updateInteractiveTransition(percentComplete: CGFloat)
    
    // Cancel the current transition
    public func cancelInteractiveTransition()

  // Complete the current transition            
    public func finishInteractiveTransition()

We can use updateInteractiveTransition to keep our transition abreast of how “complete” it is at any given time. cancelInteractiveTransition and finishInteractiveTransition are self-explanatory (we use the former to cancel the interactive transition currently in progress, and the latter to complete it). Passing 1.0 to updateInteractiveTransition has the same effect as calling finishInteractiveTransition.

Pan Gesture

One way to make use of the UIPercentDrivenInteractiveTransition API is to hook into a gesture recognizer. UIPanGestureRecognizer makes the most sense here because it allows us to hook into the gesture continuously as the user drags their finger accross the screen. It then becomes easy to update the progress of the transition inside the function that handles the gesture.

Let’s say, for example, that we want the user to be able to scrub from left to right across the screen to peel away to current view controller and arrive at a new view controller. The implementation could look something like this:

class ViewController {
  let interactiveTransition: MyTransition?
  
  override func viewDidLoad() {
      super.viewDidLoad()
    
      interactiveTransition = MyTransition(withFromViewController: self)
      let gestureRecognizer = UIPanGestureRecognizer(target: interactiveTransition, action: "didPan:")
      view.window?.addGestureRecognizer(gestureRecognizer)
  }
}
class MyTransition: UIPercentDrivenInteractiveTransition {
    var toVC: UIViewController?
    var fromVC: UIViewController?
    var container: UIView?

    func didPan(gesture: UIPanGestureRecognizer) {
    switch gesture.state {
        case .Began:
        case .Changed:
        case .Ended:
        default:
            break
        }
}

}

extension MyTransition: UIViewControllerTransitioningDelegate {

    func interactionControllerForPresentation(animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return self
    }

}

extension MyTransition: UIViewControllerAnimatedTransitioning {

    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return self
    }

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {}

    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 2.0
    }

}

We create a custom subclass of UIPercentDrivenInteractiveTransition, and extend it to implement UIViewControllerTransitioningDelegate. We also store a few key properties on our custom class (fromVC would hold the view controller we’re navigating away from, and container would hold our transitioning context’s containing view).

Notice that we’ve implemented 2 protocols: UIViewControllerTransitioningDelegate and UIViewControllerAnimatedTransitioning. These are necessary to establish our MyTransition instance as the object that manages the transition for the view controller it holds a reference to.

We put the bulk of the logic for our transition in animateTransition. There’s a lot going on here, but we’ll unpack each piece individually:

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
    
    guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
        let fromVC = fromVC,
        let container = transitionContext.containerView() else {
            debugPrint("Transition setup failed")
            return
    }

    container.insertSubview(toVC.view, aboveSubview: fromVC.view)

    let screenWidth = CGRectGetWidth(fromVC.view.frame)

    toVC.view.transform = CGAffineTransformMakeTranslation(-screenWidth, 0.0)

    UIView.animateWithDuration(
        transitionDuration(transitionContext),
        delay: 0.0,
        options: [UIViewAnimationOptions.CurveEaseOut],
        animations: {
            toVC.view.transform = CGAffineTransformIdentity
        },
        completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
    })
}

First, we write a guard statement to ensure that our destination view controller (toVC), origin view controller (fromVC), and transition container view (container) are set. Then, with toVC in hand, we add its view to the view hierarchy. We want it to be initially offscreen, so we apply a transform that moves it left by the width of the viewport. The animation itself is simple. All we do is transform toVC’s view back to its original location, and in the animation block’s completion handler, we complete the transition (assuming the transition was not cancelled in the interim).

The logic we put in animateTransition specifies our transition’s flow from a high level, but we’re writing an interactive transition and we need a way to control the progress of the transition dynamically. That’s where our gesture recognizer comes in to play:

func didPan(sender: UIPanGestureRecognizer) {
    switch sender.state {
        case .Began:
            let destinationViewController = ToVCViewController()
            destinationViewController.view.backgroundColor = .greenColor()
            destinationViewController.modalPresentationStyle = .Custom
            destinationViewController.transitioningDelegate = self
            fromVC?.transitioningDelegate = self
            fromVC?.presentViewController(destinationViewController, animated: true, completion: nil)
        case .Changed:

            guard let fromVC = fromVC else {
                debugPrint("FromVC not set")
                return
            }
            let currentTouchLocation = sender.locationInView(container)

            let screenWidth = CGRectGetWidth(fromVC.view.frame)

            let transitionProgress: CGFloat = currentTouchLocation.x / screenWidth

            print(transitionProgress)

            if transitionProgress <= 0.7 {
                self.updateInteractiveTransition(transitionProgress)
            }
            else {
                finishInteractiveTransition()
            }
        case .Ended:
            break
        default:
            break
    }
}

We switch on our UIPanGestureRecognizer’s state, starting the transition in UIGestureRecognizerState.Began and updating it in UIGestureRecognizerState.Changed. Updating the transition’s progress is as simple as calling updateInteractiveTransition: on the presenting view controller’s transitioningDelegate. Here, we check how far along the width of the viewport the user’s touch is, and update the transition accordingly. If the user’s touch is close enough to the edge (we arbitrarily use 0.7 here), we call finishInteractiveTransition, which allows our animation block’s completion handler to be called (which in turn calls completeTransition: on the transition’s UIViewControllerContextTransitioning).

At this point, we have a very basic interactive transition. Dismissing our presented view controller is not covered in this post, but the same general principles apply. Happy coding and ping me on Twitter with questions and feedback!

An example project with code from this post can be found here: https://github.com/mattThousand/InteractiveTransitionExample