UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SOLVED: Coordinating undo between view state and view model

Forums > SwiftUI

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.

  1. First undo: reverts the first change
  2. Second undo: performs the first change again
  3. Third undo: reverts the first change
  4. 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.

3      

Okay, the answer seems to be, use the @Published property wrapper and bind the TextField to that.

Modify VMHousehold:

    @Published var name: String

and modify the view:

                TextField(LocalizedStringKey.hhName, text: $household.name)

Undo/Redo seem to work properly. There. No epicycles needed.

3      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.