Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 560
Bart van Kuik Avatar asked Sep 07 '25 00:09

Bart van Kuik


2 Answers

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

like image 71
Schottky Avatar answered Sep 11 '25 01:09

Schottky


It is easier if you use a struct to hold the From fields' properties.

struct Model {
    var name: String
    var color: String
}

Then, in self.$model.sink { value in} compare if the new value if the same as the old one or it has changed.

class ViewModel: ObservableObject {
    @Published var didUpdate = false
    @Published var model: Model
    private var cancellables: [AnyCancellable] = []
    
    init() {
        self.model = Model(name: "Qui-Gon Jinn", color: "green")
        self.$model.sink { value in
            
            guard !(value.name.trimmingCharacters(in: .whitespaces).isEmpty || value.color.trimmingCharacters(in: .whitespaces).isEmpty) else {
                self.didUpdate = false
                return
            }

            if value.name != self.model.name {
                NSLog("name did chanage")
                self.didUpdate = true
            }
            
            if value.color != self.model.color {
                NSLog("Color did change")
                self.didUpdate = true
            }
            
        }
        .store(in: &self.cancellables)
    }
    
    deinit {
        self.cancellables.removeAll()
    }
}

All Code


struct Model {
    var name: String
    var color: String
}

class ViewModel: ObservableObject {
    @Published var didUpdate = false
    @Published var model: Model
    private var cancellables: [AnyCancellable] = []
    
    init() {
        self.model = Model(name: "Qui-Gon Jinn", color: "green")
        self.$model.sink { value in
            
            guard !(value.name.trimmingCharacters(in: .whitespaces).isEmpty || value.color.trimmingCharacters(in: .whitespaces).isEmpty) else {
                self.didUpdate = false
                return
            }
           
            if value.name != self.model.name {
                NSLog("Here")
                self.didUpdate = true
            }
 
            if value.color != self.model.color {
                NSLog("Here")
                self.didUpdate = true
            }
           
        }
        .store(in: &self.cancellables)
    }
    
    deinit {
        self.cancellables.removeAll()
    }
}

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


🎁

1

.navigationBarItems is deprecated. Use .toolbar instead.

.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
         Button("Save") { NSLog("Saving!") }
          .disabled(!self.viewModel.didUpdate)
    }
}

  1. https://developer.apple.com/documentation/swiftui/view/navigationbaritems(leading:trailing:)

  2. https://developer.apple.com/documentation/swiftui/view/toolbar(content:)-5w0tj

2

If you have multiple models, confirm to the Identifiable, Equatable protocols.


struct Model: Identifiable, Equatable {
    var id: UUID = UUID()
    
    var name: String
    var color: String
}

like image 21
mahan Avatar answered Sep 11 '25 00:09

mahan