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 ViewModel
s 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())
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)"
}