Trials and Tribulations of Making an Interruptable Custom View Controller Transition on iOS

I think it’s safe to say while the iOS custom view controller transition API is a very powerful one, with that power comes a great deal of complexity. It can be tricky, and I’m having one of those days where it’s getting the better of me and I just cannot get it to do what I want it to do, even though what I want it to do seems pretty straightforward. Interruptible/cancellable custom view controller transitions.

What I Want

I built a little library called ChidoriMenu that effectively just reimplements iOS 14’s Pull Down Menus as a custom view controller for added flexibility.

As it always goes, 99% of it went smoothly as could be, but then I was playing around in the Simulator with Apple’s version, and noticed with Apple’s you could tap outside the menu while it was being presented to cancel the presentation and it would smoothly retract. With mine, you have to wait for the animation to finish before dismissing. 0.4 seconds can be a long time. I NEED IT. The fluidity/cancellability of iOS' animations is one of the most fun parts of the operating system, and a big reason the iPhone X’s swipe up to go home feels so nice.

Here is Apple’s with Toggle Slow Animations enabled to better illustrate how you can interrupt/cancel it.

How I Implemented My Menu

Mine’s pretty simple. Just a custom view controller presentation that is non-interactive, using an animation controller and a UIPresentationController subclass. You just tap to summon the menu, and tap away to close it, not really anything interactive, and virtually every tutorial on the web about interactive view controller transitions have “the interaction” being driven by something like UIPanGestureRecognizer, so it didn’t seem really needed in this case. So it’s just an animation controller that animates it on and off screen.

Catch #1

Well, how do I make this interruptable? Say I manually set the animation duration to 10 seconds, and then programatically dismiss it 2 seconds after it starts as a test.

let tappedPoint = tapGestureRecognizer.location(in: view)
        
let chidoriMenu = ChidoriMenu(menu: existingMenu, summonPoint: tappedPoint)
present(chidoriMenu, animated: true, completion: nil)

DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
    chidoriMenu.dismiss(animated: true, completion: nil)
}

No dice. It queues up the dismissal and it occurs at the 10 second mark, right after the animation concludes. Not exactly interrupting anything.

Okay, let’s see. Bruce Nilo and Michael Turner of the UIKit team did a great talk at WWDC 2016 about view controller transitions and making them interruptible.

The animation is powered by UIViewPropertyAnimator, and they mention in iOS 10 they added a method called interruptibleAnimator(using:context:), wherein you return your animator as a means for the transition to be interruptible. They even state the following at the 25:40 point:

If you do not implement the interaction controller, meaning you only implement a custom animation controller, then you need to implement animateTransition. And you would do so very simply, like this method. You take the interruptible animator that you would return and you would basically tell it to start.

Which sounds great, as mine is just a normal, non-interactive animation controller. Let’s do that!

var animatorForCurrentSession: UIViewPropertyAnimator?
    
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
    // Required to use the same animator for life of transition, so don't create multiple times
    if let animatorForCurrentSession = animatorForCurrentSession {
        return animatorForCurrentSession
    }
    
    let propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), dampingRatio: 0.75)
    propertyAnimator.isInterruptible = true
    propertyAnimator.isUserInteractionEnabled = true

    // ... animation set up goes here ...
    
    // Animate! 🪄
    propertyAnimator.addAnimations {
        chidoriMenu.view.transform = finalTransform
        chidoriMenu.view.alpha = finalAlpha
    }
    
    propertyAnimator.addCompletion { (position) in
        guard position == .end else { return }
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        self.animatorForCurrentSession = nil
    }
    
    self.animatorForCurrentSession = propertyAnimator
    return propertyAnimator
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    let interruptableAnimator = interruptibleAnimator(using: transitionContext)
    
    if type == .presentation {
        if let chidoriMenu: ChidoriMenu = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? ChidoriMenu {
            transitionContext.containerView.addSubview(chidoriMenu.view)
        }
    }
    
    interruptableAnimator.startAnimation()
}

However, it still doesn’t interrupt it at the 2 second point, still opting to wait until the 10 second point that the animation completes. It calls the method, but it’s still not interruptible. I tried intercepting the dismiss call and calling .isReversed = true manually on the property animator, but it still waits 10 seconds before the completion handler is called.

After that above quote, they then state “However, we kind of advise that you use an interaction controller if you’re going to make it interruptible.” so I’m going to keep that in mind.

Catch #2

Even if the above did work, it has to be powered by a user tapping outside the menu to close it. This is accomplished in my UIPresentationController subclass by adding a tap gesture recognizer to a background view, which then calls dismiss upon being tapped.

override func presentationWillBegin() {
    super.presentationWillBegin()

    darkOverlayView.backgroundColor = UIColor(white: 0.0, alpha: 0.2)
    presentingViewController.view.tintAdjustmentMode = .dimmed
    containerView.addSubview(darkOverlayView)

    tapGestureRecognizer.addTarget(self, action: #selector(tappedDarkOverlayView(tapGestureRecognizer:)))
    darkOverlayView.addGestureRecognizer(tapGestureRecognizer)
}

@objc private func tappedDarkOverlayView(tapGestureRecognizer: UITapGestureRecognizer) {
    presentedViewController.dismiss(animated: true, completion: nil)
}

Problem is, all taps also refuse to be registered until the animation completes. And it’s not an issue with the UITapGestureRecognizer, adding a simple UIButton results in the same behavior where it becomes tappable as soon as the animation ends.

(Note: when switching to an interactive transition below, UIPresentationController becomes freed up and accepts these touches.)

All Signs Point to Interactive

Between the advice of the UIKit engineers in the WWDC video, and the fact it doesn’t seem interactible during the presentation, let’s just bite the bullet and make it an interactive transition. Plus, the WWDC 2013 video on Custom Transitions Using View Controllers states (paraphrasing) “Interactive transitions don’t need to be powered by gestures only, anything iterable works”.

My issue here is, what is iterating? It’s just a “fire and forget” animation from the tap of a button. Essentially the API works by incrementing a “progress” value throughout the animation so the custom transition is aware of where you’re at in the transition. For instance if you’re swiping back to dismiss, it would be a measurement from 0.0 to 1.0 of how close to the left side of the screen you are. There’s many examples online, Apple included, showing how to implement interactive view controllers powered by a UIPanGestureRecognizer, but I’m really having trouble wrapping my head around what is iterating or driving the progress updates here.

The only thing I could really think of was CADisplayLink (which is basically just an NSTimer synchronized with the refresh rate of the screen — 60 times per second typically) that just tracks how long it’s been since the animation started. If it’s a 10 second animation, and 5 seconds have passed, you’re 50% done! Here’s an implementation, after I changed my animation controller to be a subclass of UIPercentDrivenInteractiveTransition rather than NSObject:

var displayLink: CADisplayLink?
var transitionContext: UIViewControllerContextTransitioning?
var presentationAnimationTimeStart: CFTimeInterval?

override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
    // ...

    self.transitionContext = transitionContext
    self.presentationAnimationTimeStart = CACurrentMediaTime()

    let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkUpdate(displayLink:)))
    self.displayLink = displayLink
    displayLink.add(to: .current, forMode: .common)
}

@objc private func displayLinkUpdate(displayLink: CADisplayLink) {
    let timeSinceAnimationBegan = displayLink.timestamp - presentationAnimationTimeStart
    let progress = CGFloat(timeSinceAnimationBegan / transitionDuration(using: transitionContext))
    self.update(progress) // <-- secret sauce
}

Again, this seems kinda counter intuitive to me. In our case time powers the animation, and we’re trying to shoehorn it into an interactive progress API by measuring time itself. But hey, if it works, it works.

But alas, it doesn’t.

Catch #3

The issue now is that, once the animation starts, it no longer obeys our custom timing curve. Mimicking Apple’s, we want our view controller to present with a subtle little bounce, rather than a boring, linear animation. But using CADisplayLink to power it results in the animation being shown with a linear animation, despite the interruptiblePropertyAnimator we returned looking like this: UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), dampingRatio: 0.75). See that damping? That’s springy! I even tried really spelling it out to the UIPercentDrivenInteractiveTransition with a self.timingCurve = propertyAnimator.timingParameters. No luck still.

But wait, that’s really weird. I use interactive view controller transitions in Apollo to power the custom navigation controller animations, and I distinctly remember it annoyingly following the animation curve during the interactive transition. I specifically had to program around this, because when you’re actually interactive, say following a user’s finger, you need it to be linear so that it follows the finger predictably.

Okay, so I check out Apollo’s code. Ah ha, I wrote it a few years back, so it uses the older school UIView.animate… rather than UIViewPropertyAnimator. Surely that can’t be it.

… It was it.

UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0.0, usingSpringWithDamping: 0.75, initialSpringVelocity: 0, options: [.allowUserInteraction, .beginFromCurrentState]) {
    chidoriMenu.view.transform = finalTransform
    chidoriMenu.view.alpha = finalAlpha
} completion: { (didComplete) in
    if (isPresenting && transitionContext.transitionWasCancelled) || (!isPresenting && !transitionContext.transitionWasCancelled) {
        presentingViewController.view.tintAdjustmentMode = .automatic
    }
    
    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}

It works if I use the old school UIView.animate APIs in startInteractiveTransition and remove the interruptibleAnimator method, and CADisplayLink perfectly follows the animation curve. Okay what gives, implementing interruptibleAnimator was supposed to bridge this gap, there’s even a question on StackOverflow about it but I suppose that question doesn’t say anything about animation curves. So, bug maybe?

End Result

So I guess that kinda works? But this all feels so hacky. I don’t like CADisplayLink much here, it seems to have a few jitters when dismissing as opposed to the first solution (only on device, not Simulator), and it would be nice to know how to use it with the newer UIViewPropertyAnimator APIs. I get a general “fragile” feeling with my code here that I don’t really want to ship, so I reverted back to the initial, non-interactive solution. (Additional minor thing that might not even be possible is that Apple’s also allows you to add another one as the existing one is dismissing, which my code doesn’t do and I didn’t even realize was possible.) And worst of all, you ask? CADisplayLink means “Toggle Show Animations” in the Simulator doesn’t work for the animation anymore!

(Maybe I just need to rebuild Apollo in SwiftUI.)

Here’s some gists showing the two final “solutions”:

A Call for Help

If you know your way around the custom view controller transition APIs and have any insight, you’d be my favorite person on the planet. Making animations more interruptible would be a fun skill to learn, I’m just at wit’s end with trying to implement it. I’ve linked the gists in the previous paragraph, and ChidoriMenu in its entirety with the non-interactive implementation is also on GitHub.

I’m curious if there’s a way to implement it without requiring an interactive transition, but if not, it’d be neat to know if it actually does require CADisplayLink, and if it does, it’d be neat to know what I’m still doing wrong in the above code, haha.

DMs are open on my Twitter, feel free to reach out (alternatively my email is me@ my domain name).