'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()
}
}
}
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!
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 |