DynamicProperty PropertyWrapper is not working as I expect it to work

Hi All.

I have a question why a Dynamic Property wrapper is not working as I expect it to work.
If I do this with @AppStorage it works as expected, but with @iCloudStorage it does not work.

Demo code with @AppStorage:

class DataClass: ObservableObject {
    @AppStorage("dataSource")
    var dataSource: String = "DataSource"
}

struct ContentView: View {
    @ObservedObject var data = DataClass()

    var body: some View {
        VStack {
            Text(data.dataSource) // this does update
            Button("Change") {
                data.dataSource = "New DataSource"
            }
        }
    }
}

iCloudStorage property wrapper:

import Foundation
import SwiftUI

/// A property wrapper that reads and writes to iCloud.
///
/// Example:
/// ```
/// @iCloudStorage("key") var value: String = "default"
/// ```
@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
    // swiftlint:disable:previous type_name
    /// The key to read and write to.
    let key: String

    /// The default value to use if the value is not set yet.
    let defaultValue: T

    @State private var value: T

    /// Creates an `iCloudStorage` property.
    ///
    /// - Parameter wrappedValue: The default value.
    /// - Parameter key: The key to read and write to.
    public init(wrappedValue: T, _ key: String) {
        self.key = key
        self.defaultValue = wrappedValue
        self.value = NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
    }

    /// The value of the key in iCloud.
    public var wrappedValue: T {
        get {
            return value
        }

        nonmutating set {
            NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
            value = newValue
        }
    }

    /// A binding to the value of the key in iCloud.
    public var projectedValue: Binding<T> {
        Binding {
            return self.wrappedValue
        } set: { newValue in
            self.wrappedValue = newValue
        }
    }
}

Demo code with @iCloudStorage property wrapper:

class DataClass: ObservableObject {
    @iCloudStorage("dataSource")
    var dataSource: String = "DataSource"
}

struct ContentView: View {
    @ObservedObject var data = DataClass()

    var body: some View {
        VStack {
            Text(data.dataSource) // this does not update
            Button("Change") {
                data.dataSource = "New DataSource" 
            }
        }
    }
}

If I pass it directly to the ContentView it works as expected, but if I pass it to the DataClass class it does not work as expected.

This works "slightly" better, but this takes a lot of time to update, and only updates if something else changes in the view.

@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
    // swiftlint:disable:previous type_name
    /// The key to read and write to.
    let key: String

    /// The default value to use if the value is not set yet.
    let defaultValue: T

    final private class Storage: ObservableObject {
        var value: T {
            willSet {
                print("Willset")
                objectWillChange.send()
            }
        }

        init(_ value: T) {
            self.value = value
        }
    }

    @ObservedObject private var value: Storage

    /// Creates an `iCloudStorage` property.
    ///
    /// - Parameter wrappedValue: The default value.
    /// - Parameter key: The key to read and write to.
    public init(wrappedValue: T, _ key: String) {
        self.key = key
        self.defaultValue = wrappedValue
        self.value = Storage(
            NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
        )
    }

    /// The value of the key in iCloud.
    public var wrappedValue: T {
        get {
            return value.value
        }

        nonmutating set {
            NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
            value.value = newValue
        }
    }

    /// A binding to the value of the key in iCloud.
    public var projectedValue: Binding<T> {
        Binding {
            return self.wrappedValue
        } set: { newValue in
            value.value = newValue
            self.wrappedValue = newValue
        }
    }
}

Need to set the update function in the property wrapper:

public mutating func update() {
    _value.update()
}

Hi Ethan,

This does not work unfortunately.

It updates correctly if used directly to a view but not when used in a ObservableObject class.

AppStorage requires iOS 14… which is the same year that StateObject was released. That might be a clue. It's possible that State alone won't work… could you think of a creative way that StateObject could help?

This might have some more clues for you:

I've found a (non ideal) way to handle this.

import Foundation
import SwiftUI

/// A property wrapper that reads and writes to iCloud.
///
/// Example:
/// ```
/// @iCloudStorage("key") var value: String = "default"
/// ```
@propertyWrapper
public struct iCloudStorage<T>: DynamicProperty {
    // swiftlint:disable:previous type_name
    /// The key to read and write to.
    let key: String

    /// The default value to use if the value is not set yet.
    let defaultValue: T

    final private class Storage: ObservableObject {
        var value: T {
            willSet {
                print("Willset")
                objectWillChange.send()
            }
        }

        init(_ value: T) {
            self.value = value
        }
    }

    @ObservedObject private var value: Storage

    /// Creates an `iCloudStorage` property.
    ///
    /// - Parameter wrappedValue: The default value.
    /// - Parameter key: The key to read and write to.
    public init(wrappedValue: T, _ key: String) {
        self.key = key
        self.defaultValue = wrappedValue
        self.value = Storage(
            NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue
        )
    }

    /// The value of the key in iCloud.
    public var wrappedValue: T {
        get {
            return value.value
        }

        nonmutating set {
            NSUbiquitousKeyValueStore.default.set(newValue, forKey: key)
            value.value = newValue
        }
    }

    /// A binding to the value of the key in iCloud.
    public var projectedValue: Binding<T> {
        Binding {
            return self.wrappedValue
        } set: { newValue in
            value.value = newValue
            self.wrappedValue = newValue
        }
    }
}

And then

class DataClass: ObservableObject {
    @iCloudStorage("dataSource")
    var dataSource: String = "DataSource" {
        willSet {
            objectWillChange.send() // This is the trick
            // hopefully I can find a way that I can omit this
        }
    }
}

struct ContentView: View {
    @ObservedObject var data = DataClass()

    @iCloudStorage("dataSource")
    var dataSource: String = "DataSource"

    var body: some View {
        VStack {
            Text(dataSource) // this does update
            Button("Change") {
                dataSource = [
                    "New DataSource",
                    "Test",
                    "qqqq"
                ].randomElement()!
            }

            Divider()

            Text(data.dataSource) // this does update
            Button("Change") {
                data.dataSource = [
                    "New DataSource",
                    "Test",
                    "qqqq"
                ].randomElement()!
            }
        }
    }
}

Sorry, I missed that this is also desired in an ObservableObject. Fwiw, wrappers like AppStorage are meant to be used within a View and using them in something like an ObservableObject will bring weird results. I've written a few custom wrappers pretty cleanly that work only in Views:

If you want both, a macro may be the most suitable option instead that makes some other storage Published variable to finally drive the changes, or calls objectWillChange like you've done.

Although, I think that the implementation would require knowing the surrounding context and the macro system doesn't have that yet. I may be wrong.

1 Like