user12132829
user12132829

Reputation:

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)

Upvotes: 33

Views: 31117

Answers (12)

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 120002

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:

.preferredColorScheme(.dark) // or `.light or` `nil` to use the current scheme

or

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

Entire App (SwiftUI)

You can apply it to the ContentView inside the root WindowGroup 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 (How to add scene delegate in SwiftUI):

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 // or `.light` or `.unspecified` to use the current scheme

Upvotes: 53

Medhi
Medhi

Reputation: 3245

My approach is simple: you force dark mode or not. If not, app is based on system wide setting.

1: Save userDefault preference for dark mode:

class UserDefaultsManager: ObservableObject {
    static let userDefaults = UserDefaults(suiteName: "fr.keyup.isoft")!
    
    @AppStorage("darkMode", store: userDefaults)
    var appearance: Bool = false
}

2: Pass it to entire App with environnmentObject:

var body: some Scene {
    WindowGroup {
         TabBar()
            .preferredColorScheme(userDefaultsManager.appearance ? .dark : nil)
    }
}

3: Then add an toggle in your Settings View (ie: Form):

Section {
        Toggle("Force dark mode", isOn: userDefaultsManager.$appearance)
} header: {
        Text("Notifications")
}

Upvotes: 0

Alexa Francis
Alexa Francis

Reputation: 1

Swift 5

To set the color on any system UI that you're showing (.actionSheet, .confirmationDialog, etc.), add this at the bottom of the view's body:

    var body: some View {
        ...
        .preferredColorScheme(.dark)
        .environment(\.colorScheme, .dark)
        .onAppear {
            let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
            if let firstWindow = scene?.windows.first {
                firstWindow.overrideUserInterfaceStyle = .dark
            }
        }
    }

Upvotes: 0

Sanjay Soni
Sanjay Soni

Reputation: 186

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

Upvotes: 0

WanderingPanda
WanderingPanda

Reputation: 93

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

Upvotes: 9

shanezzar
shanezzar

Reputation: 1170

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"
}

Upvotes: 5

Prasanth
Prasanth

Reputation: 676

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.

Upvotes: 2

multitudes
multitudes

Reputation: 3545

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

Upvotes: 4

Arturo
Arturo

Reputation: 4200

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

Upvotes: 8

Vicky Dungranee
Vicky Dungranee

Reputation: 23

#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
        })

    }
}

Upvotes: 2

ADB
ADB

Reputation: 719

@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)
    }
}

Upvotes: 8

hstdt
hstdt

Reputation: 6261

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

Upvotes: 9

Related Questions