Search code examples
swiftuicombine

SwiftUI Combine observing updates


I have a SwiftUI Form with a backing Model. I wish to enable a Save button when the Model changed. I have the following code:

class Model: ObservableObject {
    @Published var didUpdate = false
    @Published var name = "Qui-Gon Jinn"
    @Published var color = "green"
    private var cancellables: [AnyCancellable] = []

    init() {
        self.name.publisher.combineLatest(self.color.publisher)
            .sink { _ in
                NSLog("Here")
                self.didUpdate = true
            }
            .store(in: &self.cancellables)
    }
}

struct ContentView: View {
    @StateObject var model = Model()

    var body: some View {
        NavigationView { 
            Form {
                Toggle(isOn: $model.didUpdate) {
                    Text("Did update:")
                }
                TextField("Enter name", text: $model.name)
                TextField("Lightsaber color", text: $model.color)
            }
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .navigationBarItems(
                trailing:
                Button("Save") { NSLog("Saving!") }
                    .disabled(!model.didUpdate)
            )
        }
    }
}

There are two problems with this code.

First problem is that upon instantiation of the Model, the log will show "Here", and thus set didUpdate to true. The second problem is that when the user changes the model via the textfields, it doesn't actually fire the publishers.

How should these problems be fixed?

(I've thought of adding didSet{} to each property in the Model but that is very ugly when there are lots of properties. I've also thought of adding modifiers to the textfields, but I really prefer putting this code in the Model, because a network update could also change the Model).


Solution

  • There is a much easier way to do what you want, however this option might not be what you want in the future. But what it all comes down to is the mutability of state.

    First of all, you seem to confuse the Model with the ViewModel. In your case, the model should be something like this:

    struct Model: Equatable {
        var name = "Qui-Gon Jinn"
        var color = "green"
    }
    

    Note that your model is Equatable. In swift, the default implementation that will be synthesized for you simply checks if all elements are equal to one another, i.e. the default implementation looks something like this:

    static func ==(lhs: Model, rhs: Model) -> Bool {
        lhs.name == rhs.name && lhs.color == rhs.color
    }
    

    We can use this behavior to get the desired result:

    struct ContentView: View {
        
        var original: Model
        @State var updated: Model
        
        init(original: Model) {
            self.original = original
            self.updated = original
        }
        
        var body: some View {
                NavigationView {
                    Form {
                        TextField("Enter name", text: $updated.name)
                        TextField("Lightsaber color", text: $updated.color)
                    }
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .navigationBarItems(
                        trailing:
                        Button("Save") { NSLog("Saving!") }
                            .disabled(original == updated)
                    )
                }
            }
    }
    

    You can now simply pass an old (or new) model to your ContentView. Whenever the model is different from the original one, the save-button will be enabled and when it's the same, save is disabled. Important: This neat way of writing your model is only possible, when you use a struct as your model since they have value-semantics. It is also for this reason that structs are preferred over classes when modeling your task.

    Now if you insist on using your ViewModel (for example because the conformance to Equatable is not possible or inefficient), you can do something similar. First, however, notice that this line

    name.publisher
    

    Is a publisher on the name (which would be of type Publishers.Sequence<String, Never>), not the @Published value (which is actually of type Published<String>.Publisher) The former publishes every Character of the String, i.e. this

    let name = "Qui-Gon Jinn"
    
    let cancel = name.publisher.print().sink { _ in }
    

    prints

    Q
    u
    i
    -
    ...
    

    What you actually want is the projected value of the name, which already is a publisher i.e.

    $name.dropFirst().sink { _ in
        NSLog("Here")
        self.didUpdate = true
    }
    

    Note that you need to drop the first value since the model immediately publishes after subscribing. You can also wrap all of this into the aforementioned model and call the publisher of the model (it will publish when any if it's properties changes).