Set the iOS status bar style in SwiftUI using a custom view modifier

Set the iOS status bar style in SwiftUI using a custom view modifier

Good news! You're working on a brand new iOS application, your deployment target is iOS 15+, and you create a new project and select SwiftUI for the interface (which means you have no AppDelegate or SceneDelegate). Your design team has handed you the most beautiful UI designs you have ever seen and in them, some screens require an iOS status bar with a dark content appearance and other screens that require a light content appearance. That's ok, right? No big deal. But, unfortunately, with the current version of SwiftUI, this is a big deal and not something that has a very elegant solution.

Before I get started with my solution I have a few things to say. First, this solution works now, with the current version of SwiftUI and Xcode as of the time of this blog's post date. If you are reading this after WWDC 2022 I would recommend that you stop reading and check out Apple's SwiftUI documentation and see if they have developed their own modifier to solve this problem. Reading before WWDC 2022 or Apple still has not provided a first-class solution? Then read on. I'd also like to say, that I am in no way proud of this code. It is the hackiest code I've ever written and it makes me sick to look at. But it gets the job done. I debated even writing this blog post, that is how much I hate it. Please don't call the police on me.

If you are not using SwiftUI you can change the color of the status bar with methods found in this excellent blog post.

If your app can use the same status bar mode for every view, you can change the value in your info.plist file. Add the following to info.plist,

<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>

Or UIStatusBarStyleDarkContent.

If you meet the criteria of a SwiftUI app without a SceneDelegate then you can use the following method.

First, in your info.plist add the following,

<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>

Next, we will extend View and add a new view modifier that we can use in any of our SwiftUI views that need to change the status bar color. Here is the full code if you want to copy and paste it. I will go into more detail below.

import SwiftUI

extension View {
    /// A view modifier to set the color of the iOS Status Bar
    func statusBarStyle(_ style: UIStatusBarStyle, ignoreDarkMode: Bool = false) -> some View {
        background(HostingWindowFinder(callback: { window in
            guard let rootViewController = window?.rootViewController else { return }
            let hostingController = HostingViewController(rootViewController: rootViewController, style: style, ignoreDarkMode: ignoreDarkMode)
            window?.rootViewController = hostingController
        }))
    }
}

fileprivate class HostingViewController: UIViewController {
    private var rootViewController: UIViewController?
    private var style: UIStatusBarStyle = .lightContent
    private var ignoreDarkMode: Bool = false
    
    init(rootViewController: UIViewController, style: UIStatusBarStyle, ignoreDarkMode: Bool) {
        self.rootViewController = rootViewController
        self.style = style
        self.ignoreDarkMode = ignoreDarkMode
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        guard let child = rootViewController else { return }
        addChild(child)
        view.addSubview(child.view)
        child.didMove(toParent: self)
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        if ignoreDarkMode || traitCollection.userInterfaceStyle == .light {
            return style
        } else {
            if style == .darkContent {
                return .lightContent
            } else {
                return .darkContent
            }
        }
    }
    
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        setNeedsStatusBarAppearanceUpdate()
    }
}

fileprivate struct HostingWindowFinder: UIViewRepresentable {
    var callback: (UIWindow?) -> ()

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        // NO-OP
    }
}

The HostingWindowFinder is getting the current UIWindow. We then set its rootViewController to HostingViewController which does the heavy lifting. We add the UIWindow's current root view controller as a child view controller to HostingViewController. We are also overriding preferredStatusBarStyle with the status bar style, we want to use.

struct HostingWindowFinder: UIViewRepresentable {
    var callback: (UIWindow?) -> ()

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        // NO-OP
    }
}

HostingWindowFinder is a pretty simple UIViewRepresentable struct. It has a callback that returns a UIWindow on the main thread.

HostingViewController is a UIViewController with the following vars and init,

    private var rootViewController: UIViewController?
    private var style: UIStatusBarStyle = .lightContent
    private var ignoreDarkMode: Bool = false
    
    init(rootViewController: UIViewController, style: UIStatusBarStyle, ignoreDarkMode: Bool) {
        self.rootViewController = rootViewController
        self.style = style
        self.ignoreDarkMode = ignoreDarkMode
        super.init(nibName: nil, bundle: nil)
    }

rootViewController is the current rootViewController of the UIWindow found with HostingWindowFinder. Style is the UIStatusBarStyle that we want to set the status bar to, .lightContent or .darkContent. And ignoreDarkMode is a Bool to override some of the default functionality of how iOS handles the status bar style in dark mode.

In the viewDidLoad() method we add rootViewController as a child view controller,

override func viewDidLoad() {
        super.viewDidLoad()
        guard let child = rootViewController else { return }
        addChild(child)
        view.addSubview(child.view)
        child.didMove(toParent: self)
    }

We override the preferredStatusBarStyle var to set the style we have chosen in our modifier,

override var preferredStatusBarStyle: UIStatusBarStyle {
        if ignoreDarkMode || traitCollection.userInterfaceStyle == .light {
            return style
        } else {
            if style == .darkContent {
                return .lightContent
            } else {
                return .darkContent
            }
        }
    }

Finally, we update the view if the user changes from light mode to dark more and vice versa in the iOS system settings,

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        setNeedsStatusBarAppearanceUpdate()
    }

In our SwiftUI view, we can use this modifier like so,

import SwiftUI

struct MyView: View {
    
    var body: some View {
        Text("my text")
         .statusBarStyle(.darkContent)   
    }
}

This use of the statusBarStyle modifier will force the iOS status bar to the darkContent style. lightContent is our other choice.

I hope this helps you get the desired look in your SwiftUI app while we wait for a first-party Apple-provided modifier. What do you think? Is this a good solution? Let me know on Twitter, @TomRads.