I have a long register form that consists of 4 steps (the content is not relevant), here are the mockups:
My problem is that I need to share a progress view between multiple views. This view should have an animation of growth. What would be the right and clean way to do this with UIKit? Should I create a custom Navigation Bar with that progress? Or use child controllers in some way?
I've been searching over here but the other questions I found are very old (like 7 years ago) and I don't know if there could be better solutions.
Thanks a lot!
There are various ways to do this...
One common approach is to set the "progress view" as the navigation bar's Title View -- but that won't show it below the navigation bar.
So, another approach is to subclass UINavigationController
and add a "progress view" as a subview. Then, implement willShow viewController
and/or didShow viewController
to update the progress.
As a quick example, assuming we have 4 "steps" to navigate to...
We'll start with defining a "base" view controller, with two properties that our custom nav controller class will use:
class MyBaseVC: UIViewController {
// this will be read by ProgressNavController
// to calculate the "progress percentage"
public let numSteps: Int = 4
// this will be set by each MyBaseVC subclass,
// and will be read by ProgressNavController
public var myStepNumber: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
// maybe some stuff common to the "step" controllers
}
}
Then, each "step" controller will be a subclass of MyBaseVC
, and will set its "step number" (along with anything else specific to that controller):
class Step1VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 1
// maybe some other stuff specific to this "step"
}
}
class Step2VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 2
// maybe some other stuff specific to this "step"
}
}
class Step3VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 3
// maybe some other stuff specific to this "step"
}
}
class Step4VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 4
// maybe some other stuff specific to this "step"
}
}
Then we can setup our custom nav controller class like this (it's not really as complicated as it may look):
class ProgressNavController: UINavigationController, UINavigationControllerDelegate {
private let outerView = UIView()
private let innerView = UIView()
private var pctConstraint: NSLayoutConstraint!
override init(rootViewController: UIViewController) {
super.init(rootViewController: rootViewController)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
// for this example, we're using a simple
// green view inside a red view
// as our "progress view"
// we set it up here, but we don't add it as a subview
// until we navigate to a MyBaseVC
// we know we're setting
// outerView height to 20
// innerView height to 12 (4-points top/bottom "padding")
// so let's round the ends of the innerView
innerView.layer.cornerRadius = 8.0
outerView.backgroundColor = .systemRed
innerView.backgroundColor = .systemGreen
outerView.translatesAutoresizingMaskIntoConstraints = false
innerView.translatesAutoresizingMaskIntoConstraints = false
outerView.addSubview(innerView)
// initialize pctConstraint
pctConstraint = innerView.widthAnchor.constraint(equalTo: outerView.widthAnchor, multiplier: .leastNonzeroMagnitude)
NSLayoutConstraint.activate([
innerView.topAnchor.constraint(equalTo: outerView.topAnchor, constant: 4.0),
innerView.leadingAnchor.constraint(equalTo: outerView.leadingAnchor, constant: 4.0),
innerView.bottomAnchor.constraint(equalTo: outerView.bottomAnchor, constant: -4.0),
pctConstraint,
])
self.delegate = self
}
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// if the next VC to show
// is a MyBaseVC subclass
if let _ = viewController as? MyBaseVC {
// add the "progess view" if we're coming from a non-MyBaseVC controller
if outerView.superview == nil {
view.addSubview(outerView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
outerView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 4.0),
outerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
outerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
outerView.heightAnchor.constraint(equalToConstant: 20.0),
])
// .alpha to Zero so we can "fade it in"
outerView.alpha = 0.0
// we just added the progress view,
// so we'll let didShow "fade it in"
// and update the progress width
} else {
self.updateProgress(viewController)
}
} else {
if outerView.superview != nil {
// we want to quickly "fade-out" and remove the "progress view"
// if the next VC to show
// is NOT a MyBaseVC subclass
UIView.animate(withDuration: 0.1, animations: {
self.outerView.alpha = 0.0
}, completion: { _ in
self.outerView.removeFromSuperview()
self.pctConstraint.isActive = false
self.pctConstraint = self.innerView.widthAnchor.constraint(equalTo: self.outerView.widthAnchor, multiplier: .leastNonzeroMagnitude)
self.pctConstraint.isActive = true
})
}
}
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
// if the VC just shown
// is a MyBaseVC subclass
// AND
// outerView.alpha < 1.0 (meaning it was just added)
if let _ = viewController as? MyBaseVC, outerView.alpha < 1.0 {
self.updateProgress(viewController)
}
// otherwise, updateProgress() is called from willShow
}
private func updateProgress(_ viewController: UIViewController) {
if let vc = viewController as? MyBaseVC {
// update the innerView width -- the "progress"
let nSteps: CGFloat = CGFloat(vc.numSteps)
let thisStep: CGFloat = CGFloat(vc.myStepNumber)
var pct: CGFloat = .leastNonzeroMagnitude
// sanity check
// avoid error/crash if either values are Zero
if nSteps > 0.0, thisStep > 0.0 {
pct = thisStep / nSteps
}
// don't exceed 100%
pct = min(pct, 1.0)
// we can't update the multiplier directly, so
// deactivate / update / activate
self.pctConstraint.isActive = false
self.pctConstraint = self.innerView.widthAnchor.constraint(equalTo: self.outerView.widthAnchor, multiplier: pct, constant: -8.0)
self.pctConstraint.isActive = true
// if .alpha is already 1.0, this is effectively ignored
UIView.animate(withDuration: 0.1, animations: {
self.outerView.alpha = 1.0
})
// animate the "bar width"
UIView.animate(withDuration: 0.3, animations: {
self.outerView.layoutIfNeeded()
})
}
}
}
So, when we navigate to a new controller:
MyBaseVC
I put up a complete example you can check out and inspect here: https://github.com/DonMag/ProgressNavController