Okay, basic situation: we've got a view model object which holds some state. And we've got a view, which can edit that state. Here, specifically, we're working on a VMHousehold
object that has a name
property which is a String
and our view has a TextField
that has a binding to the household's name property.
class VMHousehold: ObservableObject, Identifiable, Hashable, Equatable {
var undoManager: UndoManager?
...
var name: String
...
// We'll get to this in a minute -- I've done lots of experimentation
func set(name: String) {
let oldName = self.name
if oldName != name {
objectWillChange.send()
undoManager?.registerUndo(withTarget: self) { household in
household.set(name: oldName)
}
self.name = name
}
}
...
}
struct EditHousehold: View {
@Environment(\.undoManager) var undoManager
@ObservedObject var household: VMHousehold
...
var body: some View {
return Form {
Section(header: Text(LocalizedStringKey.hhSettings)) {
TextField(LocalizedStringKey.hhName, text: $household.name)
}
...
}
.onChange(of: undoManager) { newManager in
household.undoManager = newManager
}
.onAppear {
household.undoManager = undoManager
}
}
...
Okay, so what happens with the code above is, the TextField
acts as one would expect - it edits the name
property of the household object. All fine. But, there's no undo since I haven't wired in the UndoManager
.
Now, first, I added in Paul's swell Binding.onChange
extension, which allows me to see when the name gets changed. However, that calls the handler after the value is changed. If I switch it around so the handler gets called first, then that's pretty good, right?
extension Binding {
func beforeChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
handler(newValue)
self.wrappedValue = newValue
}
)
}
}
// and we change the TextField so it looks like this:
TextField(LocalizedStringKey.hhName, text: $household.name.beforeChange {newValue in
household.set(name: newValue)
})
This sort of works. Now, when we change the name of the household, the Undo
menu item becomes enabled and we can undo the change. And redo works as well! That's great! Except. We only made one change to the name (type a single letter, or delete a single letter). So we'd expect that there would only be one thing to undo -- but no, there are four.
- First undo: reverts the first change
- Second undo: performs the first change again
- Third undo: reverts the first change
- Fourth undo: performs the first change again
So, it looks like the undo is being registered multiple times. This is not what a user expects.
Now, I've tried creating yet another Binding extension that doesn't actually change the underlying value at all, but only calls a closure with the new and old values, but the behavior is the same. So I suspect my logic is incorrect up in the set(name: )
method on the household object. Before I go any farther down this rabbit hole of epicycles, has anyone done it better? The effect I want to achieve is, the TextField updates the model property and when Undo is applied, it's applied to both what the TextField displays and to the model.