SwiftUI:如何让用户使用选项 "light"、"dark" 和 "system" 实时设置应用程序外观?

SwiftUI: How to let the user set the app appearance in real-time w/ options "light", "dark", and "system"?

我目前正在尝试在一个应用程序中实施一个解决方案,用户应该能够使用以下选项实时切换应用程序的外观:

已证明使用 .preferredColorScheme() 设置浅色和深色配色方案非常简单且响应迅速;但是,我还没有为“系统”选项找到任何令人满意的解决方案。

我目前的做法如下:

  1. 在 ContentView 中使用 @Environment(.colorScheme) 获取设备配色方案
  2. 创建自定义视图修改器以在任何视图上应用相应的配色方案
  3. 在“MainView”(这是应用程序的真实内容应该存在的地方)上使用修饰符在配色方案之间切换

我的想法是将 MainView 嵌入到 ContentView 中,这样 @Environment(.colorScheme) 就不会被应用于 MainView 的任何 colorScheme 所干扰。

然而,它仍然没有按预期工作:当设置明暗外观时,一切都按预期工作。但是,当从 light/dark 切换到“系统”时,外观的变化只有在重新启动应用程序后才能看到。然而,预期的行为是外观会立即发生变化。

对此有什么想法吗?

以下是相关的代码片段:

主视图

import SwiftUI

struct MainView: View {

    @AppStorage("selectedAppearance") var selectedAppearance = 0

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                selectedAppearance = 1
            }) {
                Text("Light")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 2
            }) {
                Text("Dark")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 0
            }) {
                Text("System")
            }
            Spacer()
        }
    }
}

ContentView

import SwiftUI

struct ContentView: View {

    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        MainView()
            .modifier(ColorSchemeModifier(colorScheme: colorScheme))
    }
}

“实用工具”

import Foundation
import SwiftUI

struct ColorSchemeModifier: ViewModifier {

    @AppStorage("selectedAppearance") var selectedAppearance: Int = 0
    var colorScheme: ColorScheme

    func body(content: Content) -> some View {
        if selectedAppearance == 2 {
            return content.preferredColorScheme(.dark)
        } else if selectedAppearance == 1 {
            return content.preferredColorScheme(.light)
        } else {
            return content.preferredColorScheme(colorScheme)
        }
    }
}

我认为这在 SwiftUI 中是不可能的。

根据文档,使用 preferredColorScheme 将影响整个层次结构,从封闭的演示文稿开始:

The color scheme applies to the nearest enclosing presentation, such as a popover or window. Views may read the color scheme using the colorScheme environment value.

因此,这会影响您的整个视图层次结构,这意味着您不能仅针对某些视图覆盖它。相反,它“冒泡”并更改整个 window(或表示上下文)的配色方案。

但是,您可以从 UIKit 更改它,但使用 overrideUserInterfaceStyle,它也是支持 .dark.light.unspecified。未指定为系统设置。

这如何转化为您的应用程序?好吧,我认为您根本不需要视图修饰符。相反,您需要监视 UserDefaults 以了解 selectedAppearance 键的更改,并通过将 overrideUserInterfaceStyle 更改为适当的值来对其做出反应。

因此,根据您的应用程序的其余部分的结构,在您的 AppDelegateSceneDelegate 中(您也可以使用任何其他对象,但它需要访问所提供的UIWindow,因此在重构时请记住这一点),您可以将侦听器挂接到 UserDefaults 以侦听更改。像这样:

UserDefaults.standard.addObserver(self, forKeyPath: "selectedAppearance", options: [.new], context: nil)

然后覆盖 observeValue 以监视对默认键的更改:

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard
        let userDefaults = object as? UserDefaults,
        keyPath == "selectedAppearance"
    else {
        return
    }

    switch userDefaults.integer(forKey: "selectedAppearance") {
    case 2:
        window?.overrideUserInterfaceStyle = .dark
    case 1:
        window?.overrideUserInterfaceStyle = .light
    default:
        window?.overrideUserInterfaceStyle = .unspecified
    }
}

一些其他需要改进的地方(脱离了这个问题的上下文,但我认为值得一提):

  • 我会使用 enum 而不是 integer 作为外观的有效值。
  • 考虑到 UIKit 提供了一个仅包含您想要的 3 个值的枚举,我会重用它,因此您不需要 observeValue 调用中的 switch

适用于

.preferredColorScheme(selectedAppearance == 1 ? .light : selectedAppearance == 2 ? .dark : nil)

iOS 14.5+: 这就对了。他们得到了“nil”来重置您的 preferredColorScheme。

iOS14:

唯一的问题是您必须重新加载应用程序才能使系统正常工作。我可以想到一个解决方法 - 当用户选择“系统”时,您首先确定当前的 colorScheme 是什么,将其更改为它然后才将 selectedAppearance 更改为 0.

  • 用户会立即看到结果,下次启动时将是系统主题。

编辑:

工作思路如下:

struct MainView: View {
    @Binding var colorScheme: ColorScheme
    @AppStorage("selectedAppearance") var selectedAppearance = 0

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                selectedAppearance = 1
            }) {
                Text("Light")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 2
            }) {
                Text("Dark")
            }
            Spacer()
            Button(action: {
                if colorScheme == .light {
                    selectedAppearance = 1
                }
                else {
                    selectedAppearance = 2
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                    selectedAppearance = 0
                }
            }) {
                Text("System")
            }
            Spacer()
        }
    }
}

struct ContentView: View {
    @AppStorage("selectedAppearance") var selectedAppearance = 0
    @Environment(\.colorScheme) var colorScheme
    @State var onAppearColorScheme: ColorScheme = .light //only for iOS<14.5

    var body: some View {
        MainView(colorScheme: $onAppearColorScheme)
            .onAppear { onAppearColorScheme = colorScheme } //only for iOS<14.5
            .preferredColorScheme(selectedAppearance == 1 ? .light : selectedAppearance == 2 ? .dark : nil) }
}

有一个小问题 -> 它仅作为 onAppear 而不是 onChange 起作用(不知道为什么)

我最终使用了以下解决方案,它是对@pgb 给出的答案的轻微改编:

内容视图:

struct ContentView: View {

    @AppStorage("selectedAppearance") var selectedAppearance = 0
    var utilities = Utilities()

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                selectedAppearance = 1
            }) {
                Text("Light")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 2
            }) {
                Text("Dark")
            }
            Spacer()
            Button(action: {
                selectedAppearance = 0
            }) {
                Text("System")
            }
            Spacer()
        }
        .onChange(of: selectedAppearance, perform: { value in
            utilities.overrideDisplayMode()
        })
    }
}

帮手class

class Utilities {

    @AppStorage("selectedAppearance") var selectedAppearance = 0
    var userInterfaceStyle: ColorScheme? = .dark

    func overrideDisplayMode() {
        var userInterfaceStyle: UIUserInterfaceStyle

        if selectedAppearance == 2 {
            userInterfaceStyle = .dark
        } else if selectedAppearance == 1 {
            userInterfaceStyle = .light
        } else {
            userInterfaceStyle = .unspecified
        }
    
        UIApplication.shared.windows.first?.overrideUserInterfaceStyle = userInterfaceStyle
    }
}

Swift 中的系统级实现示例UI 以及 SceneDelegate 生命周期

我已经回答了这个问题 ,但是我提供了系统选项的完整实现,这在另一个 post 中没有被要求,这很适合这个 post ].它像宣传的那样工作(到目前为止)。

我必须进行大量研究并解决很多问题。我还没有时间考虑使用 iOS14 的 @main 而不是 SceneDelegate,但希望将来这样做。
我还想在 Form 中添加一个 NavigationLink,就像在 GitHub iOS 应用程序中一样!它看起来比选择器更好。

这是 GitHub 存储库的 link。该示例具有浅色、深色和自动选择器,可更改整个应用程序的设置。
我付出了额外的努力使其可本地化!

GitHub repo

我需要访问 SceneDelegate 并且我使用与 Mustapha 相同的代码并进行了少量添加,当应用程序启动时我需要读取存储在 UserDefaults 或 @AppStorage 等中的设置
因此我在启动时再次更新 UI:

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

函数 updateUserInterfaceStyle() 将在 SceneDelegate 中。 我在这里使用 UserDefaults 的扩展,使其与 iOS13 兼容(感谢 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
            }
        }
    }

这与apple documentation for UIUserInterfaceStyle

一致

使用选择器意味着我需要迭代我的三个案例,所以我制作了一个符合可识别且类型为 LocalizedStringKey 的枚举用于本地化:

// check LocalizedStringKey instead of string for localisation!
enum Appearance: LocalizedStringKey, CaseIterable, Identifiable {
    case light
    case dark
    case automatic

    var id: String { UUID().uuidString }
}

这是选择器的完整代码:


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

代码 onAppear 用于在用户进入该设置视图时将滚轮设置为正确的值。每次移动轮子时,通过 .onChange 修饰符,用户默认值都会更新,应用程序会通过引用 SceneDelegate.

更改所有视图的设置

(如果有兴趣,GH 回购中有 gif。)