How can I subscribe to changes to an @AppStorage var...

From what I've read, @AppStorage vars should be @Published, however the following code generates a syntax error at extended's .sink modifier: Cannot call value of non-function type 'Binding<Subject>'

class LanguageManager: ObservableObject {
    @Published var fred = "Fred"
    @AppStorage("extended") var extended: Bool = true
    private var subscriptions = Set<AnyCancellable>()
    
    init() {
        $fred
            .sink(receiveValue: {value in
                print("value: \(value)")
            })
            .store(in: &subscriptions)
        $extended
            .sink(receiveValue: {value in
                print("value: \(value)")
            })
            .store(in: &subscriptions)
         
    }

Does anyone know of a way to listen for (subscribe to) changes in @AppStorage values? didSet works in for a specific subset of value changes, but this is not sufficient for my intended use.

Answered by mikeTheDad in 789496022

I found an implementation I'm mostly happy with. First, I defined a Notification.Name and created a userDefaults binding 'factory'

extension Notification.Name {
    static let userDefaultsChanged = Notification.Name(rawValue: "user.defaults.changed")
}
struct BindingFactory {
    static func binding(for defaultsKey: String) -> Binding<Bool> {
        return Binding {
            return UserDefaults.standard.bool(forKey: defaultsKey)
        } set: { newValue in
            UserDefaults.standard.setValue(newValue, forKey: defaultsKey)
            NotificationCenter.default.post(name: .userDefaultsChanged, object: defaultsKey)
        }
    }
}

Then in my UI elements that were previously using @AppStorage, they now use the new bindings.

    var body: some View {
        VStack {
            Toggle("Extended", isOn: BindingFactory.binding(for: "extended"))
            Text(LanguageManager.shared.summary)
        }
    }

And now LanguageManager's init can add a subscriber to the userDefaultsChanged notification.

    init() {
        NotificationCenter.default.publisher(for: .userDefaultsChanged)
            .sink(receiveValue: { notification in
                print("\(notification.object!) changed")
                self.updateItems()
            })
            .store(in: &subscriptions)
    }

The main thing I don't really like about this is the need to create a static func binding(for defaultsKey: String) -> Binding<Bool> for each type of binding (Bool, String, Int, etc.)

From my knowledge, I believe @AppStorage is more like @State.

The projectedValue of the Published property wrapper exposes a publisher which is what you are subscribing to.

The projectedValue of the AppStorage property wrapper is a binding to a value from UserDefaults.

Note: the projectedValue is the property accessed with the $ operator.



As a solution, I would use SwiftUI's onChange(of:perform:) modifier, or I guess as an alternative didSet, but this seems to be not what you want.

I am not familiar enough with Combine to provide a full working solution, but I can give you some ideas:

  • Create a custom Publisher as an extension on AppStorage to subscribe to
  • Could create a custom property wrapper which replicates AppStorage but has a publisher for its projectedValue
  • Or, could send/publish the change in the didSet of the @AppStorage property using some Combine object
Accepted Answer

I found an implementation I'm mostly happy with. First, I defined a Notification.Name and created a userDefaults binding 'factory'

extension Notification.Name {
    static let userDefaultsChanged = Notification.Name(rawValue: "user.defaults.changed")
}
struct BindingFactory {
    static func binding(for defaultsKey: String) -> Binding<Bool> {
        return Binding {
            return UserDefaults.standard.bool(forKey: defaultsKey)
        } set: { newValue in
            UserDefaults.standard.setValue(newValue, forKey: defaultsKey)
            NotificationCenter.default.post(name: .userDefaultsChanged, object: defaultsKey)
        }
    }
}

Then in my UI elements that were previously using @AppStorage, they now use the new bindings.

    var body: some View {
        VStack {
            Toggle("Extended", isOn: BindingFactory.binding(for: "extended"))
            Text(LanguageManager.shared.summary)
        }
    }

And now LanguageManager's init can add a subscriber to the userDefaultsChanged notification.

    init() {
        NotificationCenter.default.publisher(for: .userDefaultsChanged)
            .sink(receiveValue: { notification in
                print("\(notification.object!) changed")
                self.updateItems()
            })
            .store(in: &subscriptions)
    }

The main thing I don't really like about this is the need to create a static func binding(for defaultsKey: String) -> Binding<Bool> for each type of binding (Bool, String, Int, etc.)

How can I subscribe to changes to an @AppStorage var...
 
 
Q