From 303cbf02a9ce580d71162538518ca7c8bc2ca1df Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 13 Mar 2025 14:11:18 +0100 Subject: [PATCH 1/5] send notification when the current theme changes --- Sources/ComponentsKit/Theme/Theme.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/ComponentsKit/Theme/Theme.swift b/Sources/ComponentsKit/Theme/Theme.swift index d65e0665..01aac7e4 100644 --- a/Sources/ComponentsKit/Theme/Theme.swift +++ b/Sources/ComponentsKit/Theme/Theme.swift @@ -17,9 +17,21 @@ public struct Theme: Initializable, Updatable, Equatable { public init() {} } -// MARK: - Theme + Shared +// MARK: - Theme + Current extension Theme { + /// A notification that is triggered when a theme changes. + public static let didChangeThemeNotification = Notification.Name("didChangeThemeNotification") + /// A current instance of `Theme` for global use. - public static var current: Self = .init() + /// + /// Triggers `Theme.didChangeThemeNotification` notification when the value changes. + public static var current = Self() { + didSet { + NotificationCenter.default.post( + name: Self.didChangeThemeNotification, + object: nil + ) + } + } } From a12d03f28cb3697b073c1dac1ebbe039ffbea222 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 13 Mar 2025 14:11:41 +0100 Subject: [PATCH 2/5] add helpers to observe theme changes --- .../Helpers/SwiftUI/ThemeChangeObserver.swift | 48 +++++++++++ .../UIKit/NSObject+ObserveThemeChange.swift | 80 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift create mode 100644 Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift diff --git a/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift new file mode 100644 index 00000000..df3bd5ae --- /dev/null +++ b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// A SwiftUI wrapper that listens for theme changes and automatically refreshes its content. +/// +/// `ThemeChangeObserver` ensures that its child views **rebuild** whenever the theme changes, +/// helping to apply updated theme styles dynamically. +/// +/// ## Usage +/// +/// Wrap your view inside `ThemeChangeObserver` to make it responsive to theme updates: +/// +/// ```swift +/// @main +/// struct Root: App { +/// var body: some Scene { +/// WindowGroup { +/// ThemeChangeObserver { +/// Content() +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Performance Considerations +/// +/// - This approach forces a **full re-evaluation** of the wrapped content, which ensures all theme-dependent +/// properties are updated. +/// - Use it **at a high level** in your SwiftUI hierarchy (e.g., wrapping entire screens) rather than for small components. +public struct ThemeChangeObserver: View { + @State private var themeId = UUID() + @ViewBuilder var content: () -> Content + + public init(content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + self.content() + .onReceive(NotificationCenter.default.publisher( + for: Theme.didChangeThemeNotification, + object: nil + )) { _ in + self.themeId = UUID() + } + .id(self.themeId) + } +} diff --git a/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift new file mode 100644 index 00000000..5691d49f --- /dev/null +++ b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift @@ -0,0 +1,80 @@ +import Foundation +import Combine + +extension NSObject { + /// Observes changes to the `.current` theme and updates dependent views. + /// + /// This method allows you to respond to theme changes by updating view properties that depend on the theme. + /// + /// You can invoke the ``observeThemeChange(_:)`` method a single time in the `viewDidLoad` + /// and update all the view elements: + /// + /// ```swift + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// + /// style() + /// + /// observeThemeChanges { [weak self] in + /// guard let self else { return } + /// + /// self.style() + /// } + /// } + /// + /// func style() { + /// view.backgroundColor = UniversalColor.background.uiColor + /// // ... + /// } + /// ``` + /// + /// > Note: There is no need to update components from the library as they observe the changes internally. + /// + /// ## Cancellation + /// + /// The method returns an ``AnyCancellable`` that can be used to cancel observation. For + /// example, if you only want to observe while a view controller is visible, you can start + /// observation in the `viewWillAppear` and then cancel observation in the `viewWillDisappear`: + /// + /// ```swift + /// var cancellable: AnyCancellable? + /// + /// func viewWillAppear() { + /// super.viewWillAppear() + /// cancellable = observeThemeChange { [weak self] in + /// // ... + /// } + /// } + /// func viewWillDisappear() { + /// super.viewWillDisappear() + /// cancellable?.cancel() + /// } + /// ``` + /// + /// - Parameter apply: A closure that will be called whenever the `.current` theme changes. + /// This should contain logic to update theme-dependent views. + /// - Returns: An `AnyCancellable` instance that can be used to stop observing the theme changes when needed. + @discardableResult + public func observeThemeChange(_ apply: @escaping () -> Void) -> AnyCancellable { + let cancellable = NotificationCenter.default.publisher( + for: Theme.didChangeThemeNotification + ) + .receive(on: DispatchQueue.main) + .sink { _ in + apply() + } + self.cancellables.append(cancellable) + return cancellable + } + + fileprivate var cancellables: [Any] { + get { + objc_getAssociatedObject(self, Self.cancellablesKey) as? [Any] ?? [] + } + set { + objc_setAssociatedObject(self, Self.cancellablesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private static let cancellablesKey = "themeChangeObserverCancellables" +} From b31f71d0b7fc36dba21b0626c34b16bd4da7e915 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 13 Mar 2025 14:28:48 +0100 Subject: [PATCH 3/5] Update README.md --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/README.md b/README.md index 5dcd4c8d..39014212 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,74 @@ When the user switches the theme, apply it by assigning it to the `current` inst Theme.current = halloweenTheme ``` +**Handling Theme Changes** + +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. + +**SwiftUI** + +For SwiftUI apps, you can use `ThemeChangeObserver` to automatically refresh views when the theme updates. + +```swift +@main +struct Root: App { + var body: some Scene { + WindowGroup { + ThemeChangeObserver { + Content() + } + } + } +} +``` + +We recommend using this helper in the root of your app to redraw everything at once. + +**UIKit** + +For UIKit apps, use the `observeThemeChange(_:)` method to update elements that depend on the properties from the library. + +```swift +override func viewDidLoad() { + super.viewDidLoad() + + style() + + observeThemeChange { [weak self] in + guard let self else { return } + self.style() + } +} + +func style() { + view.backgroundColor = UniversalColor.background.uiColor +} +``` + +**Manually Handling Theme Changes** + +If you are not using the built-in helpers, you can listen for theme change notifications and manually update the UI: + +```swift +NotificationCenter.default.addObserver( + self, + selector: #selector(handleThemeChange), + name: Theme.didChangeThemeNotification, + object: nil +) + +@objc private func handleThemeChange() { + view.backgroundColor = UniversalColor.background.uiColor +} +``` + +Don't forget to remove the observer when the view is deallocated: +```swift +deinit { + NotificationCenter.default.removeObserver(self, name: Theme.didChangeThemeNotification, object: nil) +} +``` + **Extend Colors** All colors from the theme can be used within the app. For example: From 8e5160aa7537d159fbd969a2bf71b294ee519a01 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 13 Mar 2025 15:29:10 +0100 Subject: [PATCH 4/5] update docs --- README.md | 4 ++++ .../Helpers/UIKit/NSObject+ObserveThemeChange.swift | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 39014212..27c1b1b9 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,10 @@ override func viewDidLoad() { func style() { view.backgroundColor = UniversalColor.background.uiColor + button.model = ButtonVM { + $0.title = "Tap me" + $0.color = .accent + } } ``` diff --git a/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift index 5691d49f..a0876878 100644 --- a/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift +++ b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift @@ -24,12 +24,14 @@ extension NSObject { /// /// func style() { /// view.backgroundColor = UniversalColor.background.uiColor + /// button.model = ButtonVM { + /// $0.title = "Tap me" + /// $0.color = .accent + /// } /// // ... /// } /// ``` /// - /// > Note: There is no need to update components from the library as they observe the changes internally. - /// /// ## Cancellation /// /// The method returns an ``AnyCancellable`` that can be used to cancel observation. For From 8eeb592243e88e2876f66ea2da741c51c3d5a299 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 13 Mar 2025 16:57:17 +0100 Subject: [PATCH 5/5] run swiftlint --- .../ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift | 4 ++-- .../Helpers/UIKit/NSObject+ObserveThemeChange.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift index df3bd5ae..bc590c8e 100644 --- a/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift +++ b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift @@ -30,11 +30,11 @@ import SwiftUI public struct ThemeChangeObserver: View { @State private var themeId = UUID() @ViewBuilder var content: () -> Content - + public init(content: @escaping () -> Content) { self.content = content } - + public var body: some View { self.content() .onReceive(NotificationCenter.default.publisher( diff --git a/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift index a0876878..88eb1f2a 100644 --- a/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift +++ b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift @@ -1,5 +1,5 @@ -import Foundation import Combine +import Foundation extension NSObject { /// Observes changes to the `.current` theme and updates dependent views. @@ -68,7 +68,7 @@ extension NSObject { self.cancellables.append(cancellable) return cancellable } - + fileprivate var cancellables: [Any] { get { objc_getAssociatedObject(self, Self.cancellablesKey) as? [Any] ?? [] @@ -77,6 +77,6 @@ extension NSObject { objc_setAssociatedObject(self, Self.cancellablesKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } - + private static let cancellablesKey = "themeChangeObserverCancellables" }