Writing Custom Animations on iOS

We build professional apps for Android, iOS and mobile web.
Have a look

Part I: UIViewController AnimatedTransitioning protocol

One of my very favourite things to do with programming for iOS, is to write sweet and sugary GUI code.

Often, the first thing I'll do when I see a slick animation taking place on Windows Phone 😱 or Android 😱😱 is think, "Okay, Ben.. How would I implement that on iOS?"

left

And so, it was with the release of Windows Phone 7 (2010) that I fell in love with their transitions between View Controllers. I quickly realised, that I needed to teach myself the ins and outs of the Apple-provided CoreAnimation framework if I were to ever realise my goal of "Mastering the Art of Good UI Design" -- And no, I don't mean the Cookbook by Julia Child.

Why did I want to coat my apps with layers of GUI icing? Because back then, often the most complex animations in App Store-apps were the default pushViewController:animated and popViewController:animated: used throughout. More complex animations could be sought from.. ahem other sources, if you did the deed and Jailbroke your iPhone. The tweaks were often awesome and if written right, Apple would even take the idea and put it in the next version of iOS.

Perhaps the most surprising thing about these tweaks, was the fact that they were smooth. I'm talking about a time when the iPhone shipped with 128MB of RAM and I don't even want to think about the paltry amount of Video memory.. 😭

Thus, realising the sheer power of the CoreAnimation framework, even on the earliest versions of the system, I set about teaching myself the ins and outs, and long since mastered the art of custom transitions.

Before iOS 7 you had to write some seriously good code and manage your MVC or MVVM quite well to do this. But in iOS 7 and later, Apple finally realised that iOS apps had to evolve, and made available the (undoubtedly private for years previous) UIViewControllerAnimatedTransitioning and UIPercentDrivenInteractiveTransition protocols for developers to employ in their apps.

Unfortunately however, the documentation behind this is rather sparse. I've filed a few radars about it but that was back when iOS 7 was released, and it still hasn't been resolved.

But how?

Disclaimer: I'll be writing this in Swift 🎉 😉

The first thing you'll want to do is open your Navigation-based Application and make sure you've linked the QuartzCore framework to your app.

Now, create two Swift files. Call the first one CustomPresentation and the other one CustomInteraction. These two files will house all your code for managing the custom UIViewControllerAnimatedTransitioning code. This code will only ever be invoked when you want to pushViewController:animated: or popViewController:animated: or even when you want to use an UIStoryboardSegue.

Open up CustomInteraction first, because this has the least code. Highlight everything that's in there, aside from the top comment, then paste this into it:

import UIKit

class CustomInteraction: UIPercentDrivenInteractiveTransition {

    var navigationController: UINavigationController?
    var shouldCompleteTransition = false
    var transitionInProgress = false

    override init() {
        super.init()

        completionSpeed = 1 - percentComplete
    }

    func attachToViewController(viewController: UIViewController) {
        navigationController = viewController.navigationController
        setupGestureRecognizer(viewController.view)
    }

    private func setupGestureRecognizer(view: UIView) {
        view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: "handlePanGesture:"))
    }

    func handlePanGesture(gestureRecognizer: UIPanGestureRecognizer) {
        guard let gestureSuperview = gestureRecognizer.view?.superview else { return }

        let viewTranslation = gestureRecognizer.translationInView(gestureSuperview)
        let location = gestureRecognizer.locationInView(gestureSuperview)

        switch gestureRecognizer.state {
        case .Began:
            if location.x > PercentageValues.Threshold.rawValue {
                cancelInteractiveTransition()
                return
            }
            transitionInProgress = true
            navigationController?.popViewControllerAnimated(true)

        case .Changed:
            let const = CGFloat(fminf(fmaxf(Float(viewTranslation.x / 200.0), 0.0), 1.0))
            shouldCompleteTransition = const > PercentageValues.Half.rawValue
            updateInteractiveTransition(const)
        case .Cancelled, .Ended:
            transitionInProgress = false
            if !shouldCompleteTransition || gestureRecognizer.state == .Cancelled {
                cancelInteractiveTransition()
            } else {
                finishInteractiveTransition()
            }
        default:
            print("Swift switch must be exhaustive, thus the default")
        }
    }
}

enum PercentageValues: CGFloat {  
    case Threshold = 50.0
    case Half = 0.50
}

I'm going to explain each method there, in depth.

1) attachToViewController: literally and physically attaches your CustomInteraction controller to the ViewController. This is usually your "Root" ViewController, and so you don't ever need to add another one. It lives in the UINavigationController somewhere from there on.

2) setupGestureRecognizer:view: adds the coveted UIPanGestureRecognizer to our custom transition. This means that technically we overwrite the default, Apple-provided Interactive Pan gesture which is used to flick backward without hitting the ever-present iOS back button.

3) handlePanGesture:gestureRecognizer: as you may, or may not have guessed, handles the panning that we initiate with our finger.

In .Began I check to see if the finger is close enough to the left-side of the screen before beginning the gesture.

If you're like me, and often like to hide the default (read: ugly) UINavigationBar and use your own prettier version, I found that even if you started from the middle of the screen it would pick up the gesture, which interrupted other gestures or even scrolling in UITableView or UICollectionView objects. We can't have that, so hence the check. The threshold I put in the code above, is 50pt from the left. Feel free to play with that value, or even make it 0, to have it suit your needs.

.Changed is technically where the magic happens. Here, I update the transition percentage. If I've moved my finger any less than 50% of the way across the screen, the transition gets cancelled, and visually "unwound" depending on how quick you have your animation. If it lasts a couple of seconds, you'll see it reverse. If it's shorter, it often seems like it'll snap back. 👍

.Ended || .Cancelled checks to see whether, at the final moment, the transition should proceed, or unwind.

The enum provides the custom values you can play with. Leave everything else alone. :)


Now open up your CustomPresentation class. Highlight everything except the comment, and paste this in:

import UIKit  
import Foundation

class CustomPresentation: NSObject, UIViewControllerAnimatedTransitioning {

    private let scale = UIScreen.mainScreen().scale
    private let identity = CATransform3DIdentity

    private var distance: CGFloat {
        return ZPositions.Distance.rawValue
    }

    private var spatial: CGFloat {
        return ZPositions.Spatial.rawValue
    }

    var reverse: Bool = false

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

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        let containerView = transitionContext.containerView()
        let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!
        let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!
        let toView = toViewController.view
        let fromView = fromViewController.view

        // 1
        toView.layer.transform = addDepthDownToAnimation()

        // 2
        toView.alpha = 0.0
        rasterize(withLayer: toView.layer)

        // 3
        containerView?.addSubview(toView)
        containerView?.addSubview(fromView)
        containerView?.sendSubviewToBack(reverse == true ? fromView : toView)

        // 4
        fromView.layer.zPosition = reverse ? -spatial : spatial
        toView.layer.zPosition = reverse ? spatial : -spatial

        // 5
        UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0.0, options: .CurveEaseOut, animations: { [weak self] in
            guard let weakSelf = self else { return }

            // 5.a
            fromView.layer.transform = weakSelf.addDepthDownFromAnimation()

            // 5.b
            fromView.alpha = 0.0
            weakSelf.rasterize(withLayer: fromView.layer)

            // 5.c
            toView.layer.transform = CATransform3DIdentity
            toView.alpha = 1.0

            }, completion: { finished in
                // 5.d
                if transitionContext.transitionWasCancelled() {
                    toView.removeFromSuperview()
                    toView.layer.removeAllAnimations()
                } else {
                    fromView.removeFromSuperview()
                    fromView.layer.removeAllAnimations()
                }

                // 5.e
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
        })
    }

    // 6
    func rasterize(withLayer layer: CALayer) {
        layer.contentsScale = scale
        layer.shouldRasterize = true
        layer.rasterizationScale = scale
    }

    // 7
    func addDepthDownToAnimation() -> CATransform3D {
        let toViewZ: CGFloat = reverse ? distance : -distance

        var rotationAndPerspectiveTransform: CATransform3D = CATransform3DIdentity
        rotationAndPerspectiveTransform.m34 = 1.0 / -500.0
        rotationAndPerspectiveTransform = CATransform3DTranslate(rotationAndPerspectiveTransform, 0.0, 0.0, toViewZ)

        return rotationAndPerspectiveTransform
    }

    func addDepthDownFromAnimation() -> CATransform3D {
        let fromViewZ: CGFloat = reverse ? -distance : distance

        var rotationAndPerspectiveTransform: CATransform3D = CATransform3DIdentity
        rotationAndPerspectiveTransform.m34 = 1.0 / -500.0
        rotationAndPerspectiveTransform = CATransform3DTranslate(rotationAndPerspectiveTransform, 0.0, 0.0, fromViewZ)

        return rotationAndPerspectiveTransform
    }

}

// 8
enum ZPositions: CGFloat {  
    case Spatial = 300
    case Distance = 150
}

There's the code. If you're still reading this, then you want to learn! 🎉

1) addDepthDownToAnimation() is the function that adds the animation to the toView

2) I initially hide the destination, and rasterise the layer.

3) I add both toView and fromView to the containerView and send one of them to the back depending on reverse being true or false.

4) Also depending on reverse's value, I set the .zPosition of the views. This will force a spatial positioning of the views to render without intersecting.

5) The animation.
a: Add the animation to the fromView object.
b: Rasterise the fromView and animate its alpha to 0.0 over the course of the animation.
c: Restore the initial position of the toView object by using CATransform3DIdentity which equates to real-world terms of "Restore my initial position and remove the animation."
d: Foo to see if I should cancel() or finish() the animation depending on whether the transition was cancelled or not. -- This is where Interaction ties in
e: complete() the transition if it wasn't previously cancelled.

6) Our nifty rasterize:layer function, which saves writing out those three lines every time I want to do this.

7) The depthDown animations. This is where the magic happens. Notice here that I'm not scaling the views. Rather, I'm translating the view across the Z-axis. The key bit of magic here is the .m34 property, which adds perspective-depth to the animation. Without this, the animation would happen without you seeing anything.

8) The enum that I use to avoid magic numbers. 😈


Integration

Well this is the easier part.

Open up your MasterViewController file. You'll have this if you never bothered to rename it to something more explanatory depending on your Use Case, or if nobody else ever will look at the code.

Make sure that this file conforms to UINavigationControllerDelegate

And paste these two lines in at the top where all your variables should be:

let presenting = CustomPresentation()  
let interaction = CustomInteraction()  

You'll also want to add the requisite UINavigationControllerDelegate methods to satisfy Xcode's complaint that the current file doesn't conform to the protocol/delegate you just declared. So let's do that now:

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

    if operation == .Push {
        interaction.attachToViewController(toVC)
    }

    presenting.reverse = operation == .Pop
    return presenting
}

func navigationController(navigationController: UINavigationController, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {  
    return interaction.transitionInProgress ? interaction : nil
}

Make sure that in your viewDidLoad: method, you have navigationController?.delegate = self so the methods you just added, actually get called.

Those methods, in your Root View Controller will attach to your global UINavigationController instance and whenever you push or pop a View Controller in your app, your custom animation will play! 🎉

Give it a try 😉

Want more? Part II: More Complex CoreAnimation

Download The Source Code