Skip to content

Commit 1810098

Browse files
Merge pull request #76 from componentskit/theme-observation
Theme change notification + observation
2 parents eb7a1c5 + 8eeb592 commit 1810098

File tree

4 files changed

+216
-2
lines changed

4 files changed

+216
-2
lines changed

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,78 @@ When the user switches the theme, apply it by assigning it to the `current` inst
123123
Theme.current = halloweenTheme
124124
```
125125

126+
**Handling Theme Changes**
127+
128+
When changing themes dynamically, you may need to **update the UI** to reflect the new theme. Below are approaches for handling this in different environments.
129+
130+
**SwiftUI**
131+
132+
For SwiftUI apps, you can use `ThemeChangeObserver` to automatically refresh views when the theme updates.
133+
134+
```swift
135+
@main
136+
struct Root: App {
137+
var body: some Scene {
138+
WindowGroup {
139+
ThemeChangeObserver {
140+
Content()
141+
}
142+
}
143+
}
144+
}
145+
```
146+
147+
We recommend using this helper in the root of your app to redraw everything at once.
148+
149+
**UIKit**
150+
151+
For UIKit apps, use the `observeThemeChange(_:)` method to update elements that depend on the properties from the library.
152+
153+
```swift
154+
override func viewDidLoad() {
155+
super.viewDidLoad()
156+
157+
style()
158+
159+
observeThemeChange { [weak self] in
160+
guard let self else { return }
161+
self.style()
162+
}
163+
}
164+
165+
func style() {
166+
view.backgroundColor = UniversalColor.background.uiColor
167+
button.model = ButtonVM {
168+
$0.title = "Tap me"
169+
$0.color = .accent
170+
}
171+
}
172+
```
173+
174+
**Manually Handling Theme Changes**
175+
176+
If you are not using the built-in helpers, you can listen for theme change notifications and manually update the UI:
177+
178+
```swift
179+
NotificationCenter.default.addObserver(
180+
self,
181+
selector: #selector(handleThemeChange),
182+
name: Theme.didChangeThemeNotification,
183+
object: nil
184+
)
185+
186+
@objc private func handleThemeChange() {
187+
view.backgroundColor = UniversalColor.background.uiColor
188+
}
189+
```
190+
191+
Don't forget to remove the observer when the view is deallocated:
192+
```swift
193+
deinit {
194+
NotificationCenter.default.removeObserver(self, name: Theme.didChangeThemeNotification, object: nil)
195+
}
196+
```
197+
126198
**Extend Colors**
127199

128200
All colors from the theme can be used within the app. For example:
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import SwiftUI
2+
3+
/// A SwiftUI wrapper that listens for theme changes and automatically refreshes its content.
4+
///
5+
/// `ThemeChangeObserver` ensures that its child views **rebuild** whenever the theme changes,
6+
/// helping to apply updated theme styles dynamically.
7+
///
8+
/// ## Usage
9+
///
10+
/// Wrap your view inside `ThemeChangeObserver` to make it responsive to theme updates:
11+
///
12+
/// ```swift
13+
/// @main
14+
/// struct Root: App {
15+
/// var body: some Scene {
16+
/// WindowGroup {
17+
/// ThemeChangeObserver {
18+
/// Content()
19+
/// }
20+
/// }
21+
/// }
22+
/// }
23+
/// ```
24+
///
25+
/// ## Performance Considerations
26+
///
27+
/// - This approach forces a **full re-evaluation** of the wrapped content, which ensures all theme-dependent
28+
/// properties are updated.
29+
/// - Use it **at a high level** in your SwiftUI hierarchy (e.g., wrapping entire screens) rather than for small components.
30+
public struct ThemeChangeObserver<Content: View>: View {
31+
@State private var themeId = UUID()
32+
@ViewBuilder var content: () -> Content
33+
34+
public init(content: @escaping () -> Content) {
35+
self.content = content
36+
}
37+
38+
public var body: some View {
39+
self.content()
40+
.onReceive(NotificationCenter.default.publisher(
41+
for: Theme.didChangeThemeNotification,
42+
object: nil
43+
)) { _ in
44+
self.themeId = UUID()
45+
}
46+
.id(self.themeId)
47+
}
48+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Combine
2+
import Foundation
3+
4+
extension NSObject {
5+
/// Observes changes to the `.current` theme and updates dependent views.
6+
///
7+
/// This method allows you to respond to theme changes by updating view properties that depend on the theme.
8+
///
9+
/// You can invoke the ``observeThemeChange(_:)`` method a single time in the `viewDidLoad`
10+
/// and update all the view elements:
11+
///
12+
/// ```swift
13+
/// override func viewDidLoad() {
14+
/// super.viewDidLoad()
15+
///
16+
/// style()
17+
///
18+
/// observeThemeChanges { [weak self] in
19+
/// guard let self else { return }
20+
///
21+
/// self.style()
22+
/// }
23+
/// }
24+
///
25+
/// func style() {
26+
/// view.backgroundColor = UniversalColor.background.uiColor
27+
/// button.model = ButtonVM {
28+
/// $0.title = "Tap me"
29+
/// $0.color = .accent
30+
/// }
31+
/// // ...
32+
/// }
33+
/// ```
34+
///
35+
/// ## Cancellation
36+
///
37+
/// The method returns an ``AnyCancellable`` that can be used to cancel observation. For
38+
/// example, if you only want to observe while a view controller is visible, you can start
39+
/// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`:
40+
///
41+
/// ```swift
42+
/// var cancellable: AnyCancellable?
43+
///
44+
/// func viewWillAppear() {
45+
/// super.viewWillAppear()
46+
/// cancellable = observeThemeChange { [weak self] in
47+
/// // ...
48+
/// }
49+
/// }
50+
/// func viewWillDisappear() {
51+
/// super.viewWillDisappear()
52+
/// cancellable?.cancel()
53+
/// }
54+
/// ```
55+
///
56+
/// - Parameter apply: A closure that will be called whenever the `.current` theme changes.
57+
/// This should contain logic to update theme-dependent views.
58+
/// - Returns: An `AnyCancellable` instance that can be used to stop observing the theme changes when needed.
59+
@discardableResult
60+
public func observeThemeChange(_ apply: @escaping () -> Void) -> AnyCancellable {
61+
let cancellable = NotificationCenter.default.publisher(
62+
for: Theme.didChangeThemeNotification
63+
)
64+
.receive(on: DispatchQueue.main)
65+
.sink { _ in
66+
apply()
67+
}
68+
self.cancellables.append(cancellable)
69+
return cancellable
70+
}
71+
72+
fileprivate var cancellables: [Any] {
73+
get {
74+
objc_getAssociatedObject(self, Self.cancellablesKey) as? [Any] ?? []
75+
}
76+
set {
77+
objc_setAssociatedObject(self, Self.cancellablesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
78+
}
79+
}
80+
81+
private static let cancellablesKey = "themeChangeObserverCancellables"
82+
}

Sources/ComponentsKit/Theme/Theme.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,21 @@ public struct Theme: Initializable, Updatable, Equatable {
1717
public init() {}
1818
}
1919

20-
// MARK: - Theme + Shared
20+
// MARK: - Theme + Current
2121

2222
extension Theme {
23+
/// A notification that is triggered when a theme changes.
24+
public static let didChangeThemeNotification = Notification.Name("didChangeThemeNotification")
25+
2326
/// A current instance of `Theme` for global use.
24-
public static var current: Self = .init()
27+
///
28+
/// Triggers `Theme.didChangeThemeNotification` notification when the value changes.
29+
public static var current = Self() {
30+
didSet {
31+
NotificationCenter.default.post(
32+
name: Self.didChangeThemeNotification,
33+
object: nil
34+
)
35+
}
36+
}
2537
}

0 commit comments

Comments
 (0)