SwiftUI: How to let the user set the app appearance in real-time w/ options "light", "dark", and "system"?
Asked Answered
I

5

3

I am currently trying to implement a solution in an app where the user is supposed to be able to switch the app's appearance in real-time with the following options:

  • System (applying whatever appearance is set in the iOS settings for the device)
  • Light (applying .light color scheme)
  • Dark (applying . dark color scheme)

Setting light and dark color schemes has proven to be quite easy and responsive using .preferredColorScheme(); however, I have not yet found any satisfying solution for the "System" option.

My current approach is the following:

  1. Getting the device color scheme using @Environment(.colorScheme) in ContentView
  2. Creating a custom view modifier for applying the respective color scheme on whatever view
  3. Using a modifier on "MainView" (that's where the real content of the app is supposed to live) to switch between the color schemes

My idea was to embed MainView in ContentView so that the @Environment(.colorScheme) would not be disturbed by any colorScheme that is applied to MainView.

However, it still doesn't work as supposed: When setting light and dark appearance, everything works as intended. However, when switching from light/dark to "system", the change in appearance is only visible after re-launching the app. Expected behavior, however, would be that the appearance changes instantly.

Any ideas on this?

Here are the relevant code snippets:

Main view

import SwiftUI

struct MainView: View {

    @AppStorage("selectedAppearance") var selectedAppearance = 0

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                selectedAppearance = 1
            }) {
                Text("Light")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 2
            }) {
                Text("Dark")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 0
            }) {
                Text("System")
            }
            Spacer()
        }
    }
}

ContentView

import SwiftUI

struct ContentView: View {

    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        MainView()
            .modifier(ColorSchemeModifier(colorScheme: colorScheme))
    }
}

"Utilities"

import Foundation
import SwiftUI

struct ColorSchemeModifier: ViewModifier {

    @AppStorage("selectedAppearance") var selectedAppearance: Int = 0
    var colorScheme: ColorScheme

    func body(content: Content) -> some View {
        if selectedAppearance == 2 {
            return content.preferredColorScheme(.dark)
        } else if selectedAppearance == 1 {
            return content.preferredColorScheme(.light)
        } else {
            return content.preferredColorScheme(colorScheme)
        }
    }
}
Iberian answered 19/1, 2021 at 19:31 Comment(2)
Have a look at the transformenvironment(_:transform:) view modifier. Let me know if that works. I've been having a lot of trouble with this too.Dusk
Thanks for the idea @PeterSchorn ... unfortunately I was not able to get it working that way.Iberian
I
10

I ended up using the following solution which is a slight adaptation of the answer that @pgb gave:

ContentView:

struct ContentView: View {

    @AppStorage("selectedAppearance") var selectedAppearance = 0
    var utilities = Utilities()

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                selectedAppearance = 1
            }) {
                Text("Light")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 2
            }) {
                Text("Dark")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 0
            }) {
                Text("System")
            }
            Spacer()
        }
        .onChange(of: selectedAppearance, perform: { value in
            utilities.overrideDisplayMode()
        })
    }
}

Helper class

class Utilities {

    @AppStorage("selectedAppearance") var selectedAppearance = 0
    var userInterfaceStyle: ColorScheme? = .dark

    func overrideDisplayMode() {
        var userInterfaceStyle: UIUserInterfaceStyle

        if selectedAppearance == 2 {
            userInterfaceStyle = .dark
        } else if selectedAppearance == 1 {
            userInterfaceStyle = .light
        } else {
            userInterfaceStyle = .unspecified
        }
    
        UIApplication.shared.windows.first?.overrideUserInterfaceStyle = userInterfaceStyle
    }
}
Iberian answered 21/1, 2021 at 18:8 Comment(1)
Running this in my app on a simulator works in a session. But if I close or quit the app the appearance is changed. Or am I missing something?Excruciating
A
8

It works with

.preferredColorScheme(selectedAppearance == 1 ? .light : selectedAppearance == 2 ? .dark : nil)

iOS 14.5+: That is it. They got the "nil" working to reset your preferredColorScheme.

iOS14:

The only issue is you have to reload the app for the system to get working. I can think of a workaround - when user selects the “system”, you first determine what is current colorScheme, change it to it and only then change the selectedAppearance to 0.

  • the user would see the result immediately and with the next start it’ll be the system theme.

Edit:

Here is the working idea:

struct MainView: View {
    @Binding var colorScheme: ColorScheme
    @AppStorage("selectedAppearance") var selectedAppearance = 0

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                selectedAppearance = 1
            }) {
                Text("Light")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 2
            }) {
                Text("Dark")
            }
            Spacer()
            Button(action: {
                if colorScheme == .light {
                    selectedAppearance = 1
                }
                else {
                    selectedAppearance = 2
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                    selectedAppearance = 0
                }
            }) {
                Text("System")
            }
            Spacer()
        }
    }
}

struct ContentView: View {
    @AppStorage("selectedAppearance") var selectedAppearance = 0
    @Environment(\.colorScheme) var colorScheme
    @State var onAppearColorScheme: ColorScheme = .light //only for iOS<14.5

    var body: some View {
        MainView(colorScheme: $onAppearColorScheme)
            .onAppear { onAppearColorScheme = colorScheme } //only for iOS<14.5
            .preferredColorScheme(selectedAppearance == 1 ? .light : selectedAppearance == 2 ? .dark : nil) }
}

There's a small catch -> it works only as onAppear and not onChange (no idea why)

Apothem answered 21/1, 2021 at 13:58 Comment(1)
nil not working in ios 15+.Betseybetsy
C
4

I don't believe this is possible in SwiftUI.

From the docs, using preferredColorScheme will affect the entire hierarchy, starting from the enclosing presentation:

The color scheme applies to the nearest enclosing presentation, such as a popover or window. Views may read the color scheme using the colorScheme environment value.

So, this affects your entire view hierarchy, which means you can't override it for only some views. Instead, it "bubbles up" and changes the color scheme for the entire window (or presentation context).

You can, however, change it from UIKit, but using overrideUserInterfaceStyle, which is an enum as well that supports .dark, .light, and .unspecified. Unspecified being the system setting.

How does this translates to your app? Well, I don't think you need a view modifier at all. Instead, you need to monitor your UserDefaults for changes to the selectedAppearance key, and react to it by changing overrideUserInterfaceStyle to the appropriate value.

So, depending on how the rest of your app is structured, either in your AppDelegate or SceneDelegate (you can use any other object as well, but it will need to access the presented UIWindow, so bear that in mind when refactoring), you can hook a listener to UserDefaults to listen for changes. Something like this:

UserDefaults.standard.addObserver(self, forKeyPath: "selectedAppearance", options: [.new], context: nil)

And then override observeValue to monitor the changes to the defaults key:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard
        let userDefaults = object as? UserDefaults,
        keyPath == "selectedAppearance"
    else {
        return
    }

    switch userDefaults.integer(forKey: "selectedAppearance") {
    case 2:
        window?.overrideUserInterfaceStyle = .dark
    case 1:
        window?.overrideUserInterfaceStyle = .light
    default:
        window?.overrideUserInterfaceStyle = .unspecified
    }
}

Some additional things to improve (out of context for this question, but I think worth mentioning):

  • I would use an enum instead of an integer for the valid values of the appearance.
  • Give that UIKit provides an enum with just the 3 values you want, I would reuse it, thus you wouldn't need the switch on the observeValue call.
Coon answered 20/1, 2021 at 20:8 Comment(6)
Thanks for the detailed answer! What bummer that this cannot be implemented in pure SwiftUI. However, I appreciate your solution and will try it out in my code to verify the answer!Iberian
SwitUI is relatively new, and for lots of things you'll need to dig UIKit APIs.Coon
It appears that even if you do want to override the appearance for your entire view hierarchy, it's not possible in pure SwiftUI.Dusk
@Coon I ended up using a solution pretty similar to what you proposed - thanks again for your help! However, I replaced the listener with an AppStorage var. So far it seems to work fine in first tests. Just for the testing I haven't yet converted the selectedAppearance in an enum yet ... but its something I will definitely do in the final implementation.Iberian
@Iberian glad this worked out. In the future, do consider giving accepting the answer that helped you instead of posting your own and accepting that one instead. They are pretty much the same solution.Coon
As of iOS 18/macOS Sequoia, this is still a correct diagnosis of why the .preferredColorScheme(nil) is useless. It is Apple's architecture design failure, as it sets the colorScheme environment variable from the top of the view stack, so once set, there is no other value to return to when you try to reset it to system color scheme.Jacaranda
E
1

Systemwide implementation example in SwiftUI with the SceneDelegate lifecycle

This has been already answered here by me, however it is fitting to this post that I provided the full implementation with the system option which was not requested in the other post. And it works as advertised (so far 😀).

I had to research a lot and had to fix quite a lot of issues. I did not have time look into using iOS14's @main instead of SceneDelegate yet but hope to do so in the future.
Also I want to add a NavigationLink in a Form like in the GitHub iOS app! It looks even better as with the picker.

Here is a link to the GitHub repo. The example has light, dark, and automatic picker which change the settings for the whole app.
And I went the extra mile to make it localizable!

GitHub repo

I need to access the SceneDelegate and I use the same code as Mustapha with a small addition, when the app starts I need to read the settings stored in UserDefaults or @AppStorage etc.
Therefore I update the UI again on launch:

private(set) static var shared: SceneDelegate?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    Self.shared = self

    // this is for when the app starts - read from the user defaults
    updateUserInterfaceStyle()
}

The function updateUserInterfaceStyle() will be in SceneDelegate. I use an extension of UserDefaults here to make it compatible with iOS13 (thanks to twanni!):

func updateUserInterfaceStyle() {
        DispatchQueue.main.async {
            switch UserDefaults.userInterfaceStyle {
            case 0:
                self.window?.overrideUserInterfaceStyle = .unspecified
            case 1:
                self.window?.overrideUserInterfaceStyle = .light
            case 2:
                self.window?.overrideUserInterfaceStyle = .dark
            default:
                self.window?.overrideUserInterfaceStyle = .unspecified
            }
        }
    }

This is consistent with the apple documentation for UIUserInterfaceStyle

Using a picker means that I need to iterate on my three cases so I made an enum which conforms to identifiable and is of type LocalizedStringKey for the localisation:

// check LocalizedStringKey instead of string for localisation!
enum Appearance: LocalizedStringKey, CaseIterable, Identifiable {
    case light
    case dark
    case automatic

    var id: String { UUID().uuidString }
}

And this is the full code for the picker:


struct AppearanceSelectionPicker: View {
    @Environment(\.colorScheme) var colorScheme
    @State private var selectedAppearance = Appearance.automatic

    var body: some View {
        HStack {
            Text("Appearance")
                .padding()
                .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
            Picker(selection: $selectedAppearance, label: Text("Appearance"))  {
                ForEach(Appearance.allCases) { appearance in
                    Text(appearance.rawValue)
                        .tag(appearance)
                }
            }
            .pickerStyle(WheelPickerStyle())
            .frame(width: 150, height: 50, alignment: .center)
            .padding()
            .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
        }
        .padding()

        .onChange(of: selectedAppearance, perform: { value in
            print("changed to ", value)
            switch value {
                case .automatic:
                    UserDefaults.userInterfaceStyle = 0
                    SceneDelegate.shared?.window?.overrideUserInterfaceStyle =  .unspecified
                case .light:
                    UserDefaults.userInterfaceStyle = 1
                    SceneDelegate.shared?.window?.overrideUserInterfaceStyle =  .light
                case .dark:
                    UserDefaults.userInterfaceStyle = 2
                    SceneDelegate.shared?.window?.overrideUserInterfaceStyle =  .dark
            }
        })
        .onAppear {
            print(colorScheme)
            print("UserDefaults.userInterfaceStyle",UserDefaults.userInterfaceStyle)
            switch UserDefaults.userInterfaceStyle {
                case 0:
                    selectedAppearance = .automatic
                case 1:
                    selectedAppearance = .light
                case 2:
                    selectedAppearance = .dark
                default:
                    selectedAppearance = .automatic
            }
        }
    }
}

The code onAppear is there to set the wheel to the correct value when the user gets to that settings view. Every time that the wheel is moved, through the .onChange modifier, the user defaults are updated and the app changes the settings for all views through its reference to the SceneDelegate.

(A gif is on the GH repo if interested.)

Erectile answered 12/11, 2021 at 16:34 Comment(0)
S
0

Wrote a modifier to simplify. Usage.

        VStack {
           ...
        }
        .appearanceUpdate()

Code to change the global app appearance. Basically we are setting preferredColorScheme after the update of environment object app settings.

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                ContentView()
            }
            .environmentObject(AppSettings.shared)
        }
    }
}

public struct AppearanceUpdate: ViewModifier {
    @EnvironmentObject private var appSettings: AppSettings

    public func body(content: Content) -> some View {
        content
            .preferredColorScheme(appSettings.currentTheme.colorScheme)
    }
}

public extension View {
    func appearanceUpdate() -> some View {
        modifier(AppearanceUpdate())
    }
}

public struct AppSetting {
    public enum Appearance: Int, CaseIterable {
        case automatic
        case light
        case dark

        public var colorScheme: ColorScheme? {
            switch self {
            case .light:
                return .light
            case .dark:
                return .dark
            case .automatic:
                return ColorScheme(.unspecified)
            }
        }
    }
}


public final class AppSettings: ObservableObject {
    public static let shared = AppSettings()

    @AppStorage("currentAppearance") public var currentTheme: AppSetting.Appearance = .automatic
}

Sexagesimal answered 1/2 at 20:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.