Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions Sources/ComponentsKit/Helpers/SwiftUI/ThemeChangeObserver.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>: 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)
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
16 changes: 14 additions & 2 deletions Sources/ComponentsKit/Theme/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}