Skip to content

Commit 42a1861

Browse files
authored
Apply system appearance via UIKit/AppKit (#1200)
SwiftUI implementation is fragile, system appearance with `preferredColorScheme(nil)` is buggy. Instead, rely on: - iOS: UIKit → UIWindow.overrideUserInterfaceStyle - macOS: AppKit → NSApp.appearance Amends #1077 Fixes #1199
1 parent 6379566 commit 42a1861

File tree

8 files changed

+83
-42
lines changed

8 files changed

+83
-42
lines changed

Packages/App/Sources/UILibrary/Business/AppContext.swift

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import UIAccessibility
3434
public final class AppContext: ObservableObject, Sendable {
3535
public let apiManager: APIManager
3636

37+
public let appearanceManager: AppearanceManager
38+
3739
public let iapManager: IAPManager
3840

3941
public let migrationManager: MigrationManager
@@ -65,6 +67,7 @@ public final class AppContext: ObservableObject, Sendable {
6567
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
6668
) {
6769
self.apiManager = apiManager
70+
appearanceManager = AppearanceManager()
6871
self.iapManager = iapManager
6972
self.migrationManager = migrationManager
7073
self.preferencesManager = preferencesManager
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// AppearanceManager.swift
3+
// Passepartout
4+
//
5+
// Created by Davide De Rosa on 2/18/25.
6+
// Copyright (c) 2025 Davide De Rosa. All rights reserved.
7+
//
8+
// https://github.com/passepartoutvpn
9+
//
10+
// This file is part of Passepartout.
11+
//
12+
// Passepartout is free software: you can redistribute it and/or modify
13+
// it under the terms of the GNU General Public License as published by
14+
// the Free Software Foundation, either version 3 of the License, or
15+
// (at your option) any later version.
16+
//
17+
// Passepartout is distributed in the hope that it will be useful,
18+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
// GNU General Public License for more details.
21+
//
22+
// You should have received a copy of the GNU General Public License
23+
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
24+
//
25+
26+
import Foundation
27+
import SwiftUI
28+
29+
@MainActor
30+
public final class AppearanceManager: ObservableObject {
31+
private let defaults: UserDefaults
32+
33+
@Published
34+
public var systemAppearance: SystemAppearance? {
35+
didSet {
36+
defaults.set(systemAppearance?.rawValue, forKey: UIPreference.systemAppearance.key)
37+
apply()
38+
}
39+
}
40+
41+
public init(defaults: UserDefaults = .standard) {
42+
self.defaults = defaults
43+
systemAppearance = defaults.string(forKey: UIPreference.systemAppearance.key)
44+
.flatMap {
45+
SystemAppearance(rawValue: $0)
46+
}
47+
}
48+
}
49+
50+
private extension AppearanceManager {
51+
func apply() {
52+
#if os(iOS)
53+
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
54+
let window = scene.keyWindow else {
55+
return
56+
}
57+
switch systemAppearance {
58+
case .light:
59+
window.overrideUserInterfaceStyle = .light
60+
case .dark:
61+
window.overrideUserInterfaceStyle = .dark
62+
case .none:
63+
window.overrideUserInterfaceStyle = .unspecified
64+
}
65+
#elseif os(macOS)
66+
switch systemAppearance {
67+
case .light:
68+
NSApp.appearance = NSAppearance(named: .vibrantLight)
69+
case .dark:
70+
NSApp.appearance = NSAppearance(named: .vibrantDark)
71+
case .none:
72+
NSApp.appearance = nil
73+
}
74+
#endif
75+
}
76+
}

Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift

-11
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,9 @@
2424
//
2525

2626
import Foundation
27-
import SwiftUI
2827

2928
public enum SystemAppearance: String, RawRepresentable {
3029
case light
3130

3231
case dark
3332
}
34-
35-
extension Optional where Wrapped == SystemAppearance {
36-
public var colorScheme: ColorScheme? {
37-
switch self {
38-
case .none: return nil
39-
case .light: return .light
40-
case .dark: return .dark
41-
}
42-
}
43-
}

Packages/App/Sources/UILibrary/Extensions/View+Environment.swift

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ extension View {
3030
public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
3131
environmentObject(theme)
3232
.environmentObject(context.apiManager)
33+
.environmentObject(context.appearanceManager)
3334
.environmentObject(context.iapManager)
3435
.environmentObject(context.migrationManager)
3536
.environmentObject(context.preferencesManager)

Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift

-25
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,6 @@ public enum ThemeModalSize: Hashable {
6565
}
6666

6767
extension View {
68-
public func themeAppearance(systemScheme: ColorScheme) -> some View {
69-
modifier(ThemeAppearanceModifier(systemScheme: systemScheme))
70-
}
71-
7268
public func themeModal<Content>(
7369
isPresented: Binding<Bool>,
7470
options: ThemeModalOptions? = nil,
@@ -342,9 +338,6 @@ struct ThemeBooleanModalModifier<Modal>: ViewModifier where Modal: View {
342338
@Environment(\.colorScheme)
343339
private var colorScheme
344340

345-
@AppStorage(UIPreference.systemAppearance.key)
346-
private var systemAppearance: SystemAppearance?
347-
348341
@Binding
349342
var isPresented: Bool
350343

@@ -368,7 +361,6 @@ struct ThemeBooleanModalModifier<Modal>: ViewModifier where Modal: View {
368361
#endif
369362
.interactiveDismissDisabled(!options.isInteractive)
370363
.themeLockScreen()
371-
.themeAppearance(systemScheme: colorScheme)
372364
}
373365
}
374366
}
@@ -381,9 +373,6 @@ struct ThemeItemModalModifier<Modal, T>: ViewModifier where Modal: View, T: Iden
381373
@Environment(\.colorScheme)
382374
private var colorScheme
383375

384-
@AppStorage(UIPreference.systemAppearance.key)
385-
private var systemAppearance: SystemAppearance?
386-
387376
@Binding
388377
var item: T?
389378

@@ -407,7 +396,6 @@ struct ThemeItemModalModifier<Modal, T>: ViewModifier where Modal: View, T: Iden
407396
#endif
408397
.interactiveDismissDisabled(!options.isInteractive)
409398
.themeLockScreen()
410-
.themeAppearance(systemScheme: colorScheme)
411399
}
412400
}
413401
}
@@ -472,19 +460,6 @@ struct ThemeNavigationStackModifier: ViewModifier {
472460

473461
// MARK: - Content modifiers
474462

475-
struct ThemeAppearanceModifier: ViewModifier {
476-
477-
@AppStorage(UIPreference.systemAppearance.key)
478-
private var systemAppearance: SystemAppearance?
479-
480-
let systemScheme: ColorScheme
481-
482-
func body(content: Content) -> some View {
483-
content
484-
.preferredColorScheme(systemAppearance.colorScheme ?? systemScheme)
485-
}
486-
}
487-
488463
struct ThemeManualInputModifier: ViewModifier {
489464
}
490465

Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import SwiftUI
3232

3333
public struct PreferencesGroup: View {
3434

35-
@AppStorage(UIPreference.systemAppearance.key)
36-
private var systemAppearance: SystemAppearance?
35+
@EnvironmentObject
36+
private var appearanceManager: AppearanceManager
3737

3838
#if os(iOS)
3939
@AppStorage(UIPreference.locksInBackground.key)
@@ -80,7 +80,7 @@ private extension PreferencesGroup {
8080
]
8181

8282
var systemAppearancePicker: some View {
83-
Picker(Strings.Views.Preferences.systemAppearance, selection: $systemAppearance) {
83+
Picker(Strings.Views.Preferences.systemAppearance, selection: $appearanceManager.systemAppearance) {
8484
ForEach(Self.systemAppearances, id: \.self) {
8585
Text($0.localizedDescription)
8686
}

Passepartout/App/Platforms/App+iOS.swift

-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ extension PassepartoutApp {
5252
.withEnvironment(from: context, theme: theme)
5353
.environment(\.isUITesting, AppCommandLine.contains(.uiTesting))
5454
.tint(.accentColor)
55-
.themeAppearance(systemScheme: colorScheme)
5655
}
5756
}
5857
}

Passepartout/App/Platforms/App+macOS.swift

-2
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ extension PassepartoutApp {
6464
.withEnvironment(from: context, theme: theme)
6565
.environment(\.isUITesting, AppCommandLine.contains(.uiTesting))
6666
.frame(minWidth: 600, minHeight: 400)
67-
.themeAppearance(systemScheme: colorScheme)
6867
}
6968
.defaultSize(width: 600, height: 400)
7069

@@ -73,7 +72,6 @@ extension PassepartoutApp {
7372
.withEnvironment(from: context, theme: theme)
7473
.environmentObject(settings)
7574
.environment(\.isUITesting, AppCommandLine.contains(.uiTesting))
76-
.themeAppearance(systemScheme: colorScheme)
7775
}
7876
.defaultSize(width: 500, height: 400)
7977

0 commit comments

Comments
 (0)