diff --git a/Sources/Defaults/PublishedDefault.swift b/Sources/Defaults/PublishedDefault.swift new file mode 100644 index 0000000..ec8be4f --- /dev/null +++ b/Sources/Defaults/PublishedDefault.swift @@ -0,0 +1,124 @@ +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 class PublishedDefault { + private let key: Defaults.Key + private var defaultPublisher: AnyPublisher? + private var objectSubscription: AnyCancellable? + + private var value: Value { + get { Defaults[key] } + set { Defaults[key] = newValue } + } + + 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 { + // 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() + } + instance[keyPath: storageKeyPath].value = newValue + } + } + + @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 { + 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() + } + } + ``` + */ + public func reset() { + 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) + } + } +} 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) + } +}