Handling Undo & Redo in SwiftUI


With better support for SwiftUI on macOS this year more of us might be tempted to write platform native applications using the new SwiftUI app lifecycle for Big Sur. One of the key features users expect on macOS is the ability to Undo (and Redo) changes they make throughout the application. For DocumentGroup applications that use the ReferenceFileDocument this is even more critical since the framework detects changes to the document using the undoManger events.


SwiftUI provides us access to the undoManger, however, it can be a little tricky to use it within our normal Binding based data manipulation. In particular, the UndoManager object upon which we need to registerUndo(withTarget:handler:) action requires a class type in the call signature.


To help with this and keep our code a little cleaner I have adopted a Provider pattern to abstract out the undo/redo operations from our regular views.


In this article, I will show you how to create an UndoProvider that you can then use anywhere in your projects where you have a binding to a value. This will capture changes to this binding to support undo/redo operations. Here is an example of how you would use the UndoProvider in your code.


UndoProvider(self.$value) { value in
    Toggle(isOn: value) {
        Text("Subscribed")
    }
}



Provider View


To build this UndoProvider we will start by creating a simple Provider view that we can then adapt.


struct Provider<WrappedView>: View where WrappedView: View {
    
    var wrappedView: () -> WrappedView
    
    init(@ViewBuilder wrappedView: @escaping () -> WrappedView) {
        self.wrappedView = wrappedView
    }
    
    var body: some View {
        wrappedView()
    }
}


This provider View makes use of a ViewBuilder so that we can re-use it throughout our codebase.



Intercepting a Binding in the Provider View


At its heart, our UndoProvider will take a Binding<Value> and pass this on to the wrapped view in such a way that it can intercept the value changes coming from the wrapped view.


To start we will modify the above Provider view to accept a binding. Then we will pass the intercepted binding through to the wrapped view, so that we can print out whenever the wrapped view changes the value.


struct BindingInterceptedProvider<WrappedView, Value>: View where WrappedView: View {
    
    var wrappedView: (Binding<Value>) -> WrappedView
    
    var binding: Binding<Value>
    
    init(
        _ binding: Binding<Value>,
         @ViewBuilder wrappedView: @escaping (Binding<Value>) -> WrappedView
    ) {
        self.binding = binding
        self.wrappedView = wrappedView
    }
    
    var interceptedBinding: Binding<Value> {
        Binding {
            self.binding.wrappedValue
        } set: { newValue in
            print("\(newValue) is about to override \(self.binding.wrappedValue)"
            self.binding.wrappedValue = newValue
        }
    }
    
    var body: some View {
        wrappedView(self.interceptedBinding)
    }
}


There are a few important things to notice in the above example:

  • The call signature of the wrappedView has been changed to take a Binding<Value>
  • We create an interceptedBinding to capture value changes from the wrapped view
  • The binding value passed in the constructor does not need to be @Binding since our view body does not need to re-render based on changes to this value


This BindingInterceptedProvider can be used like this throughout your codebase, and will print out whenever the value is changed by the wrapped view.


BindingInterceptedProvider(self.$value) { value in
    Toggle(isOn: value) {
        Text("Subscribed")
    }
}



Integrating with UndoManger


struct UndoProvider<WrappedView, Value>: View where WrappedView: View {
    
    @Environment(\.undoManager)
    var undoManager
    
    @StateObject
    var handler: UndoHandler<Value> = UndoHandler()
    
    var wrappedView: (Binding<Value>) -> WrappedView
    
    var binding: Binding<Value>
    
    init(_ binding: Binding<Value>, @ViewBuilder wrappedView: @escaping (Binding<Value>) -> WrappedView) {
        self.binding = binding
        self.wrappedView = wrappedView
    }
    
    var interceptedBinding: Binding<Value> {
        Binding {
            self.binding.wrappedValue
        } set: { newValue in
            self.handler.registerUndo(from: self.binding.wrappedValue, to: newValue)
            self.binding.wrappedValue = newValue
        }
    }
    
    var body: some View {
        wrappedView(self.interceptedBinding).onAppear {
            self.handler.binding = self.binding
            self.handler.undoManger = self.undoManager
        }.onChange(of: self.undoManager) { undoManager in
            self.handler.undoManger = undoManager
        }
    }
}


We have introduced a StateObject that is used to handle the undoManger. The reason for this is that when registering an Undo (or Redo) you are required to reference a class so you can’t use the undoManger directly using our ProviderView as its target handler.


Creating UndoHandler Object


The UndoHandler class captures the Binding<Value> and also keeps a weak reference to the UndoManager so that we can register a redo when performing the undo.


class UndoHandler<Value>: ObservableObject {
    var binding: Binding<Value>?
    weak var undoManger: UndoManager?
    
    func registerUndo(from oldValue: Value, to newValue: Value) {
        undoManger?.registerUndo(withTarget: self) { handler in
            handler.registerUndo(from: newValue, to: oldValue)
            handler.binding?.wrappedValue = oldValue
        }
    }
    
    init() {}
}


Notice how inside the undo block we register another undo. If you register an undo while the system is doing an undo the system interprets this as a redo and adds the assisted menu items.



Some notes on usage


Editable Text inputs in SwiftUI already automatically handle undo/redo so you do not need to wrap the bindings they use with this decorator.


On macOS you will automatically get menu items and keyboard shortcut support for undo and redo, however, on iOS/iPadOS these are not automatically provided. You will need to add your UI elements to expose the undo and redo methods that you can call on the UndoManager. There is also a canUndo and canRedo that you can use to conditionally display these UI elements.


Feel free to take the full code for the example project from GitHub and experiment.