Block Based Undo/Redo Api in SwiftUI


When building applications on macOS, and iOS, it is good practice to include undo/redo support within your application. SwiftUI provides @Environment(.undoManager) this exposes an UndoManager however to register undo actions onto the manager we need to pass a class.


We could use the UndoManager itself however that would result in the undo operations persisting in the undo stack even after the user has moved on to a different view. I have already discussed an UndoProvider based solution that I am using in Cleora however in some situations it is easier to have an explicit undo and redo operation.


To do this we can define a propertyWrapper that captures UndoManager and exposes the method we need.


@propertyWrapper
struct UndoProxy: DynamicProperty {
   
  @StateObject
  var wrappedValue = UndoProxyObject()
   
  @Environment(\.undoManager)
  var projectedValue
   
  mutating func update() {
    self.wrappedValue.undoManager = self.projectedValue
  }
}


Conforming to DynamicProperty means that SwiftUI will call our update method giving us the chance to assign the UndoManger to the UndoProxyObject.


In the above UndoProxy wrapper we make use of UndoProxyObject.


class UndoProxyObject: ObservableObject {
  weak var undoManager: UndoManager?
   
  func `do`(
    _ doBlock: @escaping () -> Void,
    undo undoBlock: @escaping () -> Void
  ) {
    doBlock()
    undoManager?.registerUndo(withTarget: self) { target in
      target.do(undoBlock, undo: doBlock)
    }
  }
}



UndoProxyObject provides a single do method that can be called with do and undo blocks. It executes the do block and then registers an undo operation that flips the do and undo blocks so that the do block becomes the redo action when then undo is being applied.


A good example of the UndoProxy within your views is for adding the ability to undo a delete action on a list of items.


struct ContentView: View {
  @UndoProxy
  var undoProxy
   
  ...
   
  var body: some View {
    ForEach(users) { user in
      ...
    }.onDelete { indices in

      var removedUsers: [User] = []
      for index in indices {
        removedUsers.append(users[index])
      }
       
      undoProxy.do {
        users.remove(atOffsets: indices)
      } undo: {
        for (index, indexInList) in indices.enumerated() {
          users.insert(removedUsers[index], at: indexInList)
        }
      }
    }
  }
}