iosswiftswiftuicombineobservableobject

Passing an ObservableObject model through another ObObject?


I feel like I can sort of understand why what I'm doing isn't working but I'm still trying to wrap my head around Combine and SwiftUI so any help here would be welcome.

Consider this example:

Single view app that stores some strings in UserDefaults, and uses those strings to display some Text labels. There are three buttons, one to update the title, and one each to update the two UserDefaults-stored strings to a random string.

The view is a dumb renderer view and the title string is stored directly in an ObservableObject view model. The view model has a published property that holds a reference to a UserSettings class that implements property wrappers to store the user defined strings to UserDefaults.

Observations:

• Tapping "Set A New Title" correctly updates the view to show the new value

• Tapping either of the "Set User Value" buttons does change the value internally, however the view does not refresh. If "Set A New Title" is tapped after one of these buttons, the new values are shown when the view body rebuilds for the title change.

View:

import SwiftUI

struct ContentView: View {
    @ObservedObject var model = ViewModel()

    var body: some View {
        VStack {
            Text(model.title).font(.largeTitle)
            Form {
                Section {
                    Text(model.settings.UserValue1)
                    Text(model.settings.UserValue2)
                }

                Section {
                    Button(action: {
                        self.model.title = "Updated Title"
                    }) { Text("Set A New Title") }
                    Button(action: {
                        self.model.settings.UserValue1 = "\(Int.random(in: 1...100))"
                    }) { Text("Set User Value 1 to Random Integer") }
                    Button(action: {
                        self.model.settings.UserValue2 = "\(Int.random(in: 1...100))"
                    }) { Text("Set User Value 2 to Random Integer") }
                }

                Section {
                    Button(action: {
                        self.model.settings.UserValue1 = "Initial Value One"
                        self.model.settings.UserValue2 = "Initial Value Two"
                        self.model.title = "Initial Title"
                    }) { Text("Reset All") }
                }

            }
        }
    }
}

ViewModel:

import Combine

class ViewModel: ObservableObject {

    @Published var title = "Initial Title"

    @Published var settings = UserSettings()

}

UserSettings model:

import Foundation
import Combine

@propertyWrapper struct DefaultsWritable<T> {
    let key: String
    let value: T

    init(key: String, initialValue: T) {
        self.key = key
        self.value = initialValue
    }

    var wrappedValue: T {
        get { return UserDefaults.standard.object(forKey: key) as? T ?? value }
        set { return UserDefaults.standard.set(newValue, forKey: key) }
    }
}



final class UserSettings: NSObject, ObservableObject {
    let objectWillChange = PassthroughSubject<Void, Never>()

    @DefaultsWritable(key: "UserValue", initialValue: "Initial Value One") var UserValue1: String {
        willSet {
            objectWillChange.send()
        }
    }
    @DefaultsWritable(key: "UserBeacon2", initialValue: "Initial Value Two") var UserValue2: String {
        willSet {
            objectWillChange.send()
        }
    }

}

When I put a breakpoint on willSet { objectWillChange.send() } in UserSettings I see that the objectWillChange message is going to the publisher when I would expect it to so that tells me that the issue is likely that the view or the view model is not properly subscribing to it. I know that if I had UserSettings as an @ObservedObject on the view this would work, but I feel like this should be done in the view model with Combine.

What am I missing here? I'm sure it's really obvious...


Solution

  • ObservedObject listens for changes in @Published property, but not the deeper internal publishers, so the below idea is to join internal publisher, which is PassthroughSubject, with @Published var settings, to indicate that the latter has updated.

    Tested with Xcode 11.2 / iOS 13.2

    The only needed changes is in ViewModel...

    class ViewModel: ObservableObject {
    
        @Published var title = "Initial Title"
        @Published var settings = UserSettings()
    
        private var cancellables = Set<AnyCancellable>()
        init() {
            self.settings.objectWillChange
                .sink { _ in
                    self.objectWillChange.send()
                }
                .store(in: &cancellables)
        }
    }