From c7211f227dd559e761aca7447bc2ea0aba871053 Mon Sep 17 00:00:00 2001 From: ribilynn Date: Thu, 9 Feb 2023 18:20:09 +0900 Subject: [PATCH 1/3] Add PublishedDefault --- Sources/Defaults/PublishedDefault.swift | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Sources/Defaults/PublishedDefault.swift diff --git a/Sources/Defaults/PublishedDefault.swift b/Sources/Defaults/PublishedDefault.swift new file mode 100644 index 0000000..a3d0502 --- /dev/null +++ b/Sources/Defaults/PublishedDefault.swift @@ -0,0 +1,90 @@ +import Combine + +@propertyWrapper +public struct PublishedDefault { + private let key: Defaults.Key + private var publisher: AnyPublisher? + + private var value: Value { + get { Defaults[key] } + set { Defaults[key] = newValue } + } + + /** + Get/set a `Defaults` item and also trigger `objectWillChange` in the `ObservableObject` when the value changes. + + - Important: Like `@Published`, `@PublishedDefault` does not observe the change of the corresponding value. Only changes made via this `@PublishedDefault` will trigger `ObservableObject`'s `objectWillChange`. + + ```swift + extension Defaults.Keys { + static let opacity = Key("opacity", default: 1) + } + + class ViewModel: ObservableObject { + @PublishedDefault(.opacity) var opacity + } + ``` + */ + public init(_ key: Defaults.Key) { + self.key = key + } + + /** + The getter/setter in a `ObservableObject`. + */ + public static subscript( + _enclosingInstance instance: Object, + wrapped _: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value { + get { + instance[keyPath: storageKeyPath].value + } + set { + if let observable = instance as? any ObservableObject, + let objectWillChange = observable.objectWillChange as any Publisher as? ObservableObjectPublisher + { + objectWillChange.send() + } + instance[keyPath: storageKeyPath].value = newValue + } + } + + @available(*, unavailable, message: "@Published is only available on properties of AnyObject") + public var wrappedValue: Value { + get { value } + set { value = newValue } + } + + public var projectedValue: some Publisher { + mutating get { + if publisher == nil { + publisher = Defaults.publisher(key, options: [.initial]) + .map(\.newValue) + .eraseToAnyPublisher() + } + return publisher! + } + } + + /** + Reset the key back to its default value. + + ```swift + extension Defaults.Keys { + static let opacity = Key("opacity", default: 1) + } + + class ViewModel: ObservableObject { + @PublishedDefault(.opacity) var opacity + + func reset() { + _opacity.reset() + } + } + ``` + */ + public func reset() { + key.reset() + } +} From 66e083fc7db1f0fa0c0c41fcdd8f497b8863783a Mon Sep 17 00:00:00 2001 From: ribilynn Date: Thu, 9 Mar 2023 18:21:20 +0900 Subject: [PATCH 2/3] Add observeDefaults to ObservableObject --- Sources/Defaults/PublishedDefault.swift | 104 ++++++++++++++++-------- 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/Sources/Defaults/PublishedDefault.swift b/Sources/Defaults/PublishedDefault.swift index a3d0502..ec8be4f 100644 --- a/Sources/Defaults/PublishedDefault.swift +++ b/Sources/Defaults/PublishedDefault.swift @@ -1,47 +1,57 @@ import Combine +/** +PublishedDefault will trigger `objectWillChange` in the `ObservableObject` when the value is changed. + +- Important: By default, `PublishedDefault` does not observe changes made to the corresponding value outside of the `PublishedDefault`. +Only changes made via this `@PublishedDefault` will trigger `objectWillChange`. + +To ensure that changes made to Defaults elsewhere also trigger `objectWillChange`, you need to call `observeDefaults` once in the `ObservableObject`. + +```swift +extension Defaults.Keys { + static let opacity = Key("opacity", default: 1) +} + +class ViewModel: ObservableObject { + @PublishedDefault(.opacity) var opacity + + init() { + observeDefaults() + } +} +``` +*/ @propertyWrapper -public struct PublishedDefault { +public class PublishedDefault { private let key: Defaults.Key - private var publisher: AnyPublisher? - + private var defaultPublisher: AnyPublisher? + private var objectSubscription: AnyCancellable? + private var value: Value { get { Defaults[key] } set { Defaults[key] = newValue } } - - /** - Get/set a `Defaults` item and also trigger `objectWillChange` in the `ObservableObject` when the value changes. - - - Important: Like `@Published`, `@PublishedDefault` does not observe the change of the corresponding value. Only changes made via this `@PublishedDefault` will trigger `ObservableObject`'s `objectWillChange`. - - ```swift - extension Defaults.Keys { - static let opacity = Key("opacity", default: 1) - } - - class ViewModel: ObservableObject { - @PublishedDefault(.opacity) var opacity - } - ``` - */ + public init(_ key: Defaults.Key) { self.key = key } - + /** The getter/setter in a `ObservableObject`. */ public static subscript( _enclosingInstance instance: Object, wrapped _: ReferenceWritableKeyPath, - storage storageKeyPath: ReferenceWritableKeyPath + storage storageKeyPath: ReferenceWritableKeyPath ) -> Value { get { instance[keyPath: storageKeyPath].value } set { - if let observable = instance as? any ObservableObject, + // Skip if subscrition is already acting + if instance[keyPath: storageKeyPath].objectSubscription == nil, + let observable = instance as? any ObservableObject, let objectWillChange = observable.objectWillChange as any Publisher as? ObservableObjectPublisher { objectWillChange.send() @@ -49,35 +59,33 @@ public struct PublishedDefault { instance[keyPath: storageKeyPath].value = newValue } } - - @available(*, unavailable, message: "@Published is only available on properties of AnyObject") + + @available(*, unavailable, message: "@PublishedDefault is only available on properties of AnyObject") public var wrappedValue: Value { get { value } set { value = newValue } } - + public var projectedValue: some Publisher { - mutating get { - if publisher == nil { - publisher = Defaults.publisher(key, options: [.initial]) - .map(\.newValue) - .eraseToAnyPublisher() - } - return publisher! + if defaultPublisher == nil { + defaultPublisher = Defaults.publisher(key, options: [.initial]) + .map(\.newValue) + .eraseToAnyPublisher() } + return defaultPublisher! } /** Reset the key back to its default value. - + ```swift extension Defaults.Keys { static let opacity = Key("opacity", default: 1) } - + class ViewModel: ObservableObject { @PublishedDefault(.opacity) var opacity - + func reset() { _opacity.reset() } @@ -88,3 +96,29 @@ public struct PublishedDefault { key.reset() } } + +// A type-erase protocol used to subscribe Defaults on ObservableObject. +protocol _PublishedDefaultProtocol { + func subscribe(to publisher: ObservableObjectPublisher) +} + +extension PublishedDefault: _PublishedDefaultProtocol { + func subscribe(to publisher: ObservableObjectPublisher) { + objectSubscription = projectedValue + .dropFirst() + .sink { _ in + publisher.send() + } + } +} + +public extension ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher { + /** + Begin observing the Default value so that changes made to Defaults outside of the ObservableObject will also trigger objectWillChange. + */ + func observeDefaults() { + for (_, property) in Mirror(reflecting: self).children { + (property as? _PublishedDefaultProtocol)?.subscribe(to: objectWillChange) + } + } +} From a80432e90bdfa389e8f79c02cf2b35280370c6db Mon Sep 17 00:00:00 2001 From: ribilynn Date: Thu, 9 Mar 2023 18:21:37 +0900 Subject: [PATCH 3/3] Add PublishedDefaultTests --- .../DefaultsPublishedDefaultTests.swift | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 Tests/DefaultsTests/DefaultsPublishedDefaultTests.swift diff --git a/Tests/DefaultsTests/DefaultsPublishedDefaultTests.swift b/Tests/DefaultsTests/DefaultsPublishedDefaultTests.swift new file mode 100644 index 0000000..136391c --- /dev/null +++ b/Tests/DefaultsTests/DefaultsPublishedDefaultTests.swift @@ -0,0 +1,83 @@ +import XCTest +import Combine +import Defaults + +@available(macOS 11.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +private extension Defaults.Keys { + static let opacity = Key("opacity", default: 0.5) +} + +@available(macOS 11.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +final class ViewModel: ObservableObject { + @PublishedDefault(.opacity) var opacity +} + +@available(macOS 11.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +final class DefaultsPublishedDefaultTests: XCTestCase { + + var cancellables: [AnyCancellable] = [] + + override func setUp() { + Defaults.removeAll() + } + + override func tearDown() { + cancellables = [] + } + + func testObjectWillChange() { + let viewModel = ViewModel() + let objectExpectation = expectation(description: "Expected ObservableObject's fire") + + viewModel.objectWillChange + .sink { + objectExpectation.fulfill() + } + .store(in: &cancellables) + + viewModel.opacity = 1 + waitForExpectations(timeout: 1) + + XCTAssertEqual(viewModel.opacity, 1) + XCTAssertEqual(Defaults[.opacity], 1) + } + + func testProjectValue() { + let viewModel = ViewModel() + let valueExpectation = expectation(description: "Expected Opacity Value") + var receivedValue: Double? + + viewModel.$opacity + // skip the initial value + .dropFirst() + .sink { newValue in + receivedValue = newValue + valueExpectation.fulfill() + } + .store(in: &cancellables) + + // Changing value via Defaults directly also fire the projecttedValue + Defaults[.opacity] = 1 + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedValue, 1) + } + + func testObservation() { + let viewModel = ViewModel() + // To enable Defaults observation, call observeDefaults. + viewModel.observeDefaults() + let objectExpectation = expectation(description: "Expected ObservableObject's fire") + + viewModel.objectWillChange + .sink { + objectExpectation.fulfill() + } + .store(in: &cancellables) + + // Change value via Defaults instead of ObservableObject itself + Defaults[.opacity] = 1 + waitForExpectations(timeout: 1) + + XCTAssertEqual(viewModel.opacity, 1) + } +}