Visualizing navigation flow progress

Users are impatient. That’s a harsh truth. It’s easy to lose their attention and we, as app creators, should do everything to prevent that. One case when user can get easily tired of our app are multi-screen flows, e.g. account registration, onboarding or finalizing order in e-commerce applications. Users don’t know how many steps are left - how much more involvement is needed from them to finish.

Such flows are often implemented with UINavigationController. In this article, I’ll show how to implement sleek, interactive navigation controller progress view which will display users how far in the flow they are. This is what we’ll get after we finish:

Displaying progress bar

We’ll start with creating subclass of UINavigationController. Let’s call it ProgressNavigationController.

final class ProgressNavigationController: UINavigationController {
    private let progressView = UIProgressView(progressViewStyle: .bar)

    override func viewDidLoad() {
        super.viewDidLoad()

        setupProgressView()
    }
    
    private func setupProgressView() {
        view.addSubview(progressView)
        progressView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            progressView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor),
            progressView.heightAnchor.constraint(equalToConstant: 2.0)
        ])
    }
}

For now, it contains only a UIProgressView just below navigationBar.

We can assign any value between 0.0 and 1.0 to UIProgressView’s progress property and it’ll display it. We’ll shortly make use of it to display our navigation flow progress. To do that, we need a way of getting progress value from view controllers on our navigation controller’s navigation stack. Let’s introduce simple protocol:

protocol FlowProgressReporting {
    var flowProgress: Float { get }
}

This protocol has only one property – flowProgress. Each view controller which wants to display progress value should adopt this protocol.

Now, let’s define desired behavior for ProgressNavigationController. When topViewController of our ProgressNavigationController conforms to FlowProgressReporting, we’ll display it’s flowProgress in progressView. If currently displayed top view controller does not conform to our protocol, we’ll hide progressView. When we either push or pop from navigation stack, progressView should animate accordingly, including case when it should get hidden. Also, the most tricky part, animation should be interactive while performing swipe-to-pop gesture.

We now know how it should behave, so it’s time to turn it into code!

First step is to correctly detect pushes and pops in our navigation controller. Thankfully, there is UINavigationControllerDelegate and its methods:

  • func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool)
  • func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool)

First one is called when push/pop begins. Second one is called after push/pop finishes. willShow will be a place where all magic happens in our ProgressNavigationController ;).

So, let’s make ProgressNavigationController it’s own delegate by updating viewDidLoad method to:

override func viewDidLoad() {
    super.viewDidLoad()

    delegate = self

    setupProgressView()
}

This, obviously, gives us compiler error as we’re not yet conforming to UINavigationControllerDelegate protocol.

Putting it all together

It’s about time to reveal the spells behind the magic of willShow method and implement it:

extension ProgressNavigationController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        let flowProgress = (viewController as? FlowProgressReporting)?.flowProgress
        let progressViewTargetAlpha: CGFloat = flowProgress == nil ? 0.0 : 1.0
        
        let actions = { [progressView] in
            progressView.alpha = progressViewTargetAlpha
            
            if let flowProgress = flowProgress {
                progressView.setProgress(flowProgress, animated: animated)
            }
        }
        
        guard animated else {
            actions()
            
            return
        }
        
        transitionCoordinator?.animate(
            alongsideTransition: { _ in actions() },
            completion: { [progressView] context in
                guard context.isCancelled else { return }
                
                let previousFlowProgress = (topViewController as? FlowProgressReporting)?.flowProgress
                let previousProgressViewTargetAlpha: CGFloat = previousFlowProgress == nil ? 0.0 : 1.0
                
                UIView.animate(withDuration: context.transitionDuration) {
                    progressView.alpha = previousProgressViewTargetAlpha
                }
                
                if let previousFlowProgress = previousFlowProgress {
                    progressView.setProgress(previousFlowProgress, animated: true)
                }
            }
        )
    }
}

It’s a bit lengthy, but we’ll get through it ;).

We start with reading flow progress value from view controller which will be shown. It might be nil if view controller does not conform to FlowProgressReporting. Then, we decide if progressView should be visible by making progressViewTargetAlpha either 1.0 or 0.0. If new view controller is FlowProgressReporting, then we want to show, or keep showing, progress view. Otherwise we want to hide it or keep it hidden.

In next step we define actions - closure which sets our calculated alpha and progress in progressView. Main reason for keeping those in closure is that we need to use it in one of two places depending on fact if view controller will be shown with animation or not.

We handle not animated transition in else clause of guard animated by just calling actions() and returning from method.

Transition with animation

The most interesting part is of course handling animated transition. When transition happens, we can access transitionCoordinator property of our navigation controller. It gives us ability to hook into transition, add some animations to it and/or detect its end. That’s everything we need to properly handle our progress view animation! animate(alongsideTransition:completion:) is very powerful method. In alongsideTransition parameter we provide a closure which, as name states, will be animated with our transition. What’s most interesting, it also works with interactive transitions! When making swipe-to-pop gesture in navigation controller, progress and alpha will be changing interactively. It looks really good!

Speaking of interactive transitions, they have one ‘annoying’ quality - they can be cancelled. We need to accommodate to that fact. We do that in completion closure. First, we check if transition got cancelled by checking isCancelled property of transition context. Next, we access topViewController and read its flow progress and alpha (same as in actions closure). Then we animate them to return to previous state of progressView.

Correcting Apple

We are done! Almost. Unfortunately there is an unfixed bug in iOS which causes first call to animate(alongsideTransition:completion:) to not animate (see rdar://38135706). It can be seen in following gif:

We can fix that by detecting first animated transition and animating progressView with regular UIView.animate. First, we need to add flag property to ProgressNavigationController which will tell us if current transition is one of consecutive ones (if true) or first one (if false):

private var isConsecutiveAnimatedTransition = false

We then need to check this in willShow method, just after guard animated and before call to animate(alongsideTransition:completion:)

...

guard isConsecutiveAnimatedTransition else {
    UIView.animate(
        withDuration: transitionCoordinator?.transitionDuration ?? 0.0, 
        animations: actions
    )
    isConsecutiveAnimatedTransition = true
            
    return
}

...

In else clause we animate actions closure described before with duration taken from transition coordinator. After that we set flag to true and return from method.

And that’s all! Finished project can be found here. Thanks for reading and stay tuned for more articles!

comments powered by Disqus