An under-utilized feature I always appreciate when I see it is the ability to select an appearance for an app independent of the system appearance.

If you search how to set the color scheme of a view in SwiftUI, you’ll probably come across preferredColorScheme which looks great but doesn’t work how we’d like for this feature. Instead, we are going to make use of overrideUserInterfaceStyle

We’ll start off by creating our AppearanceOptions enum and make it conform to String and CaseIterable for use in our form later on.

enum AppearanceOptions: String, CaseIterable {
    case system, light, dark
}

And then we’ll create the most important part, our AppearanceController and set the default appearance to .System in UserDefaults using the @AppStorage property wrapper

class AppearanceController {
    static let shared = AppearanceController()
    @AppStorage("appAppearance") var appAppearance: AppearanceOptions = .system
}

Next we’ll need to get the correct UIUserInterfaceStyle to use with overrideUserInterfaceStyle so we’ll create a computed property in our AppearanceController that checks our appearance saved in UserDefaults.

var appearance: UIUserInterfaceStyle {
    switch appAppearance {
    case .system:
        return .unspecified // Uses appearance set by user in Settings
    case .light:
        return .light
    case .dark:
        return .dark
    }
}

And finally we’ll create our setAppearance function.

func setAppearance() {
    let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
    guard let window = windowScene?.windows.first else { return }
    window.overrideUserInterfaceStyle = appearance
}

Now in ContentView we’ll create a Picker for our appearance options.

struct ContentView: View {
    @AppStorage("appAppearance") var appAppearance: AppearanceOptions = .system
    
    var body: some View {
        Form {
            Picker("Appearance", selection: $appAppearance) {
                // This is where CaseIterable comes into play, allowing us to loop over our AppearanceOptions enum 
                ForEach(AppearanceOptions.allCases, id: \.self) { option in
                    Text(option.rawValue.capitalized)
                }
            }
            .pickerStyle(.inline)
        }
    }
}

On that Picker we’ll add an onChange modifier to call setAppearance when the user changes the selected appearance.

.onChange(of: appAppearance) { _ in
    AppearanceController.shared.setAppearance()
}

Now if you test your app, you should be able to set any color scheme, regardless of your system settings. But you might notice a problem if you close the app and reopen it.

If you selected Dark and your system appearance is set to Light (or vice-versa) you’ll see that the Picker reflects your selection but the appearance hasn’t changed. That’s because we forgot an important step: you need to call setAppearance when the app loads.

@main
struct AppearanceDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    AppearanceController.shared.setAppearance()
                }
        }
    }
}