iosswiftmvvmuikitmvp

Show fixed progress below navigation bar between different view controllers


I have a long register form that consists of 4 steps (the content is not relevant), here are the mockups:

four screens with a common progress bar

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!


Solution

  • 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:

    I put up a complete example you can check out and inspect here: https://github.com/DonMag/ProgressNavController