ObservableObject doesn't emit change events for nested observables by default. Here a nested settings object within a view model, which is then observed by a view.
In this small example, the menu
doesn't see changes of settings
(enable
value). How to handle this behavior with Combine to propagate changes upwards in ContentView
?
In other words, how to manually pipe changes from your nested models upwards to the dependent view: maybe introduce property wrappers in between to reduce the boiler plating involved?
// Wrapper
@propertyWrapper struct UserDefault<T: Codable> {
private let key: String
private let defaultValue: T
init(_ key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
guard let data = UserDefaults.standard.object(forKey: key) as? Data else {
return defaultValue
}
let value = try? JSONDecoder().decode(T.self, from: data)
return value ?? defaultValue
}
set {
let data = try? JSONEncoder().encode(newValue)
UserDefaults.standard.set(data, forKey: key)
}
}
}
// Values
final class UserSettings: ObservableObject {
@UserDefault("itemA", defaultValue: true)
var itemA: Bool { willSet { objectWillChange.send() } }
@UserDefault("itemB", defaultValue: true)
var itemB: Bool { willSet { objectWillChange.send() } }
@UserDefault("itemC", defaultValue: true)
var itemC: Bool { willSet { objectWillChange.send() } }
}
// Model
struct Language: Identifiable, Hashable {
var id: String
var enable: Bool
}
enum Item: Identifiable, Hashable {
var id: String {
switch self {
case .item(let language): return language.id
}
}
case item(Language)
}
// ModelView
class ViewModel: ObservableObject {
let settings = UserSettings()
@Published var menu: [Item]
var cancellables: [AnyCancellable] = []
init() {
menu = [.item(Language(id: "a", enable: settings.itemA)),
.item(Language(id: "b", enable: settings.itemB)),
.item(Language(id: "c", enable: settings.itemC))]
settings.objectWillChange.sink { [unowned self] in
self.objectWillChange.send()
}
.store(in: &cancellables)
}
}
// View
struct ContentView: View {
@StateObject var model = ViewModel()
var body: some View {
HStack() {
Button("toggle a \(model.settings.itemA.description)") { model.settings.itemA.toggle() }
Button("toggle b \(model.settings.itemB.description)") { model.settings.itemB.toggle() }
Button("toggle c \(model.settings.itemC.description)") { model.settings.itemC.toggle() }
}
Menu {
ForEach(model.menu, id:\.self) { content in //// model.menu is not updated
switch content {
case let .item(language):
if language.enable { // the value doesn't update
Button("Item \(language.id)", action: {
print(language.id)
})
}
}
}
} label: { Text("menu") }
}
}
In your current code, menu
is only ever set once (on init
). You want to set menu
every time the object updates.
To do this, change the menu
after the settings
object has changed one of the values. Here we receive on the main
thread, then update the menu with setMenu()
.
You could also change the UserSettings
to use didSet
instead of willSet
, and then you will no longer require the .receive(on: DispatchQueue.main)
. However, this may be a bit counter-intuitive to the meaning of objectWillChange
.
Code:
class ViewModel: ObservableObject {
let settings = UserSettings()
@Published private(set) var menu: [Item] = []
private var cancellables: [AnyCancellable] = []
init() {
setMenu()
settings.objectWillChange.receive(on: DispatchQueue.main).sink { [unowned self] in
setMenu()
}
.store(in: &cancellables)
}
private func setMenu() {
menu = [
.item(Language(id: "a", enable: settings.itemA)),
.item(Language(id: "b", enable: settings.itemB)),
.item(Language(id: "c", enable: settings.itemC))
]
}
}
I did change some things slightly which would be unrelated, such as the access levels. You don't want the user to accidentally edit menu
directly.