'Implement dark mode switch in SwiftUI App

I'm currently looking into Dark Mode in my App. While Dark Mode itself isn't much of a struggle because of my SwiftUI basis i'm struggling with the option to set the ColorScheme independent of the system ColorScheme.

I found this in apples human interface guidelines and i'd like to implement this feature. (Link: Human Interface Guidelines)

Any idea how to do this in SwiftUI? I found some hints towards @Environment but no further information on this topic. (Link: Last paragraph)



Solution 1:[1]

Single View

To change the color scheme of a single view (Could be the main ContentView of the app), you can use the following modifier:

.environment(\.colorScheme, .light) // or .dark

or

.preferredColorScheme(.dark)

Also, you can apply it to the ContentView to make your entire app dark!

Assuming you didn't change the ContentView name in scene delegate or @main


Entire App (Including the UIKit parts and The SwiftUI)

First you need to access the window to change the app colorScheme that called UserInterfaceStyle in UIKit.

I used this in SceneDelegate:

private(set) static var shared: SceneDelegate?

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

Then you need to bind an action to the toggle. So you need a model for it.

struct ToggleModel {
    var isDark: Bool = true {
        didSet { 
            SceneDelegate.shared?.window!.overrideUserInterfaceStyle = isDark ? .dark : .light 
        }
    }
}

At last, you just need to toggle the switch:

struct ContentView: View {
     @State var model = ToggleModel()

     var body: some View {
         Toggle(isOn: $model.isDark) {
             Text("is Dark")
        }
    }
}

From the UIKit part of the app

Each UIView has access to the window, So you can use it to set the . overrideUserInterfaceStyle value to any scheme you need.

myView.window?.overrideUserInterfaceStyle = .dark

Solution 2:[2]

A demo of using @AppStorage to switch dark mode

PS: For global switch, modifier should be added to WindowGroup/MainContentView

import SwiftUI

struct SystemColor: Hashable {
    var text: String
    var color: Color
}

let backgroundColors: [SystemColor] = [.init(text: "Red", color: .systemRed), .init(text: "Orange", color: .systemOrange), .init(text: "Yellow", color: .systemYellow), .init(text: "Green", color: .systemGreen), .init(text: "Teal", color: .systemTeal), .init(text: "Blue", color: .systemBlue), .init(text: "Indigo", color: .systemIndigo), .init(text: "Purple", color: .systemPurple), .init(text: "Pink", color: .systemPink), .init(text: "Gray", color: .systemGray), .init(text: "Gray2", color: .systemGray2), .init(text: "Gray3", color: .systemGray3), .init(text: "Gray4", color: .systemGray4), .init(text: "Gray5", color: .systemGray5), .init(text: "Gray6", color: .systemGray6)]

struct DarkModeColorView: View {

    @AppStorage("isDarkMode") var isDarkMode: Bool = true

    var body: some View {
        Form {
            Section(header: Text("Common Colors")) {
                ForEach(backgroundColors, id: \.self) {
                    ColorRow(color: $0)
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .principal) { // navigation bar
               Picker("Color", selection: $isDarkMode) {
                    Text("Light").tag(false)
                    Text("Dark").tag(true)
                }
                .pickerStyle(SegmentedPickerStyle())
            }
        }
        .modifier(DarkModeViewModifier())
    }
}

private struct ColorRow: View {

    let color: SystemColor

    var body: some View {
        HStack {
            Text(color.text)
            Spacer()
            Rectangle()
                .foregroundColor(color.color)
                .frame(width: 30, height: 30)
        }
    }
}

public struct DarkModeViewModifier: ViewModifier {

    @AppStorage("isDarkMode") var isDarkMode: Bool = true

    public func body(content: Content) -> some View {
        content
            .environment(\.colorScheme, isDarkMode ? .dark : .light)
            .preferredColorScheme(isDarkMode ? .dark : .light) // tint on status bar
    }
}

struct DarkModeColorView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            DarkModeColorView()
        }
    }
}

enter image description here

Solution 3:[3]

@Mojtaba Hosseini's answer really helped me with this, but I'm using iOS14's @main instead of SceneDelegate, along with some UIKit views so I ended up using something like this (this doesn't toggle the mode, but it does set dark mode across SwiftUI and UIKit:

@main
struct MyTestApp: App {

    @Environment(\.scenePhase) private var phase

    var body: some Scene {
        WindowGroup {
            ContentView()
                .accentColor(.red)
                .preferredColorScheme(.dark)
        }
        .onChange(of: phase) { _ in
            setupColorScheme()
        }
    }

    private func setupColorScheme() {
        // We do this via the window so we can access UIKit components too.
        let window = UIApplication.shared.windows.first
        window?.overrideUserInterfaceStyle = .dark
        window?.tintColor = UIColor(Color.red)
    }
}

Solution 4:[4]

The answer from @ADB is good, but I found a better one. Hopefully someone finds even a better one than mine :D This approach doesn't call the same function over and over again once the app switches state (goes to the background and comes back)

in your @main view add:

ContentView()
    .modifier(DarkModeViewModifier())

Now create the DarkModeViewModifier() ViewModel:

class AppThemeViewModel: ObservableObject {
    
    @AppStorage("isDarkMode") var isDarkMode: Bool = true                           // also exists in DarkModeViewModifier()
    @AppStorage("appTintColor") var appTintColor: AppTintColorOptions = .indigo
    
}

struct DarkModeViewModifier: ViewModifier {
    @ObservedObject var appThemeViewModel: AppThemeViewModel = AppThemeViewModel()
    
    public func body(content: Content) -> some View {
        content
            .preferredColorScheme(appThemeViewModel.isDarkMode ? .dark : appThemeViewModel.isDarkMode == false ? .light : nil)
            .accentColor(Color(appThemeViewModel.appTintColor.rawValue))
    }
}

Solution 5:[5]

Systemwide with SwiftUI with SceneDelegate lifecycle

I used the hint provided in the answer by in the answer by Mojtaba Hosseini to make my own version in SwiftUI (App with the AppDelegate lifecycle). I did not look into using iOS14's @main instead of SceneDelegate yet.

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.)

Solution 6:[6]

#SwiftUI #iOS #DarkMode #ColorScheme

//you can take one boolean and set colorScheme of perticuler view accordingly such like below

struct ContentView: View {

    @State var darkMode : Bool =  false

    var body: some View {
        VStack {
         Toggle("DarkMode", isOn: $darkMode)
            .onTapGesture(count: 1, perform: {
                darkMode.toggle()
            })
        }
        .preferredColorScheme(darkMode ? .dark : .light)

    }
}



// you can also set dark light mode of whole app such like below 

struct ContentView: View {
    @State var darkMode : Bool =  false

    var body: some View {
        VStack {
         Toggle("DarkMode", isOn: $darkMode)
            .onTapGesture(count: 1, perform: {
                darkMode.toggle()
            })
        }
        .onChange(of: darkMode, perform: { value in
            SceneDelegate.shared?.window?.overrideUserInterfaceStyle = value ? .dark : .light
        })

    }
}

Solution 7:[7]

I've used the answer by @Arturo and combined in some of the work by @multitudes to make my own implementation

I still add @main as well as in my settings view

ContentView()
    .modifier(DarkModeViewModifier())

I then have the following:

class AppThemeViewModel: ObservableObject {
    @AppStorage("appThemeSetting") var appThemeSetting = Appearance.system
}

struct DarkModeViewModifier: ViewModifier {
    @ObservedObject var appThemeViewModel: AppThemeViewModel = AppThemeViewModel()

    public func body(content: Content) -> some View {
        content
            .preferredColorScheme((appThemeViewModel.appThemeSetting == .system) ? .none : appThemeViewModel.appThemeSetting == .light ? .light : .dark)
    }
}

enum Appearance: String, CaseIterable, Identifiable  {
    case system
    case light
    case dark
    var id: String { self.rawValue }
}

struct ThemeSettingsView:View{
    @AppStorage("appThemeSetting") var appThemeSetting = Appearance.system

    var body: some View {
        HStack {
            Picker("Appearance", selection: $appThemeSetting) {
                ForEach(Appearance.allCases) {appearance in
                    Text(appearance.rawValue.capitalized)
                        .tag(appearance)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
        }
    }
}

working almost perfectly - the only issue I have is when switching from a user selected value to system setting it doesn't update the settings view itself. When switching from system to Dark/Light or between Dark and light it settings screen does update fine.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 I'm Joe Too
Solution 2
Solution 3
Solution 4 Arturo
Solution 5
Solution 6 Adrian Mole
Solution 7 Prasanth