How to preserve space occupied by status bar when hiding status bar animately?

Viewed 565

I tend to hide the status bar, animated in the following way.

enter image description here

var statusBarHidden: Bool = false {
    didSet {
        UIView.animate(withDuration: Constants.config_shortAnimTime) { () -> Void in
            self.setNeedsStatusBarAppearanceUpdate()
        }
    }
}

override var prefersStatusBarHidden: Bool {
    return statusBarHidden
}

override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation{
    return .slide
}

extension ViewController: SideMenuNavigationControllerDelegate {
    func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) {
        statusBarHidden = true
    }

    func sideMenuDidAppear(menu: SideMenuNavigationController, animated: Bool) {
    }

    func sideMenuWillDisappear(menu: SideMenuNavigationController, animated: Bool) {
    }

    func sideMenuDidDisappear(menu: SideMenuNavigationController, animated: Bool) {
        statusBarHidden = false
    }
}

However, I would also like to preserve the space occupied by status bar, so that when status bar appears, the entire app will not be "pushed up"

May I know how I can achieve so?

Thank you.

2 Answers

You can use additionalSafeAreaInsets to add a placeholder height, substituting the status bar.

But for devices with a notch like the iPhone 12, the space is automatically preserved, so you don't need to add any additional height.

class ViewController: UIViewController {

    var statusBarHidden: Bool = false /// no more computed property, otherwise reading safe area would be too late
    override var prefersStatusBarHidden: Bool {
        return statusBarHidden
    }

    override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation{
        return .slide
    }
    
    @IBAction func showButtonPressed(_ sender: Any) {
        statusBarHidden.toggle()
        if statusBarHidden {
            sideMenuWillAppear()
        } else {
            sideMenuWillDisappear()
        }
    }
    
    lazy var overlayViewController: UIViewController = {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        return storyboard.instantiateViewController(withIdentifier: "OverlayViewController")
    }()
    
    var additionalHeight: CGFloat {
        if view.window?.safeAreaInsets.top ?? 0 > 20 { /// is iPhone X or other device with notch
            return 0 /// add 0 height
        } else {
            /// the height of the status bar
            return view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0.0
        }
    }
}

extension ViewController {
    
    /// add placeholder height to substitute status bar
    func addAdditionalHeight(_ add: Bool) {
        if add {
            if let navigationController = self.navigationController {
                /// set insets of navigation controller if you're using navigation controller
                navigationController.additionalSafeAreaInsets.top = additionalHeight
            } else {
                /// set insets of self if not using navigation controller
                self.additionalSafeAreaInsets.top = additionalHeight
            }
        } else {
            if let navigationController = self.navigationController {
                /// set insets of navigation controller if you're using navigation controller
                navigationController.additionalSafeAreaInsets.top = 0
            } else {
                /// set insets of self if not using navigation controller
                self.additionalSafeAreaInsets.top = 0
            }
        }
    }
    
    func sideMenuWillAppear() {
        
        addChild(overlayViewController)
        view.addSubview(overlayViewController.view)
        overlayViewController.view.frame = view.bounds
        overlayViewController.view.frame.origin.x = -400
        overlayViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        overlayViewController.didMove(toParent: self)
        
        addAdditionalHeight(true) /// add placeholder height
        
        UIView.animate(withDuration: 1) {
            self.overlayViewController.view.frame.origin.x = -100
            self.setNeedsStatusBarAppearanceUpdate() /// hide status bar
        }
    }

    func sideMenuDidAppear() {}

    func sideMenuWillDisappear() {
        
        addAdditionalHeight(false) /// remove placeholder height
        
        UIView.animate(withDuration: 1) {
            self.overlayViewController.view.frame.origin.x = -400
            self.setNeedsStatusBarAppearanceUpdate() /// show status bar
        } completion: { _ in
            self.overlayViewController.willMove(toParent: nil)
            self.overlayViewController.view.removeFromSuperview()
            self.overlayViewController.removeFromParent()
        }
    }

    func sideMenuDidDisappear() {}
}

Result (Tested on iPhone 12, iPhone 8, iPad Pro 4th gen):

iPhone 12 (notch) iPhone 8 (no notch)
Show and hide side menu. The button to perform hide and show doesn't jump up when status bar hides. Show and hide side menu. The button to perform hide and show doesn't jump up when status bar hides.
iPhone 12 + navigation bar iPhone 8 + navigation bar
Show and hide side menu. The button to perform hide and show doesn't jump up when status bar hides, and navigation bar stays where it is. Show and hide side menu. The button to perform hide and show doesn't jump up when status bar hides, and navigation bar stays where it is.

Demo GitHub repo

First of all, it is not currently possible to make UINavigationController behave this way. However you can wrap your UINavigationController instance in a Container View Controller.

This will give you control over managing the top space from where the UINavigationController view layout starts. Inside this container class, you could manage it like following -

class ContainerViewController: UIViewController {

    private lazy var statusBarBackgroundView: UIView = {
        let view = UIView(frame: .zero)
        view.backgroundColor = .clear
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    private lazy var statusBarBackgroundViewHeightConstraint: NSLayoutConstraint = {
        statusBarBackgroundView.heightAnchor.constraint(equalToConstant: 0)
    }()
    
    var statusBarHeight: CGFloat {
        if #available(iOS 13.0, *) {
            guard let statusBarMananger = self.view.window?.windowScene?.statusBarManager 
            else { return 0 }
            return statusBarMananger.statusBarFrame.height
        } else {
            return UIApplication.shared.statusBarFrame.height
        }
    }

    var statusBarHidden: Bool = false {
        didSet {
            self.statusBarBackgroundViewHeightConstraint.constant = self.statusBarHidden ? self.lastKnownStatusBarHeight : 0
            self.view.layoutIfNeeded()
        }
    }

    private var lastKnownStatusBarHeight: CGFloat = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let topView = self.statusBarBackgroundView
        self.view.addSubview(topView)
        NSLayoutConstraint.activate([
            topView.topAnchor.constraint(equalTo: self.view.topAnchor),
            topView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            statusBarBackgroundViewHeightConstraint,
            topView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
        ])
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        let height = self.statusBarHeight
        if height > 0 {
            self.lastKnownStatusBarHeight = height
        }
    }

    func setUpNavigationController(_ navCtrl: UINavigationController) {
        self.addChild(navCtrl)
        navCtrl.didMove(toParent: self)
        self.view.addSubview(navCtrl.view)
    
        navCtrl.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            navCtrl.view.topAnchor.constraint(equalTo: statusBarBackgroundView.bottomAnchor),
            navCtrl.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            navCtrl.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
            navCtrl.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
        ])
    
        self.view.layoutIfNeeded()
    }

}

Now from your call site, you can do following -

class ViewController: UIViewController {
    
    var statusBarHidden: Bool = false {
        didSet {
            UIView.animate(withDuration: Constants.config_shortAnimTime) { () -> Void in
                
                /// Forward the call to ContainerViewController to act on this update
                (self.navigationController?.parent as? ContainerViewController)?.statusBarHidden = self.statusBarHidden
                
                /// Keep doing whatever you are doing now
                self.setNeedsStatusBarAppearanceUpdate()
            }
        }
    }

}
Related