1 Aug 20188 min read

Simple, custom navigation transitions

Recently I have started working on a new personal project where I had a static background for the entire application and all my view controllers’ views had a transparent background.

Default transition animation overlaps a little bit the source and destination views, therefore, there is only one way to make our application navigate with some dignity and smoothness — that’s right — it is custom animated transitioning and since we usually want to keep swipe to back gesture on view controllers we also have to create custom interactive transitioning as well.

Sample Project

I have created a sample project — you can find it on GitHub.

Transition Animation

Let’s Get Theoretical

The first thing we have to do is implement custom transition for our UINavigationController and implement its delegate method

func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {}

which provides an object that is responsible for providing information on how our custom transition will animate source and destination views while navigating between them.

To add user interaction to a view controller transition we have to implement another method (also will be needed to handle back gesture properly) is

func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {}

Both methods are depending on each other while creating custom, interactive transition and are available since iOS 7.0 so pretty much all new or updated applications can get benefits from them.

Let’s get Practical!

First of all, we have to create a new object that will be implementing UIViewControllerAnimatedTransitioning protocol.

final class TransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {

    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    }
}

Since mentioned protocol conforms to NSObjectProtocol our animator object have to conform to it as well and the easier way to achieve that is to simply inherit from NSObject. UIViewControllerAnimatedTransitioning requires two methods which you can see above as well as there are two optional methods which we will not need in our project today so let’s not get interrupted by them.

Our custom animator object needs to know if the transition is invoked by a push (being presented) or a pop (being dismissed) action of navigation controller so we will add Bool property which will store that information. Since we will create a quite simple custom animation we will not need any additional properties — if you want to create more complex animation you should store here everything that will be needed for your animation (ie. you could store here frame of a tapped button so you can animate present another controller from that point).

As an example, I have created a simple “fade and slide/move” animation. I will explain each part of my TransitionAnimator below.

final class TransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
    // 1
    let presenting: Bool

    // 2
    init(presenting: Bool) {
        self.presenting = presenting
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        // 3
        return TimeInterval(UINavigationControllerHideShowBarDuration)
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // 4
        guard let fromView = transitionContext.view(forKey: .from) else { return }
        guard let toView = transitionContext.view(forKey: .to) else { return }

        // 5
        let duration = transitionDuration(using: transitionContext)

        // 6
        let container = transitionContext.containerView
        if presenting {
            container.addSubview(toView)
        } else {
            container.insertSubview(toView, belowSubview: fromView)
        }

        // 7
        let toViewFrame = toView.frame
        toView.frame = CGRect(x: presenting ? toView.frame.width : -toView.frame.width, y: toView.frame.origin.y, width: toView.frame.width, height: toView.frame.height)

        let animations = {
            UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.5) {
                toView.alpha = 1
                if self.presenting {
                    fromView.alpha = 0
                }
            }

            UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 1) {
                toView.frame = toViewFrame
                fromView.frame = CGRect(x: self.presenting ? -fromView.frame.width : fromView.frame.width, y: fromView.frame.origin.y, width: fromView.frame.width, height: fromView.frame.height)
                if !self.presenting {
                    fromView.alpha = 0
                }
            }

        }

        UIView.animateKeyframes(withDuration: duration,
                                delay: 0,
                                options: .calculationModeCubic,
                                animations: animations,
                                completion: { finished in
                                    // 8
                                    container.addSubview(toView)
                                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}
  1. Property that indicates if the animator is being responsible for a push or a pop navigation.
  2. It’s an initializer.
  3. You can set any custom duration as you wish. I have used constant from UINavigationController to fit other (hiding/showing bar) animations in the application.
  4. We need a controller’s views that will be animated and we can access them through UIViewControllerContextTransitioning’s method view(forKey: ). If needed there is also an option to access the whole view controller, just use viewController(forKey: ) instead.
  5. Since we already defined our duration in another method simply we access it here.
  6. We access containerView here which acts as the superview for the views involved in the transition. Depending if we present or dismiss view controller we either add destination view to our container or add it below source (fromView) view.
  7. Here we set an initial frame for our destination view which in my sample it is being a little bit to the left or right of source view — that will allow us to create smooth slide/move animation. Also, it is the place where “magic” happens — create any animation you want here. I have created fade animation for first half part of the whole animation and slide/move animation for the whole duration of it.
  8. In case of transition being canceled we have to clean up everything that we have done during the process — I simply remove added destination view from the container.

Wow, that was pretty easy, wasn’t it? 😎 Now it is time to attach our custom transition to our navigation controller — how to do that? Well, there is few possible ways to go but we want to stay Swifty! To achieve that let’s choose a little bit harder (is it?) path and instead of inheriting from either UINavigationController or UIViewController lets create TransitionCoordinator (not sure if the name fits here but that is how I called it for now, if you have any suggestions, leave it in comments below). Our coordinator should conform to UINavigationControllerDelegate protocol (Hello NSObject again).

final class TransitionCoordinator: NSObject, UINavigationControllerDelegate {
    // 1
    var interactionController: UIPercentDrivenInteractiveTransition?

    // 2
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        switch operation {
        case .push:
            return TransitionAnimator(presenting: true)
        case .pop:
            return TransitionAnimator(presenting: false)
        default:
            return nil
        }
    }

    // 3
    func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactionController
    }
}
  1. Need that to handle back-gesture properly — it will be set by gesture recognizer but we will get back to it later.
  2. You probably remember theory I mentioned earlier to won’t get into details — we return here our custom animator depending on operation being either pop or push.
  3. Returns interactionController to handle interactive transitioning.

Finally, the fun begins! As I mentioned we do not want to inherit from anything and we want to be able to easily attach our custom transition to any UINavigationController we want, to do that let’s create an extension.

extension UINavigationController {
    // 1
    static private var coordinatorHelperKey = "UINavigationController.TransitionCoordinatorHelper"

    // 2
    var transitionCoordinatorHelper: TransitionCoordinator? {
        return objc_getAssociatedObject(self, &UINavigationController.coordinatorHelperKey) as? TransitionCoordinator
    }

    func addCustomTransitioning() {
        // 3
        var object = objc_getAssociatedObject(self, &UINavigationController.coordinatorHelperKey)

        guard object == nil else {
            return
        }

        object = TransitionCoordinator()
        let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
        objc_setAssociatedObject(self, &UINavigationController.coordinatorHelperKey, object, nonatomic)

        // 4
        delegate = object as? TransitionCoordinator


        // 5
        let edgeSwipeGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
        edgeSwipeGestureRecognizer.edges = .left
        view.addGestureRecognizer(edgeSwipeGestureRecognizer)
    }

    // 6
    @objc func handleSwipe(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
        guard let gestureRecognizerView = gestureRecognizer.view else {
            transitionCoordinatorHelper?.interactionController = nil
            return
        }

        let percent = gestureRecognizer.translation(in: gestureRecognizerView).x / gestureRecognizerView.bounds.size.width

        if gestureRecognizer.state == .began {
            transitionCoordinatorHelper?.interactionController = UIPercentDrivenInteractiveTransition()
            popViewController(animated: true)
        } else if gestureRecognizer.state == .changed {
            transitionCoordinatorHelper?.interactionController?.update(percent)
        } else if gestureRecognizer.state == .ended {
            if percent > 0.5 && gestureRecognizer.state != .cancelled {
                transitionCoordinatorHelper?.interactionController?.finish()
            } else {
                transitionCoordinatorHelper?.interactionController?.cancel()
            }
            transitionCoordinatorHelper?.interactionController = nil
        }
    }
}
  1. Just a static key which will be used to associate anobject (read about that ie. here).
  2. A computed property that will return our associated TransitionCoordinator object.
  3. Create an instance of TransitionCoordinator and associate it with the mentioned key.
  4. Set associated object as a delegate of UINavigationController.
  5. Let’s create an instance of UIScreenEdgePanGestureRecognizer which will react to the left edge only and add it to the navigation controller’s view.
  6. Here we handle our edge-swipe gesture. There are few cases — once we begin our gesture we create an instance of UIPercentDrivenInteractiveTransition and start popping our controller. On finger move, we update the progress of back gesture and finally, on gesture’s end we either finish the transition (if we moved controller by at least a half-width and gesture was not canceled in meantime) or cancel it — in both cases, we clear the interactionController.

And…

That is all.

We are done with the hard work. Now to make the magic happen let’s just call our extension’s method on any navigation controller we want (navigationController.addCustomTransitioning()).