Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @PublishedDefault for ObservableObject #128

Closed
wants to merge 3 commits into from
Closed
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
124 changes: 124 additions & 0 deletions Sources/Defaults/PublishedDefault.swift
Original file line number Diff line number Diff line change
@@ -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<Double>("opacity", default: 1)
}

class ViewModel: ObservableObject {
@PublishedDefault(.opacity) var opacity

init() {
observeDefaults()
}
}
```
*/
@propertyWrapper
public class PublishedDefault<Value: _DefaultsSerializable> {
private let key: Defaults.Key<Value>
private var defaultPublisher: AnyPublisher<Value, Never>?
private var objectSubscription: AnyCancellable?

private var value: Value {
get { Defaults[key] }
set { Defaults[key] = newValue }
}

public init(_ key: Defaults.Key<Value>) {
self.key = key
}

/**
The getter/setter in a `ObservableObject`.
*/
public static subscript<Object: AnyObject>(
_enclosingInstance instance: Object,
wrapped _: ReferenceWritableKeyPath<Object, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<Object, PublishedDefault>
) -> 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<Value, Never> {
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<Double>("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)
}
}
}
83 changes: 83 additions & 0 deletions Tests/DefaultsTests/DefaultsPublishedDefaultTests.swift
Original file line number Diff line number Diff line change
@@ -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<Double>("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)
}
}