diff --git a/README.md b/README.md index 5dcd4c8d..27c1b1b9 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,78 @@ 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 + button.model = ButtonVM { + $0.title = "Tap me" + $0.color = .accent + } +} +``` + +**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: diff --git a/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift b/Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift new file mode 100644 index 00000000..bc590c8e --- /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..88eb1f2a --- /dev/null +++ b/Sources/ComponentsKit/Helpers/UIKit/NSObject+ObserveThemeChange.swift @@ -0,0 +1,82 @@ +import Combine +import Foundation + +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 + /// button.model = ButtonVM { + /// $0.title = "Tap me" + /// $0.color = .accent + /// } + /// // ... + /// } + /// ``` + /// + /// ## 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" +} 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 + ) + } + } }