SwiftUI: Dynamic status bar style

SwiftUI provides many view modifiers that affect the surrounding view hierarchy, but surprisingly none that alter that status bar style.

Problem

In the old days (cough SwiftUI 1) this could be accomplished by subclassing UIHostingController to override preferredStatusBarStyle, which is read-only by default, and using the newly created class as the rootViewController of the key window.

class HostingController<Content: View>: UIHostingController<Content> {
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(
      _ scene: UIScene, 
      willConnectTo session: UISceneSession, 
      options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else { return }

        let rootView = RootView()

        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = HostingController(rootView: rootView)
        self.window = window
        window.makeKeyAndVisible()
    }
}
Old technique

Said solution worked, because the now discouraged UIWindowSceneDelegate life cycle exposed the app's SceneDelegate.

This is not the case anymore.

There are still ways to swap out the UIHostingController during runtime, but these tend to break features that depend on the new app life cycle being used (e.g. onOpenURL).

Solution

>= iOS 16

Use toolbarColorScheme(_:for:)

< iOS 16

Since swapping in a custom UIHostingController isn't an option, a minimally invasive approach involves swizzling preferredStatusBarStyle to point to a computed variable which itself points to a writeable variable.

Method swizzling is a technique of last resort; you should only use it if you have no other options.

—  eskimo

extension UIViewController {
    fileprivate enum Holder {
        static var statusBarStyleStack: [UIStatusBarStyle] = .init()
    }

    fileprivate func interpose() -> Bool {
        let sel1: Selector = #selector(
            getter: preferredStatusBarStyle
        )
        let sel2: Selector = #selector(
            getter: preferredStatusBarStyleModified
        )

        let original = class_getInstanceMethod(Self.self, sel1)
        let new = class_getInstanceMethod(Self.self, sel2)

        if let original = original, let new = new {
            method_exchangeImplementations(original, new)

            return true
        }

        return false
    }

    @objc dynamic var preferredStatusBarStyleModified: UIStatusBarStyle {
        Holder.statusBarStyleStack.last ?? .default
    }
}

Some additional scaffolding is required to implement a .statusBarStyle view modifier.

import SwiftUI

enum Interposed {
    case pending
    case successful
    case failed
}

struct InterposedKey: EnvironmentKey {
    static let defaultValue: Interposed = .pending
}

extension EnvironmentValues {
    fileprivate(set) var interposed: Interposed {
        get { self[InterposedKey.self] }
        set { self[InterposedKey.self] = newValue }
    }
}

/// `UIApplication.keyWindow` is deprecated
extension UIApplication {
    var keyWindow: UIWindow? {
        connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap(\.windows)
            .first {
                $0.isKeyWindow
            }
    }
}

extension UIViewController {
    fileprivate enum Holder {
        static var statusBarStyleStack: [UIStatusBarStyle] = .init()
    }

    fileprivate func interpose() -> Bool {
        let sel1: Selector = #selector(
            getter: preferredStatusBarStyle
        )
        let sel2: Selector = #selector(
            getter: preferredStatusBarStyleModified
        )

        let original = class_getInstanceMethod(Self.self, sel1)
        let new = class_getInstanceMethod(Self.self, sel2)

        if let original = original, let new = new {
            method_exchangeImplementations(original, new)

            return true
        }

        return false
    }

    @objc dynamic var preferredStatusBarStyleModified: UIStatusBarStyle {
        Holder.statusBarStyleStack.last ?? .default
    }
}

struct StatusBarStyle: ViewModifier {
    @Environment(\.interposed) private var interposed

    let statusBarStyle: UIStatusBarStyle
    let animationDuration: TimeInterval

    private func setStatusBarStyle(_ statusBarStyle: UIStatusBarStyle) {
        UIViewController.Holder.statusBarStyleStack.append(statusBarStyle)

        UIView.animate(withDuration: animationDuration) {
            UIApplication.shared.keyWindow?.rootViewController?.setNeedsStatusBarAppearanceUpdate()
        }
    }

    func body(content: Content) -> some View {
        content
            .onAppear {
                setStatusBarStyle(statusBarStyle)
            }
            .onChange(of: statusBarStyle) {
                setStatusBarStyle($0)
                UIViewController.Holder.statusBarStyleStack.removeFirst(1)
            }
            .onDisappear {
                UIViewController.Holder.statusBarStyleStack.removeFirst(1)

                UIView.animate(withDuration: animationDuration) {
                    UIApplication.shared.keyWindow?.rootViewController?.setNeedsStatusBarAppearanceUpdate()
                }
            }
            // Interposing might still be pending on initial render
            .onChange(of: interposed) { _ in
                UIView.animate(withDuration: animationDuration) {
                    UIApplication.shared.keyWindow?.rootViewController?.setNeedsStatusBarAppearanceUpdate()
                }
            }
    }
}

extension View {
    func statusBarStyle(
        _ statusBarStyle: UIStatusBarStyle,
        animationDuration: TimeInterval = 0.3
    ) -> some View {
        modifier(StatusBarStyle(statusBarStyle: statusBarStyle, animationDuration: animationDuration))
    }
}


@main
struct YourApp: App {
    @Environment(\.scenePhase) private var scenePhase

    /// Ensures that interposing only occurs once
    private var interposeLock = NSLock()

    @State private var interposed: Interposed = .pending

    var body: some Scene {
        WindowGroup {
            VStack {
                Text("Hello, world!")
                    .padding()
            }
            .statusBarStyle(.lightContent)
            .environment(\.interposed, interposed)
        }
        .onChange(of: scenePhase) { phase in
            /// `keyWindow` isn't set before first `scenePhase` transition
            if case .active = phase {
                interposeLock.lock()
                if case .pending = interposed,
                   case true = UIApplication.shared.keyWindow?.rootViewController?.interpose() {
                    interposed = .successful
                } else {
                    interposed = .failed
                }
                interposeLock.unlock()
            }
        }
    }
}

The .\interposed environment value can used to detect swizzling failure. A potential fallback could involve filling the safe area beneath the status bar with an adaptive color.

struct Fallback: View {
  var body: some View {
    GeometryReader { reader in
      Color(uiColor: .systemBackground)
        .opacity(0.4)
        .frame(height: reader.safeAreaInsets.top)
        .edgesIgnoringSafeArea(.top)
    }
  }
}
Attribution-NonCommercial 4.0 International (only applies to text, code license: MIT)