PropertyWrapper composition: `@Published @AppStorage("persisted") var data = 0` no parse error but error when compile

Edit: There is no need to compose @Published @AppStorage("persisted") because @AppStorage does "publish" its change and can be used inside ObservableObject and work just like @Published.

There is DefaultsWrapper package: a replacement of @AppStorage: it support many more types like Decimal, UUID. It's extensible, too!

Original post:

final class PublishedAppStorage: ObservableObject {
    @Published @AppStorage("persisted") var data = 0
}

when compile get this error in the error navigator pane:

Key path value type 'Int' cannot be converted to contextual type 'AppStorage'

same error when change to this:

final class PublishedAppStorage: ObservableObject {
    @Published<AppStorage<Int>> @AppStorage("persisted") var data = 0
}

I've never seen compile error like this in Xcode: no parse error but compile result in error in the error pane, not at the code site. (Edit: I think I understand why: the error occur inside the property wrapper, somehow the @SomePW compiler machinery cannot point the error site back at the call site)

Is there anyway to compose these two property wrappers?

My workaround:

// have to use this separate enum
enum PersistStore {
    // cannot put this in `PublishedAppStorage` and used directly in there
    @AppStorage("persisted") static var persisted = 0
}

final class PublishedAppStorage: ObservableObject {
    // this do not compile
//    @Published<AppStorage<Int>> @AppStorage("persisted") var data = 0
    @Published var data = PersistStore.persisted {
        didSet {
            PersistStore.persisted = data
        }
    }
}

These are all boilerplate, is it possible to just make a @PublishedAppStorage property wrapper have the functionality of a @Published that save change to AppStorage?, provide two projectedValue: one a Publisher, the other a SwiftUI.Binding<Value>...

Full test case
import SwiftUI

final class PublishedAppStorage: ObservableObject {
    @Published @AppStorage("persisted") var data = 0
}

struct ContentView: View {
    @EnvironmentObject var data: PublishedAppStorage

    var body: some View {
        VStack {
            Text("Hello, world! data = \(data.data)")
                .padding()
            TextField("Enter", value: $data.data, format: .number, prompt: Text("Value"))
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

You cannot compose property wrappers. In this case, using both is completely unnecessary because AppStorage already has the same functionality as Published—when the user defaults value changes, the view hierarchy will be invalidated.

The fact that the compiler gives you an incorrect error is a bug. The compiler developers obviously didn't foresee someone trying to compose property wrappers.

:raised_hands::pray: So nice it works like this!

You can for some combination. This is added by @hborla I think: How does propertyWrapper composition work vis-à-vis SwiftUI @Environment(\.presentationMode) @Binding? - #2 by hborla

Yes, property wrappers are composable. One of the few exceptions is a property wrapper that accesses the enclosing-self instance, like @Published - these wrappers don't yet work with composition. I think this is mostly a bug in the implementation, but I haven't dug too deeply into the issue yet.

Yes, as you suspected, the compiler error is actually inside the compiler-synthesized code for the property wrapper accessor, which doesn't have a valid source location in your project. In any case, this is definitely a compiler bug with the (underscored) enclosing-self subscript feature of property wrappers.

2 Likes

There might be a bug (or limitation with Combine publisher?): if @AppStorage is static, view doesn't get refreshed when AppStorage's value is changed:

import SwiftUI

struct ContentView: View {
    @AppStorage("number") static var number = 0
    @AppStorage("number") var alias = 0


    var body: some View {
        VStack {
            // this view never gets refresh on value change
            Text("number = \(Self.number)")
            // edit change get persisted, but view doesn't refresh
            TextField("Enter", value: Self.$number, format: .number, prompt: Text("Number"))
            // unless the view contains reference to the same @AppStorage but as instance property
            // uncomment this to see the view correctly refresh on edit change
//            Text("alias = \(alias)")
        }
    }
}

I was hoping to avoid duplicating the same @AppStorage as instance property everywhere by using a single static one. But as the test show, views don't get refresh when the value change. So I have to repeat the same as instance property all over the places.

1 Like

Create an ObservableObject with an @AppStorage property and inject the observable object into the root of your view hierarchy using the environmentObject extension. Then, access the object from your views using @EnvironmentObject.

2 Likes

Same issue, I tried to use another PropertyWrapper to help me format the input data, and Xcode show me this error...

I didn't realize this post was six months old.

I found a non-normal solution that using a static propertyWrapper wrapper can help me solve this problem:

static subscript<EnclosingType>(
	_enclosingInstance instance: EnclosingType,
	wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingType, Value>,
	storage storageKeyPath: ReferenceWritableKeyPath<EnclosingType, Self>
) -> Value where EnclosingType: ObservableObject, EnclosingType.ObjectWillChangePublisher == ObservableObjectPublisher {
	get {
		let `self` = instance[keyPath: storageKeyPath]
		...
		return ...
	}
	set {
		...
		instance.objectWillChange.send()
	}
}

@available(*,unavailable, message: "Not supported")
var wrappedValue: Value {
	get { fatalError() }
	set { fatalError() }
}

I had the same goal in mind and after quite a bit of investigations and trial-and-error, I ended up with the following:

import SwiftUI
import Combine

@propertyWrapper
public struct PublishedAppStorage<Value> {
    
    // Based on: https://github.com/OpenCombine/OpenCombine/blob/master/Sources/OpenCombine/Published.swift
    
    @AppStorage
    private var storedValue: Value
    
    private var publisher: Publisher?
    internal var objectWillChange: ObservableObjectPublisher?
    
    /// A publisher for properties marked with the `@Published` attribute.
    public struct Publisher: Combine.Publisher {
        
        public typealias Output = Value
        
        public typealias Failure = Never
        
        public func receive<Downstream: Subscriber>(subscriber: Downstream)
        where Downstream.Input == Value, Downstream.Failure == Never
        {
            subject.subscribe(subscriber)
        }
        
        fileprivate let subject: Combine.CurrentValueSubject<Value, Never>
        
        fileprivate init(_ output: Output) {
            subject = .init(output)
        }
    }

    public var projectedValue: Publisher {
        mutating get {
            if let publisher = publisher {
                return publisher
            }
            let publisher = Publisher(storedValue)
            self.publisher = publisher
            return publisher
        }
    }
    
    @available(*, unavailable, message: """
               @Published is only available on properties of classes
               """)
    public var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
    
    public static subscript<EnclosingSelf: ObservableObject>(
        _enclosingInstance object: EnclosingSelf,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, PublishedAppStorage<Value>>
    ) -> Value {
        get {
            return object[keyPath: storageKeyPath].storedValue
        }
        set {
            // https://stackoverflow.com/a/59067605/14314783
            (object.objectWillChange as? ObservableObjectPublisher)?.send()
            object[keyPath: storageKeyPath].publisher?.subject.send(newValue)
            object[keyPath: storageKeyPath].storedValue = newValue
        }
    }
    
    // MARK: - Initializers

    // RawRepresentable
    init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == String {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }
    
    // String
    init(wrappedValue: String, _ key: String, store: UserDefaults? = nil) where Value == String {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }

    // Data
    init(wrappedValue: Data, _ key: String, store: UserDefaults? = nil) where Value == Data {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }
    
    // Int
    init(wrappedValue: Int, _ key: String, store: UserDefaults? = nil) where Value == Int {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }
    
    // URL
    init(wrappedValue: URL, _ key: String, store: UserDefaults? = nil) where Value == URL {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }
    
    // Double
    init(wrappedValue: Double, _ key: String, store: UserDefaults? = nil) where Value == Double {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }

    // Bool
    init(wrappedValue: Bool, _ key: String, store: UserDefaults? = nil) where Value == Bool {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }

}

And it is used like that:

public class DMDefaults: ObservableObject
{
    @PublishedAppStorage("useVerboseLog")
    public var useVerboseLog: Bool = true
}
3 Likes

Did you see my edit at the top of the original post? @AppStorage is Published. It works in ObservedObject and @EnvironmentObject

Yes, but I need also to use it in my ViewModel using sink. Is there a way to get the publisher of the @AppStorage? I'm defining my @AppStorage in the ViewModel, not in my SwiftUI views...

I don’t know. Maybe this help:

1 Like

This can have synchronization issue in the binding between AppStorage and ObservableObject.
See this repository for demonstration: https://github.com/ShikiSuen/StateObjectWrappedBugDemo/blob/main/README_EN.md

Update: an Apple DTS engineer discouraged me from using any approach of wrapping AppStorages into ObservableObjects.