PleasantNavigationController

So you’re building your app, and have a navigation controller, and sometimes you need to know when the controller is about to navigate, or has just finished navigating. Well, there’s a great built in API for that, right?

- (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated;
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated;

Very nice indeed. Now, your view controller is about to be shown, and you need to know whether the user got there by navigating back, e.g. by tapping the back button, or by swiping right to exit the current top controller in the stack. Ouch. You don’t know. And UINavigationcontroller won’t tell you. All it will tell you is “when [it] shows a new top view controller via a push, pop or setting of the view controller stack”. You never know the direction of the move. There are hacks to detect a back button tap, or a right-swipe and pass that along. These I find unsatisfactory. It would be better if UINavigationController told its delegate more about its actions. And, as it turns out, it is not difficult to extend UINavigationController to do exactly that.

Enter PleasantNavigationController and its PleasantNavigationControllerDelegate protocol.

We start by defining the API we want to subscribe to. We want to be told when a navigation event is about to happen, and when it has just finished, and we want to know the direction of the move:

@objc protocol PleasantNavigationControllerDelegate: AnyObject {
    @objc optional func willPushViewController(_ viewController: UIViewController, animated: Bool)
    @objc optional func didPushViewController(_ viewController: UIViewController, animated: Bool)

    @objc optional func willPopViewController(animated: Bool)
    @objc optional func didPopViewController(animated: Bool)

    @objc optional func willPopToViewController(_ viewController: UIViewController, animated: Bool)
    @objc optional func didPopToViewController(_ viewController: UIViewController, animated: Bool)

    @objc optional func willPopToRootViewController(animated: Bool)
    @objc optional func didPopToRootViewController(animated: Bool)

    @objc optional func willSetViewControllers(_ viewControllers: [UIViewController], animated: Bool)
    @objc optional func didSetViewControllers(_ viewControllers: [UIViewController], animated: Bool)
}

This provides us with all the relevant events, so that we can react to them as needed. Now we need a version of the navigation controller that implements the protocol:

class PleasantNavigationController: UINavigationController {
	// First, some obligatory overrides
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(rootViewController: UIViewController) {
        super.init(rootViewController: rootViewController)
    }
	
	// Sometimes we need to be able to access the delegate with the more refined type.
    var pleasantDelegate: PleasantNavigationControllerDelegate? {
        return delegate as? PleasantNavigationControllerDelegate
    }

	// And, finally, the functions that implement our protocol
    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        pleasantDelegate?.willPushViewController?(viewController, animated: animated)
        super.pushViewController(viewController, animated: animated)
        pleasantDelegate?.didPushViewController?(viewController, animated: animated)
    }

    override func popViewController(animated: Bool) -> UIViewController? {
        pleasantDelegate?.willPopViewController?(animated: animated)
        defer { pleasantDelegate?.didPopViewController?(animated: animated) }
        return super.popViewController(animated: animated)
    }

    override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
        pleasantDelegate?.willPopToViewController?(viewController, animated: animated)
        defer { pleasantDelegate?.didPopToViewController?(viewController, animated: animated) }
        return super.popToViewController(viewController, animated: animated)
    }

    override func popToRootViewController(animated: Bool) -> [UIViewController]? {
        pleasantDelegate?.willPopToRootViewController?(animated: animated)
        defer { pleasantDelegate?.didPopToRootViewController?(animated: animated) }
        return super.popToRootViewController(animated: animated)
    }

    override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
        pleasantDelegate?.willSetViewControllers?(viewControllers, animated: animated)
        super.setViewControllers(viewControllers, animated: animated)
        pleasantDelegate?.didSetViewControllers?(viewControllers, animated: animated)
    }
}

Note the defer in the functions that return a value. Swift makes it so easy to ensure an action is taken even after returning from a function. Or rather, after executing the call in the return statement, but before returning control to the calling code, the deferred code will be executed, which is just what we need, and allows us to keep our code concise and to the point.

With this in place, you make your observers conform to the PleasantNavigationControllerDelegate protocol, assign them as the navigation controller delegate at the appropriate time, and become master of navigation in your app.

Published on 1 November, 2018