drekka
drekka

Reputation: 21883

How can I listen to changes in a @AppStorage property when not in a view?

The following is the content of a playground that illustrates the problem. Basically I have a value stored in UserDefaults and accessed by a variable wrapped in the @AppStorage property wrapper. This lets me access the updated value in a View but I'm looking for a way to listen to changes in the property in ViewModels and other non-View types.

I have it working in the follow code but I'm not sure it's the best way to do it and I'd love to avoid having to declare a PassthroughSubject for each property I want to watch.

Note: I did originally sink the ObservableObject's objectWillChange property however that will reflect any change to the object and I'd like to do something more fine grained.

So does anyone have any ideas on how to improve this technique?

import Combine
import PlaygroundSupport
import SwiftUI

class AppSettings: ObservableObject {
    var myValueChanged = PassthroughSubject<Int, Never>()
    @AppStorage("MyValue") var myValue = 0 {
        didSet { myValueChanged.send(myValue) }
    }
}

struct ContentView: View {

    @ObservedObject var settings: AppSettings
    @ObservedObject var viewModel: ValueViewModel

    init() {
        let settings = AppSettings()
        self.settings = settings
        viewModel = ValueViewModel(settings: settings)
    }

    var body: some View {
        ValueView(viewModel)
            .environmentObject(settings)
    }
}

class ValueViewModel: ObservableObject {

    @ObservedObject private var settings: AppSettings
    @Published var title: String = ""
    private var cancellable: AnyCancellable?

    init(settings: AppSettings) {
        self.settings = settings
        title = "Hello \(settings.myValue)"

        // Is there a nicer way to do this?????
        cancellable = settings.myValueChanged.sink {
            print("object changed")
            self.title = "Hello \($0)"
        }
    }
}

struct ValueView: View {

    @EnvironmentObject private var settings: AppSettings
    @ObservedObject private var viewModel: ValueViewModel

    init(_ viewModel: ValueViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        Text("This is my \(viewModel.title) value: \(settings.myValue)")
            .frame(width: 300.0)
        Button("+1") {
            settings.myValue += 1
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Upvotes: 6

Views: 4832

Answers (2)

Pablo Martinez
Pablo Martinez

Reputation: 1639

The accepted solution PublishingAppStorage class is not fully working when binding views and

@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 String
    init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == String {
        self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
    }
    
    // RawRepresentable Int
    init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value: RawRepresentable, Value.RawValue == Int {
        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: Value, _ 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)
    }
}

This one works as if you were using @AppStorage

viewModel.$fade.sink { [weak self] newFadeSetting in

}

where fade is

@PublishedAppStorage("fade") var fade: ScreenFadeSettingItem = .on

And for binding you can simply use

SettingSegmentPicker<ScreenFadeSettingItem>(
        titles: ScreenFadeSettingItem.allCases,
        selection: $viewModel.fade
)

Upvotes: 6

drekka
drekka

Reputation: 21883

I wrote this property wrapper:

/// Property wrapper that acts the same as @AppStorage, but also provides a ``Publisher`` so that non-View types
/// can receive value updates.
@propertyWrapper
struct PublishingAppStorage<Value> {

    var wrappedValue: Value {
        get { storage.wrappedValue }
        set {
            storage.wrappedValue = newValue
            subject.send(storage.wrappedValue)
        }
    }

    var projectedValue: Self {
        self
    }

    /// Provides access to ``AppStorage.projectedValue`` for binding purposes. 
    var binding: Binding<Value> {
        storage.projectedValue
    }

    /// Provides a ``Publisher`` for non view code to respond to value updates.
    private let subject = PassthroughSubject<Value, Never>()
    var publisher: AnyPublisher<Value, Never> {
        subject.eraseToAnyPublisher()
    }

    private var storage: AppStorage<Value>

    init(wrappedValue: Value, _ key: String) where Value == String {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value: RawRepresentable, Value.RawValue == Int {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value == Data {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value == Int {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value: RawRepresentable, Value.RawValue == String {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value == URL {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value == Double {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    init(wrappedValue: Value, _ key: String) where Value == Bool {
        storage = AppStorage(wrappedValue: wrappedValue, key)
    }

    mutating func update() {
        storage.update()
    }
}

Basically it wraps @AppStorage and adds a Publisher. Using it is exactly the same from a declaration point of view:

@PublishedAppStorage("myValue") var myValue = 0

and accessing the value is exactly the same, however accessing the binding is slightly different as the projected value projects Self so it done through $myValue.binding instead of just $myValue.

And of course now my non-view can access a publisher like this:

cancellable = settings.$myValue.publisher.sink {
    print("object changed")
    self.title = "Hello \($0)"
}

Upvotes: 1

Related Questions