What the best way to achieve @UserDefaults @State?

I need my var to have both @UserDefaults @State functionality without change any existing code that's already using the var as @State.

I can get the @UserDefaults code from the Property Wrapper Proposal: swift-evolution/0258-property-wrappers.md at master · apple/swift-evolution · GitHub

@propertyWrapper
struct UserDefault<T> {
  let key: String
  let defaultValue: T
  
  var wrappedValue: T {
    get {
      return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
    }
    set {
      UserDefaults.standard.set(newValue, forKey: key)
    }
  }
}

Can I modify this to add @State to it?

You can't use @State, this var is only writeable from the SwiftUI-Thread. But you could add a ViewModel:

final class UserSettings: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    @UserDefault(key: "text", defaultValue: "")
    var text: String

    private var notificationSubscription: AnyCancellable?

    init() {
        notificationSubscription = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification).sink { _ in
            self.objectWillChange.send()
        }
    }
}

This ViewModel will also fire, if you use UserDefaults.set(_:forKey:).

2 Likes

Sure you can use both! Just compose the wrappers, but keep in mind that your binding won‘t be of type T but of type UserDefault<T>. Also for SwiftUI to recognize State, it must be the outer most wrapper.

@State @UserDefaulr(key: ..., defaultValue: ...)
var property: T

// `$property` will be of type `UserDefault<T>`

@State @UserDefault(key: "foo", default: "here here") var foo

Error:

Multiple property wrappers are not supported

The proposal said composition is not support for now, which is the reason I ask the original question.

The proposal suggest to simulate the two. But I don't know how @State is inside.

Or @State @UserDefault should work after all? Am I doing something wrong?

That‘s weird wrappers composition was implemented, there are a bunch of tests for that on the master. Which Xcode beta are you using?

Version 11.0 beta 6 (11M392q)

Can‘t really verify from my phone (for like a week).
I thought it was implemented, maybe it was reverted due some issues?

Does the composition for the following simple wrapper also fail?

@propertyWrapper
struct W<V> {
  var wrappedValue: V
}

struct T {
  @W @W var property: String = "swift"
}

If that feature does not work you can write the boilerplate yourself and delete it when it becomes available:

private var _property = State(wrappedValue: UserDefault(...))
var property: T {
  get { return _propety.wrappedValue.wrappedValue }
  set { _propety.wrappedValue.wrappedValue = newValue }
}

It was temporarily disabled due to pending bugs: ([5.1] Disable property wrapper composition. by jckarter · Pull Request #26288 · apple/swift · GitHub), and enabled again after those bugs were fixed (Sema: Correct composition of property wrappers. by jckarter · Pull Request #26326 · apple/swift · GitHub). However, that fix wasn’t cherry picked to 5.1 hence why it’s still disabled on 5.1, but works on master.

1 Like

It doesn‘t look like that wrappers composition will land in Swift 5.1.

Cc @Joe_Groff still any plans or is it too late/risky now?

1 Like

Simply composing @State @UserDefault will not do what I want in the end :angry:.

@State @UserDefaulr(key: ..., defaultValue: "a string")
var property: T         // <== T is a String

One of the use is with TextField("Field Name", text: $property) or any other form widgets, for TextField $property is a Binding<String>, but it's a Binding<UserDefault<String>>. Anyway, the end goal is when TextField or any other widgets make edit, the change is propagate back and persist to UserDefaults. But just composing wrapper of a wrapper, the whole thing just doesn't work.

Can I subscribe to @State change event? It must have publisher inside.

You can use $property.wrappedValue to get the Binding of type T.

Edit: Keep in mind that Binding itself has a wrappedValue property which returns just Value, that means there will be a collision and the compiler will prefer the type member rather the dynamic key-path subscript. To resolve the collision just provide the type you want or use it in a context where you expect Binding<T>. That way the compiler will opt in and use $property[dynamicMember: \.wrappedValue] which is the same as $property.wrappedValue as Binding<T>.

2 Likes

I got the double-wrapper method working on Swift 5.2, with some tweaks. The big thing was changing the UserDefault propertyWrapper so that it took its default value as the normal Swift value initializer, instead of as a parameter in the propertyWrapper, so that the compiler would understand that my @State @UserDefaults do have initial values.

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T

    init(wrappedValue value: T, key: String) {
        self.key = key
        self.defaultValue = value
    }

    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

public struct PreferencesUIView : View {
    @State @UserDefault(key: "usePaperLook") var usePaperLook: Bool = true

     public var body: some View {
         Toggle(isOn: $usePaperLook[dynamicMember: \.wrappedValue]) {
               Text("Use Paper Texture")
         }
    }    
}

I verified it writes the default value out and reads it back correctly. The only missing piece for me now is having this auto-detect when this default changes (from somewhere else in the program, or even another process) and trigger an update when it does.

% defaults read com.delicious-monster.Dwelling usePaperLook
0

[Edit: I discovered this only works when the Toggle button is in .toggleStyle(SwitchToggleStyle()), so this code does NOT work for the general case — it is apparently missing something, like my code attempt below.]

1 Like

Wwonderful! So happy you got the whole thing working!

$usePaperLook[dynamicMember: .wrappedValue]

I would not have been able to figure this out...

:+1::raised_hands::clap:

The point of this feature is that you should be able to just write $usePaperLook.wrappedValue ;) Just try it out.

If I use $usePaperLook.wrappedValue I get the error:

Cannot convert value of type 'UserDefault' to expected argument type 'Binding'

Which seems like it makes sense to me since the UserDefault wrapper is inside the State wrapper, but then I don’t understand what’s going on under the hood enough to know why $usePaperLook[dynamicMember: \.wrappedValue] works.

I also can’t reverse the order of @State and @UserDefault without errors, but that may just be due to my naive implementation of @UserDefault / random guesses at how this all works.

[I also tested `$usePaperLook.wrappedValue as Binding<Bool>` and the compiler hates that as well, with the same error as the first attempt in this post.]

Ah sorry my bad. The key-path member lookup proposal says:

Key path member lookup only applies when the @dynamicMemberLookup type does not contain a member with the given name.

So basically this is exactly like in your case and where the collision occurs.

@dynamicMemberLookup
struct S<Value> {
  let value: Value
  subscript<Subject>(
    dynamicMember keyPath: WritableKeyPath<Value, Subject>
  ) -> S<Subject> {
    S<Subject>(value: value[keyPath: keyPath])
  }
}

struct T {
  let value: String
}

let s_of_t = S(value: T(value: "swift"))

// error: Cannot convert value of type 'T' to specified type 'String'
let string: String = s_of_t.value

For your example then only $usePaperLook[dynamicMember: \.wrappedValue] is the right solution, if you don't want to wrap the Binding manually. Sorry for the confusion.

I’m not sure I understand enough to wrap the Binding manually — what would that entail? I’m looking at the (purported) source code for @State and I see:

    public var projectedValue: Binding<Value> {
        return Binding(get: { return self.wrappedValue }, set: { newValue in self.wrappedValue = newValue })
    }

But I’m not sure if this is related to what you’re saying.

First you get the project value as a Binding through _usePaperLook.projectedValue, but you reference it as $usePaperLook. Then Binding has a required subscript (but not necessarily in that form) due to the @dynamicMemberLookup attribute. For more information here are the related proposals:

The implementation of that subscript might look similar to this:

public subscript<Subject>(
  dynamicMember keyPath: WritableKeyPath<Value, Subject>
) -> Binding<Subject> {
  Binding<Subject>(
    get: {
      self.wrappedValue[keyPath: keyPath]
    },
    set: { newValue in
      self.wrappedValue[keyPath: keyPath] = newValue
    }
  )
}

Ok, I read the swift-evolution proposals and played with the above some, but decided this approach was too complicated (for me!) for the specific thing I’m trying to accomplish, which is reading and setting a UserDefault, which are always one level deep (eg, don’t really need paths, AFICT?).

I switched to a different approach of ONLY having a UserDefault<Value> @propertyWrapper (no more @State) and as the cruelest words in the universe go: it ALMOST works.

@propertyWrapper
struct UserDefault<Value>  {
    let key: String
    let defaultValue: Value

    init(wrappedValue value: Value, key: String) {
        self.defaultValue = value
        self.key = key
    }

    var wrappedValue: Value {
        get { UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue }
        nonmutating set { UserDefaults.standard.set(newValue, forKey: key) }
    }
    var projectedValue: Binding<Value> {
        Binding<Value>( get: { self.wrappedValue }, set: { newValue in self.wrappedValue = newValue } )
    }
}


public struct PreferencesUIView : View {
    @UserDefault(key: "usePaperLook") private var usePaperLook: Bool = true

    public var body: some View {
        Toggle(isOn: $usePaperLook) { Text("Use Paper Texture") }
    }
}

This compiles AND displays the correct state on launch AND lets you toggle the state of the preference on disk...BUT when you toggle the toggle it only changes state for a millisecond and then goes back to whatever the launch state was (even though the UserDefault was, in fact, changed in the database).

So obviously I’m missing something. Is this whole approach dumb? Anyone? Beuller? Anyone?

[Edit: cleaned up code a tiny bit but bug is still there.]

1 Like

I don't know how SwiftUI View maintain state, but I think you must use @State. It's possible that inside SwiftUI, your View is "forgotten" because SwiftUI somehow sees no difference. So it's just keeping that old copy, not the one you operate on. This is only my guess. I hope someone knowledgeable tell you.

But I would guess you need to involve @State.