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:) the 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 aBinding<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 own UI elements to expose the undo
and redo
methods that you can call on the UndoManager
. There is also 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.