sindresorhus / Defaults

đź’ľ Swifty and modern UserDefaults
https://swiftpackageindex.com/sindresorhus/Defaults/documentation/defaults
MIT License
1.93k stars 115 forks source link

Add @PublishedDefault for ObservableObject #128

Closed Lumisilk closed 1 year ago

Lumisilk commented 1 year ago

Introduction

Hi. Thanks for the awesome Defaults framework!

This PR add a new property wrapper @PublishedDefault, that is used exclusively for ObservableObject.

@PublishedDefault will trigger objectWillChange when changing the value of Defaults via ObservableObject.

You can use it like this

extension Defaults.Keys {
    static let opacity = Key<Double>("opacity", default: 0.5)
}

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

viewModel.opacity = 1.0
viewModel.objectWillChange // => fire Void
viewModel.$opacity // => fire 1.0

Detail

I used a feature called Referencing the enclosing 'self' in a wrapper type that is not in the Swift documentation but is in the Property Wrapper's Proposal. This feature makes the property wrapper can access the object that holding itself.

However, you can access the enclosing object only when that object reads/write the corresponding value, instead of all the time.

Due to this limitation, @PublishedDefault CANNOT observe changes in the value of the underlying UserDefaults, and can only trigger objectWillChange if the value change is made by the ObservableObject that holds it.

Defaults[.opacity] = 0.2

// neither of the following will fire
viewModel.objectWillChange
viewModel.$opacity

(Well, technically you can let @PublsihedDefaults hold a reference to the object when read/write occurs to make the observation work. But that doesn't smell good, so I didn't try it.)

It would be nice if you can also check the comments.

This PR also fix #48

Other helpful reference:

https://www.swiftbysundell.com/articles/accessing-a-swift-property-wrappers-enclosing-instance/ https://www.avanderlee.com/swift/appstorage-explained/#creating-an-alternative-solution-for-reading-and-writing-user-defaults-through-a-property-wrapper

TODO

sindresorhus commented 1 year ago

Thanks for working on this đź‘Ť

Have you considered integrating this into @Default, like with @AppStorage? That would be preferable.

sindresorhus commented 1 year ago

Due to this limitation, @PublishedDefault CANNOT observe changes in the value of the underlying UserDefaults, and can only trigger objectWillChange if the value change is made by the ObservableObject that holds it.

Maybe something like this: https://github.com/mergesort/Boutique/blob/ad47507dc0e1c4cf1693fd08567457d9caf664b7/Sources/Boutique/Stored.swift#L32-L44

Or this: https://github.com/j1mmyto9/photo-editor-luts-swiftui/blob/529c3b86f5b179c1148a5dfdb90358eba8749e8a/colorful-room/Utility/NestedObservableObject.swift#L33-L45

Lumisilk commented 1 year ago

About @Default

Since @Published's projectedValue should be Publisher<Value, Never> but @Default for SwiftUI is Binding<Value>, I think the two feature cannot be integrated into one @Default.

About observation

I tried this:

public struct PublishedDefault<Value: _DefaultsSerializable> {
    private var cancellable: AnyCancellable?

    public static subscript<Object: ObservableObject>(
        _enclosingInstance instance: Object,
        wrapped _: ReferenceWritableKeyPath<Object, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<Object, Self>
    ) -> Value where Object.ObjectWillChangePublisher == ObservableObjectPublisher {
        get {
            subscribeDefault(to: instance, storage: storageKeyPath)
            return instance[keyPath: storageKeyPath].value
        }
        set {
            subscribeDefault(to: instance, storage: storageKeyPath)
            instance[keyPath: storageKeyPath].value = newValue
        }
    }

    public static func subscribeDefault<Object: ObservableObject>(
        to instance: Object,
        storage storageKeyPath: ReferenceWritableKeyPath<Object, Self>
    ) where Object.ObjectWillChangePublisher == ObservableObjectPublisher {
        guard instance[keyPath: storageKeyPath].cancellable == nil else {
            return
        }
        // transfer Defaults publisher to objectWillChange
        instance[keyPath: storageKeyPath].cancellable =
        Defaults.publisher(instance[keyPath: storageKeyPath].key, options: [.prior])
            .sink { [weak instance] change in
                guard let instance, change.isPrior else { return }
                instance.objectWillChange.send()
            }
    }

    // Use Defaults.Publisher as projectedValue directly
}

Making @PublishedDefault catch a reference to the object when read/write occurs, observation works. But ONLY when the read/write occurred at least once.

Like this:

let viewModel = ViewModel()

Defaults[.opacity] = 1

viewModel.$opacity // not fire
viewModel.objectWillChange // not fire

viewModel.opacity = 1

// now observation starts working
viewModel.$opacity // fire
viewModel.objectWillChange // fire

I'm afraid I cannot find any solution about observation…

sindresorhus commented 1 year ago

Since @Published's projectedValue should be Publisher<Value, Never> but @Default for SwiftUI is Binding, I think the two feature cannot be integrated into one @Default.

I wonder how @AppStorage is able to do this then. Maybe they're using some internal hooks.

sindresorhus commented 1 year ago

Having it be @PublishedDefault is fine.

sindresorhus commented 1 year ago

And you sure the subscript is not called during initialisation?

sindresorhus commented 1 year ago

What if in the normal init() we call Self.subscribe() to start listening? Where Self.subscribe() is a static method that sets up the listening once.

Lumisilk commented 1 year ago

I wonder how @AppStorage is able to do this then

I didn't know AppStorage become compatible with ObservableObject from iOS 14.5. (missed the detail link of reply in #48)

And I finally catch up what's the problem here. It seems that @Published didn't use this enclosing feature. The objectWillChange's get method injects itself to the @Published using the runtime introspection. We cannot inject the code to the default implementation of ObservableObject.objectWillChange, so there doesn't seem to be a perfect solution:

Is my understanding right?


And you sure the subscript is not called during initialisation?

The static subscript, Yes, it's not.


What if in the normal init() we call Self.subscribe() to start listening? Where Self.subscribe() is a static method that sets up the listening once.

What's the Self here? ObservableObject?

sindresorhus commented 1 year ago

Is my understanding right?

Yes

What's the Self here? ObservableObject?

PublishedDefault

Lumisilk commented 1 year ago

Sorry I didn't get your point…

What I want is a property wrapper that can trigger the owner ObservableObject's objectWillChange fire when the corresponding default value changed.

And static subscript(_enclosingInstance instance:, wrapped _:, storage storageKeyPath:) is the only place where you can access the owner object from the property wrapper side. init() or Self.someStaticMethod() can't do it.

sindresorhus commented 1 year ago

This is ugly, but one workaround may be to tell the user to do this:

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

    init() {
        opacity = opacity
    }
}
sindresorhus commented 1 year ago

Since @Published's projectedValue should be Publisher<Value, Never> but @Default for SwiftUI is Binding, I think the two feature cannot be integrated into one @Default.

Thinking more about this. That is just a requirement for @Published, not for a property wrapper that wants to trigger an ObservableObject.

For example: https://github.com/fatbobman/CloudStorage/blob/164586fc1bd780e181eae64a0b795e5c3e2c60b9/Sources/CloudStorage/CloudStorage.swift#L13

sindresorhus commented 1 year ago

With the latest news about @Observable, I don't think the current approach is going to work out. See: https://github.com/sindresorhus/Defaults/issues/142

sindresorhus commented 1 year ago

Closing this. Let's continue the discussion in #142.