Search code examples
swiftswiftuicombine

Updates changes for nested models


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") }
    }
}

Solution

  • 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.