Reputation:
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
Reputation: 120002
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`
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
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")
}
}
}
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
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
Reputation: 1
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
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
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
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
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
Reputation: 3545
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.)
Upvotes: 4
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
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
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
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()
}
}
}
Upvotes: 9