sunshinejr / SwiftyUserDefaults

Modern Swift API for NSUserDefaults
http://radex.io/swift/nsuserdefaults/static
MIT License
4.84k stars 364 forks source link

Suggestion - Property Wrapper that works with SwiftUI #231

Open ConfusedVorlon opened 4 years ago

ConfusedVorlon commented 4 years ago

I'm playing with SwiftUI. I was hoping for a property wrapper that would allow me to use the magnificent SwiftyUserDefaults neatly with SwiftUI

Specifically, I'm looking at the case where I can click a button to toggle a setting, and expect the View to update appropriately.

I have had a shot at it. This works, although it doesn't observe changes to the defaults if they're made elsewhere.

I can't see a way to make observation work as the PropertyWrapper seems to need to be a struct rather than a class (so can't hold an observer which may mutate it)

#if swift(>=5.1)

//Inspired by https://stackoverflow.com/questions/59580065/is-it-correct-to-expect-internal-updates-of-a-swiftui-dynamicproperty-property-w

//Note - this doesn't work Property Wrapper is a class rather than a struct

@propertyWrapper
struct SwiftyUIDefault<T: DefaultsSerializable> : DynamicProperty where T.T == T {

    public let key: DefaultsKey<T>

    public var wrappedValue: T {
        get {
            return storage.wrappedValue
        }
        nonmutating set {
            apply(newValue)
        }
    }

    private let storage: State<T.T>

    public var projectedValue: Binding<T.T> {
        storage.projectedValue
    }

    private func apply(_ newValue: T) {
        Defaults[key: key] = newValue

        DispatchQueue.main.async {
            self.storage.wrappedValue = newValue
        }
    }

    public init(keyPath: KeyPath<DefaultsKeys, DefaultsKey<T>>) {
        self.key = Defaults.keyStore[keyPath: keyPath]

        self.storage = State<T.T>(initialValue: Defaults[key: key])

    }

}

#endif

You can test this with

import SwiftUI
import SwiftyUserDefaults

extension DefaultsKeys {
    var userLastLoginDate: DefaultsKey<Date> { .init("userLastLoginDate", defaultValue: Date.distantPast) }
}

struct Test: View {

    @SwiftyUIDefault(keyPath: \.userLastLoginDate)
    var userLastLoginDate: Date

    var body: some View {
        VStack {

            Text("Selected: \(userLastLoginDate)")
            .padding()

            Button(action: {
                self.userLastLoginDate = Date()
            }) {
                Text("Change Swifty")
            }

        }

    }
}

struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
            .previewLayout(.sizeThatFits)
            .previewDevice(.iPhone7)
    }
}

I don't really understand what the mechanisms are here. DynamicProperty is definitely a requirement, but the documentation doesn't give much info about how it 'tells' the View that there has been an update.

ConfusedVorlon commented 4 years ago

update: the wrapper above doesn't work in an ObserveableObject

in that scenario, the best I could see is

class Pref:ObservableObject {

    @SwiftyUserDefault(keyPath: \.userLastLoginDate, options: [.observed])
    var userLastLoginDate: Date {
        willSet {
            objectWillChange.send()
        }
    }
}
ConfusedVorlon commented 4 years ago

And in the multi-document scenario where there isn't a shared prefs object

extension DefaultsKeys {
    var magnifyRotation: DefaultsKey<Bool> { .init("magnifyRotation", defaultValue: false) }

}

class Prefs:ObservableObject {

    @SwiftyUserDefault(keyPath: \.magnifyRotation, options: [.observed])
    var magnifyRotation: Bool {
        willSet {
            objectWillChange.send()
        }
    }

    var observers:[DefaultsDisposable] = []

    init() {
        let disposable = Defaults.observe(\.magnifyRotation) { [weak self] (observer) in
            self?.objectWillChange.send()
        }
        observers.append(disposable)
    }

    deinit {
        observers.forEach { (disposable) in
            disposable.dispose()
        }
    }

}

there seems like a lot of boilerplate here

khuffie commented 3 years ago

@ConfusedVorlon did you ever resolve this?

ConfusedVorlon commented 3 years ago

I never came up with a good solution that integrated nicely with SwiftyUserDefaults

lordzsolt commented 1 year ago

I played around with this, I think it is possible to do this, following this article by Sundell: https://www.swiftbysundell.com/articles/accessing-a-swift-property-wrappers-enclosing-instance/

I dropped this file into the Pods project: https://gist.github.com/lordzsolt/cf96c5bc4619b2a09d32bce5a5254c27

Then I did:

class SomeViewModel: ObservableObject {
    @PublishedUserDefault(keyPath: \.someValue)
    var someValue Double

    init() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            let value = UserDefaults.standard.value(forKey: "someValue") as! Double
            UserDefaults.standard.set(value + 1, forKey: "someValue")

            DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
                someValue += 1
            }
        }
    }
}

And it seems like it will trigger a redraw of the SwiftUI View each time.

I might try to make a PR with it, but there is a bit too much magic that I don't fully understand. I don't want to break other people's production, until I test it enough myself.

ConfusedVorlon commented 1 year ago

This stuff melts my brain - but I'm really glad you're giving it a go. This would be a great addition to the library.

I had a look at the code, and the thing that strikes me as odd is setting up the observation as a defer in the getter. It seems like that would be more natural in the init.

Perhaps there is some magic going on...

lordzsolt commented 1 year ago

Perhaps there is some magic going on...

Yes, that's what I would've liked to have as well, but I think you only get the enclosing type in the subscript method