Visualizing navigation flow progress
May 25, 2019
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!