29

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)

  • The article you are linking to is from 2016, when there was no system-wide dark mode. – LuLuGaGa Oct 20 '19 at 19:15
  • Do you mean the Human Interface Guidelines? They were updated for iOS 13 but maybe you're right and the settings section is old. –  Oct 20 '19 at 20:40
  • There is a Google tool to check when a web site was last changed and it says it was in 2016. There is a section in HIG about iOS 13 changes and it doesn’t mention Settings. – LuLuGaGa Oct 20 '19 at 20:57
  • Well in that case i'm sorry that i've linked an old article but it wasn't a code reference. More of a "i would like to reproduce something similar" reference. –  Oct 21 '19 at 05:14
  • Don't be sorry. All I wanted to say that it is old and probably not important any more. – LuLuGaGa Oct 21 '19 at 17:25

10 Answers10

43

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
I'm Joe Too
  • 5,468
  • 1
  • 17
  • 29
Mojtaba Hosseini
  • 95,414
  • 31
  • 268
  • 278
  • This helped a lot! Thank you! Any idea to put this into a segmented picker and add the function to use system ColorScheme? –  Oct 20 '19 at 20:37
  • Similar to this. The only thing different is the model. – Mojtaba Hosseini Oct 20 '19 at 20:39
  • You mean the ToggleModel, right? How would you approach this task? –  Oct 20 '19 at 20:42
  • Comment is not a place to answer question my friend. But feel free to [ask a new question](https://stackoverflow.com/questions/ask). But this time, make sure you read [how-to-ask](https://stackoverflow.com/help/how-to-ask) guideline and add a minimal code with the code you already tried ;) – Mojtaba Hosseini Oct 20 '19 at 20:45
  • Understand! I will ask another question on this topic soon ;) –  Oct 21 '19 at 05:15
  • 1
    Okay i don't have to ask another question, the solution is the third case: `.unspecified` . Thank you very much and have a nice day! –  Oct 21 '19 at 12:42
  • This really helped! Is it possible to show the switch as on when dark mode is enabled on the device already? At the moment it always shows as off when the view first opens. Thanks! – Tom Wicks Mar 07 '20 at 17:24
  • Apologies, noob here, could you elaborate or link out to some background on the "I used this in SceneDelegate:" part? Or perhaps include minimal complete snippet for that part as well? – Antony Stubbs Jul 03 '20 at 14:48
  • 1
    is there a way to have 3 options: auto, light, dark? – Lukasz D Jan 06 '22 at 11:36
9

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

hstdt
  • 5,652
  • 2
  • 34
  • 34
  • 1
    I'd use a Bool for isDarkMode instead of an int if possible. i'm not familiar with swiftUI but this gives me C vibe :D – Deitsch Jul 21 '20 at 09:12
  • This is great! One thing though, on the simulator status bar text becomes unreadable (carrier info etc.) (white on white) when the system mode is dark, and the application is light. Is there a way to exclude the bar from the change, or change the colour of the text? – fankibiber Jul 27 '20 at 11:09
  • @fankibiber Code updated. Add `.preferredColorScheme(isDarkMode ? .dark : .light)` – hstdt Jul 27 '20 at 11:35
  • Easy as one Modifier onto the entire ContentView - Awesome! Thanks. – nicksarno Aug 26 '20 at 15:31
  • With this how would you set it back to use the system color scheme too? – Ludyem Sep 07 '20 at 13:38
  • I am indeed wondering how this can be improved via including a third `System` option. I guess we need to use an `Int` as tags, then use that with `AppStorage` to modify in `DarkModeViewModifier`. I am not sure how to implement it properly though. – fankibiber Sep 22 '20 at 13:50
8

@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)
    }
}
ADB
  • 591
  • 7
  • 21
7

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))
    }
}
Arturo
  • 3,254
  • 2
  • 22
  • 61
5

Using @AppStorage

Initial app launch code

Make sure that appearanceSwitch is an optional ColorScheme? so .none can can be selected

import SwiftUI

@main
struct AnOkApp: App {

    @AppStorage("appearanceSelection") private var appearanceSelection: Int = 0

    var appearanceSwitch: ColorScheme? {
        if appearanceSelection == 1 {
            return .light
        }
        else if appearanceSelection == 2 {
            return .dark
        }
        else {
            return .none
        }
    }
    
    var body: some Scene {
        WindowGroup {
            AppearanceSelectionView()
                .preferredColorScheme(appearanceSwitch)
        }
    }
}

Selection View

import SwiftUI

struct AppearanceSelectionView: View {

    @AppStorage("appearanceSelection") private var appearanceSelection: Int = 0
    
    var body: some View {
        NavigationView {
            Picker(selection: $appearanceSelection) {
                Text("System")
                    .tag(0)
                Text("Light")
                    .tag(1)
                Text("Dark")
                    .tag(2)
            } label: {
              Text("Select Appearance")
            }
            .pickerStyle(.menu)
        }
    }
}
3

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

multitudes
  • 2,898
  • 2
  • 22
  • 29
2
#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
        })

    }
}
Adrian Mole
  • 49,934
  • 160
  • 51
  • 83
2

Let's try to make life easier,

In your initial app launch code, add the following,

@main
struct MyAwesomeApp: App {
    @AppStorage("appearance") var appearance: String = "system"
    
    var body: some Scene {
        WindowGroup {
            StartView()
                .preferredColorScheme(appearance == "system" ? nil : (appearance == "dark" ? .dark : .light))
        }
    }
}

Now you can make a separate setting AppearanceView and can do the following,

struct AppearanceView: View {
    @AppStorage("appearance") var appearance: String = "system"
    
    var body: some View {
        VStack {
            ScrollView(showsIndicators: false) {
                ForEach(Appearance.allCases, id: \.self) { appearance in
                    Button {
                        self.appearance = appearance.rawValue
                    } label: {
                        HStack {
                            Text(LocalizedStringKey(appearance.rawValue))
                            
                            Spacer()
                            
                            Image(self.appearance == appearance.rawValue ? "check-selected" : "check-unselected")
                        }
                    }
                }
            }
        }
    }
    
}

And the Appearance enum,

enum Appearance: String, CaseIterable {
    case light = "light"
    case dark = "dark"
    case system = "system"
}
shanezzar
  • 1,031
  • 13
  • 17
1

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.

Prasanth
  • 646
  • 6
  • 21
1

Easiest Solution:

import Foundation
import SwiftUI

enum AppearanceType: Codable, CaseIterable, Identifiable {
    case automatic
    case dark
    case light

    var id: Self {
        return self
    }

    var label: String {
        switch self {
        case .automatic:
            "Automatic"
        case .dark:
            "Dark"
        case .light:
            "Light"
        }
    }
}

extension AppearanceType {
    var colorScheme: ColorScheme? {
        switch self {
        case .automatic:
            nil
        case .dark:
            .dark
        case .light:
            .light
        }
    }
}

Here UserDefaultsService stores the selected AppearanceType enum value to the UserDefault.

import SwiftUI

final class SettingsViewModel: ObservableObject {
    private let userDefaultService: UserDefaultsService
    @Published private(set) var appearance: AppearanceType

    init(usersDefaultService: UserDefaultsService) {
        self.userDefaultService = usersDefaultService
        self.appearance = userDefaultService.getAppearanceType() ?? .automatic
    }

    func changeAppearance(with appearance: AppearanceType) {
        withAnimation {
            self.appearance = appearance
            userDefaultService.setAppearanceType(appearance: appearance)
        }
    }
}

Now we can use like this,

    @StateObject var authViewModel: AuthViewModel = .init(authService: AppDependencies.shared.authService)
    @StateObject var settingsViewModel: SettingsViewModel = .init(usersDefaultService: AppDependencies.shared.userDefaultsService)

    var body: some Scene {
        WindowGroup {
            Group {
                if authViewModel.isAuthenticated {
                    RootView()
                } else {
                    AuthScreen()
                }
            }
            .environmentObject(authViewModel)
            .environmentObject(settingsViewModel)
            .preferredColorScheme(settingsViewModel.appearance.colorScheme)
        }
    }
Sanjay Soni
  • 168
  • 4
  • 10