Custom Navigation Transitions

Profile photo of Rihards Baumanis
Rihards Baumanis
Apr 16, 2018
6 min
Categories: Development
Compass in hand against the background of trees and mountains
Compass in hand against the background of trees and mountains

So, it's a pretty cool thing to do a custom navigation transitions between your controllers. I decided to play around with it and the end result turned out to be quite good looking. Why not share with others.

This is the default transition that everyone using iOS is definitely used to.

https://miro.medium.com/max/868/1*-wOODZAh2dGgKj_O_XdPEA.gif

My idea was to create something a little different. I wanted the transition to go side-by-side where one controller pushes the other out of the way and vice-versa. And to top it off have a global background for all ViewControllers to make them transition from one to another without seeing the controller bounds change.

So to make this scenario we have the following steps to take:

  • Make a custom navigation controller, which will overwatch all of the interactions;
  • Make a custom animator, which handles the controller frame animations;
  • Make a custom interactor, which handles screen panning to allow interactive 'pop' transition;
  • Make a custom interactive background;

We start off nice and simple — create a new single view project. Create one UINavigationController subclass and 3 UIViewController subclasses. All of them won't be needed, but they help to make a point. Sometimes.

Step 1:

The Main.storyboard file is set up the following way:

https://miro.medium.com/max/1400/1*5vATBNainCRoNw4MRfpmbw.png

UINavigationController, which is set to subclass to our custom navigation class (BaseNC), it has a RootViewController relationship to VC1. VC1 then has a push segue tied from the 'Push' button to VC2. Same for VC2 to VC3. And VC3, in the end, has a custom action tied to its controller class, where it performs a popToRootViewController call. Nothing special here, just setup.

Setup, done, moving over to BaseNC class to start the setup.

extension UINavigationController { func makeNavigationBarTransparent() { self.navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) self.navigationBar.shadowImage = UIImage() self.navigationBar.isTranslucent = true } func resetNavigationBar() { self.navigationBar.setBackgroundImage(nil, for: UIBarMetrics.default) self.navigationBar.shadowImage = nil self.navigationBar.isTranslucent = false } } class BaseNC: UINavigationController { enum NavigationTransitionStyle { case sideBySide } private var transitionStyle: NavigationTransitionStyle? override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) } // init(rootViewController: UIViewController, transitionStyle: NavigationTransitionStyle? = nil) { // super.init(rootViewController: rootViewController) // self.transitionStyle = transitionStyle // } required init?(coder aDecoder: NSCoder) { transitionStyle = .sideBySide super.init(coder: aDecoder) } override func viewDidLoad() { super.viewDidLoad() delegate = self self.makeNavigationBarTransparent() } }

This is the initial setup for our main UINavigationController. Since we're setting our superclass from the storyboard, we are going to be using the decoder initialiser and setting the transition style manually. The commented initialiser will allow us to use this navigation controller for different initialisation types if need be.

The enclosed enum NavigationTransitionStyle will be used to separate transition styles for different needs. In this tutorial, we'll be using only one style, but the idea for reusability remains.

In ViewDidLoad we also add the delegate to self and use an UINavigationController extension to make the navigation bar transparent. That is definitely not necessary but makes the whole look of the transitions much prettier.

Since the code right now doesn't compile due to delegacy, let's make it compliant with the delegate protocol.

extension BaseNC: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return nil } func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { } func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { switch operation { case .push: return pushOperationTransitionAnimator(for: toVC) default: return defaultAnimator(for: fromVC) } } private func pushOperationTransitionAnimator(for vc: UIViewController) -> UIViewControllerAnimatedTransitioning? { guard let style = transitionStyle else { return nil } switch style { case .sideBySide: return SideBySidePushTransitionAnimator(direction: .left, navigationControl: self) } } private func defaultAnimator(for vc: UIViewController) -> UIViewControllerAnimatedTransitioning? { guard let style = transitionStyle else { return nil } switch style { case .sideBySide: return SideBySidePushTransitionAnimator(direction: .right, navigationControl: self) } } }

This is how the navigation controller looks when adding the compliance to the UINavigationControllerDelegate protocol and adding a custom animator. The animator will ensure that the controllers when pushed and popped move according to our commands.

Step 2: Next to the animator itself.

class SideBySidePushTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { enum PushTransitionDirection { case left case right } private let nav: BaseNC private var duration = 0.4 private let direction: PushTransitionDirection init(direction: PushTransitionDirection, navigationControl: BaseNC) { self.direction = direction self.nav = navigationControl } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromView = transitionContext.view(forKey: .from) else { return } guard let toView = transitionContext.view(forKey: .to) else { return } let width = fromView.frame.size.width let centerFrame = CGRect(x: 0, y: 0, width: width, height: fromView.frame.height) let completeLeftFrame = CGRect(x: -width, y: 0, width: width, height: fromView.frame.height) let completeRightFrame = CGRect(x: width, y: 0, width: width, height: fromView.frame.height) switch direction { case .left: transitionContext.containerView.addSubview(toView) toView.frame = completeRightFrame case .right: transitionContext.containerView.insertSubview(toView, belowSubview: fromView) toView.frame = completeLeftFrame } toView.layoutIfNeeded() let animations: (() -> Void) = { [weak self] in guard let direction = self?.direction else { return } switch direction { case .left: fromView.frame = completeLeftFrame case .right: fromView.frame = completeRightFrame } toView.frame = centerFrame } let completion: ((Bool) -> Void) = { _ in transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } if transitionContext.isInteractive && direction == .right { regular(animations, duration: 0.5, completion: completion) } else { spring(animations, duration: duration, completion: completion) } } private func spring(_ animations: @escaping (() -> Void), duration: TimeInterval, completion: ((Bool) -> Void)?) { UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.1, options: .allowUserInteraction, animations: animations, completion: completion) } private func regular(_ animations: @escaping (() -> Void), duration: TimeInterval, completion: ((Bool) -> Void)?) { UIView.animate(withDuration: duration, animations: animations, completion: completion) } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return duration } }

This is how the SideBySide animator looks in its simple glory. We have an initialiser, which takes the navigation controller and direction as parameters. After that, it's all about setting the incoming and outgoing ViewController frames.

One place that is can maybe be confusing is the different animation calls.

If the animation is performed automatically (back button pressed or a present/pop function called), the user has no control over the transition. But when the left side panning is performed, the TransitionContext is interactive and it looks a bit smoother when the completion animation (panning and releasing) is performed differently from regular push/pop animation. But that's just my opinion, it's all up to interpretation.

In any case, we get the 'from' and 'to' views, insert them and perform the frame animations manually. In this case, the pushed controller is added on the far right side of the screen and then animated in, while the current controller is animated out. The same for popping, but the other way around. That's all straight forward.

This is how the SideBySide animator looks in its simple glory. We have an initialiser, which takes the navigation controller and direction as parameters. After that, it's all about setting the incoming and outgoing ViewController frames. One place that is can maybe be confusing is the different animation calls. If the animation is performed automatically (back button pressed or a present/pop function called), the user has no control over the transition. But when the left side panning is performed, the TransitionContext is interactive and it looks a bit smoother when the completion animation (panning and releasing) is performed differently from regular push/pop animation. But that's just my opinion, it's all up to interpretation.

In any case, we get the 'from' and 'to' views, insert them and perform the frame animations manually. In this case, the pushed controller is added on the far right side of the screen and then animated in, while the current controller is animated out. The same for popping, but the other way around. That's all straight forward.

Step 3 — Interactor:

Implementing this allows the navigation transitions to perform side-by-side, but there is no interactivity since we're overriding it in our NavigationController. This is the time to add our SideBySideInteractor.

protocol NavigationInteractionProxy { var isPerforming: Bool { get } } class SideBySidePushInteractor: UIPercentDrivenInteractiveTransition, NavigationInteractionProxy { private weak var navigationController: UINavigationController? private let transitionCompletionThreshold: CGFloat = 0.5 var completion: (() -> Void)? var isPerforming: Bool = false init?(attachTo viewController: UIViewController) { guard let nav = viewController.navigationController else { return nil } self.navigationController = nav super.init() setupBackGesture(view: viewController.view) } private func setupBackGesture(view: UIView) { let swipeBackGesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleBackGesture(_:))) swipeBackGesture.edges = .left view.addGestureRecognizer(swipeBackGesture) } override func finish() { super.finish() completion?() } @objc private func handleBackGesture(_ gesture: UIScreenEdgePanGestureRecognizer) { guard let nav = navigationController else { return } let viewTranslation = gesture.translation(in: gesture.view?.superview) let transitionProgress = viewTranslation.x / nav.view.frame.width switch gesture.state { case .began: isPerforming = true nav.popViewController(animated: true) case .changed: update(transitionProgress) case .cancelled: isPerforming = false cancel() case .ended: if gesture.velocity(in: gesture.view).x > 300 { finish() return } isPerforming = false transitionProgress > transitionCompletionThreshold ? finish() : cancel() default: return } } }

This is the interactor that allows us to perform panning action on the pushed controller and keep the action interactive. Identical to iOS default panning interaction, but we still keep our custom transition active.

For this, to work, we need to have a protocol — NavigationInteractionProxy. This has only one variable — isPerforming. This will communicate with our navigation controller about the actions performed, so we can keep our interaction.

The class initialiser takes 2 arguments — the navigation controller (which is set to weak to avoid leaks) and the animatable controller (to which we add the pan gesture). We also have a completion, but we'll get to that later.

In the gesture handling function, we get the progress of the pan action — this tells us how far we are with the transition.

When we start the transition, we have to pop the ViewController to allow it to start animating. If we release it before the center point of the screen, the transition will be cancelled and the controller returned to it's original position. If after center point, then the pop transition will be completed.

Popping controllers has an interesting feature, that if we start the pan gesture with enough speed (velocity), then we straight up pop the controller to improve user interaction. This is handled in '.ended' case of the gesture state.

Now let's update our navigation controller to add the interactor.

class BaseNC: UINavigationController { enum NavigationTransitionStyle { case sideBySide } fileprivate var interactors: [NavigationInteractionProxy?] = [] private var transitionStyle: NavigationTransitionStyle? override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) } // init(rootViewController: UIViewController, transitionStyle: NavigationTransitionStyle? = nil) { // super.init(rootViewController: rootViewController) // self.transitionStyle = transitionStyle // } required init?(coder aDecoder: NSCoder) { transitionStyle = .sideBySide super.init(coder: aDecoder) self.addUniversalBackground() } override func viewDidLoad() { super.viewDidLoad() delegate = self self.makeNavigationBarTransparent() } } extension BaseNC: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { let interactor = interactors.last return interactor??.isPerforming == true ? (interactor as? UIViewControllerInteractiveTransitioning) : nil } func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { if viewController == navigationController.viewControllers.first { interactors.removeAll() } } func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { switch operation { case .push: initializeInteractorFor(toVC) return pushOperationTransitionAnimator(for: toVC) default: return defaultAnimator(for: fromVC) } } func initializeInteractorFor(_ vc: UIViewController) { guard let style = transitionStyle else { return } switch style { case .sideBySide: addSideBySideInteractorFor(vc) } } private func addSideBySideInteractorFor(_ vc: UIViewController) { let interactor = SideBySidePushInteractor(attachTo: vc) interactor?.completion = { [weak self] in self?.interactors.removeLast() } interactors.append(interactor) } private func pushOperationTransitionAnimator(for vc: UIViewController) -> UIViewControllerAnimatedTransitioning? { guard let style = transitionStyle else { return nil } switch style { case .sideBySide: return SideBySidePushTransitionAnimator(direction: .left, navigationControl: self) } } private func defaultAnimator(for vc: UIViewController) -> UIViewControllerAnimatedTransitioning? { guard let style = transitionStyle else { return nil } switch style { case .sideBySide: return SideBySidePushTransitionAnimator(direction: .right, navigationControl: self) } } }

This is how our navigation controller looks with added interactor handling.

The idea is that now we have a private array —

fileprivate var interactors: [NavigationInteractionProxy?] = []

This is required to keep a reference on all the interactors created in the navigation stack. For every pushed controller we have an interactor.

So, why do we need the protocol — that is to tell the transition animation, that we currently are in the process of panning and we should use the interactor. Otherwise, the animation will just complete without user interaction.

One thing that has to be said is that when we're moving to rootViewController, we should get rid of all interactors. This case appears when we have at least 2 controllers on the stack and then we pop to root. The interactors.removeAll() fixes that problem.

This all could have been written much shorter, but the idea was to make it easily adaptable for new interactors and animators. So far I've had no problems introducing new animations in this class.

By adding this we should be able to perform the user interactive panning pop transition.

Step 4: Interactive background

To achieve that we do the following:

  1. Add a UIScrollView property to the navigation controller class;
  2. Add a baseOffset property in the BaseNC class — private var backgroundStartOffset: CGFloat = 0
extension BaseNC { func addUniversalBackground() { let scrollView = UIScrollView(frame: view.frame) let img = #imageLiteral(resourceName: "main_bg") let imgView = UIImageView(image: img.resizedImageWithinRect(rectSize: CGSize(width: img.size.width, height: UIScreen.main.bounds.height + 200))) imgView.contentMode = .center scrollView.addSubview(imgView) scrollView.isUserInteractionEnabled = false let offset = 0.2 * (imgView.image?.size.width ?? 0) backgroundStartOffset = offset scrollView.setContentOffset(CGPoint(x: offset, y: 0), animated: false) self.view.insertSubview(scrollView, at: 0) scrollableBackground = scrollView } }

The starting offset will help to reset the image to its original position on rootViewController.

  1. Get a large image that can handle at least some scrolling left and right;
  2. In BaseNC initialiser call the addUniversalBackground() function;

The image resizing functions will be available in the final project, but they don't do anything special other than making the image scale properly for the screen.

Now we have the background, but we need the interaction, so:

  1. In storyboard for each ViewController, we set the view background color to clear.
  2. Add the following code to our BaseNC class:
var bgMovementValue: CGFloat { return 100 } func increaseBackgroundOffset() { guard var offset = scrollableBackground?.contentOffset else { return } offset.x += bgMovementValue scrollableBackground?.contentOffset = offset } func decreaseBackgroundOffset() { guard var offset = scrollableBackground?.contentOffset else { return } offset.x -= bgMovementValue scrollableBackground?.contentOffset = offset } func resetBackgroundOffset() { guard var offset = scrollableBackground?.contentOffset else { return } offset.x = backgroundStartOffset scrollableBackground?.contentOffset = offset }

These are the functions that will move our background together with transitioning controllers.

3. Change the animation block in our TransitionAnimator to:

let animations: (() -> Void) = { [weak self] in guard let direction = self?.direction else { return } switch direction { case .left: self?.nav.increaseBackgroundOffset() fromView.frame = completeLeftFrame case .right: if transitionContext.viewController(forKey: .to) == self?.nav.viewControllers.first { self?.nav.resetBackgroundOffset() } else { self?.nav.decreaseBackgroundOffset() } fromView.frame = completeRightFrame } toView.frame = centerFrame }

And that's all there is to it.

If everything is set up correctly and I haven't forgotten anything, the interaction should be somewhat similar to this. But I probably have, so the project link is available at the end to see the end product.

https://miro.medium.com/max/852/1*Hx5sSiOfMHjGZ_tQtHJPCg.gif

The durations, interactions and handles can all be modified and customised to fit anyone's needs.

Feel free to check out the project here.

Thanks for reading & happy coding.

—R

https://miro.medium.com/max/300/1*hB1OofoTpqI6wm43oKjH8A.png

Receive a new Mobile development related stories first. — Hit that follow button

Twitter: @ChiliLabs

www.chililabs.io

Share with friends

Let’s build products together!

Contact us