- Published on
Implementing Undo/Redo in SwiftUI with UndoManager
- Authors

- Name
- Mick MacCallum
- @0x7fs
Professional apps need undo/redo functionality. Whether you're building a drawing app, text editor, or any tool where users make changes, giving them the ability to revert mistakes dramatically improves the user experience.
SwiftUI doesn't automatically provide undo/redo like UIKit's UITextView does, but we can integrate Foundation's UndoManager to add this capability to any SwiftUI view. The challenge is bridging UndoManager's imperative API with SwiftUI's declarative state management.
Understanding UndoManager
UndoManager is a Foundation class that maintains a stack of undo actions. When you perform an action, you register the inverse operation with the undo manager. If the user undoes, that inverse operation runs.
Key concepts:
- Register undo: Store the previous state when making changes
- Undo: Roll back to the previous state
- Redo: Re-apply an undone change
- Grouping: Combine multiple changes into a single undo action
Creating an UndoManager Environment
First, make UndoManager available via SwiftUI's environment:
import SwiftUI
struct UndoManagerKey: EnvironmentKey {
static let defaultValue: UndoManager? = nil
}
extension EnvironmentValues {
var undoManager: UndoManager? {
get { self[UndoManagerKey.self] }
set { self[UndoManagerKey.self] = newValue }
}
}
Injecting UndoManager
Provide the undo manager at your app or scene level:
@main
struct MyApp: App {
@StateObject private var undoManager = UndoManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.undoManager, undoManager)
}
}
}
Or for a specific view:
struct EditorView: View {
@StateObject private var undoManager = UndoManager()
var body: some View {
DrawingCanvas()
.environment(\.undoManager, undoManager)
}
}
Basic Text Field with Undo
Here's a simple example with a text editor:
struct UndoableTextEditor: View {
@State private var text = ""
@Environment(\.undoManager) var undoManager
var body: some View {
VStack {
TextEditor(text: Binding(
get: { text },
set: { newValue in
registerUndo(oldValue: text, newValue: newValue)
text = newValue
}
))
.border(Color.gray, width: 1)
.frame(height: 200)
HStack {
Button("Undo") {
undoManager?.undo()
}
.disabled(!(undoManager?.canUndo ?? false))
Button("Redo") {
undoManager?.redo()
}
.disabled(!(undoManager?.canRedo ?? false))
}
.padding()
}
.padding()
}
func registerUndo(oldValue: String, newValue: String) {
undoManager?.registerUndo(withTarget: self) { _ in
self.text = oldValue
// Register redo
self.undoManager?.registerUndo(withTarget: self) { _ in
self.text = newValue
}
}
}
}
Observable Object with Undo
For more complex state, use an ObservableObject:
class DrawingState: ObservableObject {
@Published var shapes: [Shape] = []
var undoManager: UndoManager?
struct Shape: Identifiable, Equatable {
let id = UUID()
var position: CGPoint
var color: Color
}
func addShape(_ shape: Shape) {
let oldShapes = shapes
undoManager?.registerUndo(withTarget: self) { target in
target.shapes = oldShapes
target.undoManager?.registerUndo(withTarget: target) { target in
target.shapes = target.shapes + [shape]
}
}
shapes.append(shape)
}
func removeShape(_ shape: Shape) {
guard let index = shapes.firstIndex(of: shape) else { return }
let oldShapes = shapes
let removedShape = shapes[index]
undoManager?.registerUndo(withTarget: self) { target in
target.shapes = oldShapes
target.undoManager?.registerUndo(withTarget: target) { target in
target.shapes.remove(at: index)
}
}
shapes.remove(at: index)
}
func moveShape(_ shape: Shape, to position: CGPoint) {
guard let index = shapes.firstIndex(of: shape) else { return }
let oldPosition = shapes[index].position
undoManager?.registerUndo(withTarget: self) { target in
target.shapes[index].position = oldPosition
target.undoManager?.registerUndo(withTarget: target) { target in
target.shapes[index].position = position
}
}
shapes[index].position = position
}
}
struct DrawingView: View {
@StateObject private var state = DrawingState()
@Environment(\.undoManager) var undoManager
var body: some View {
VStack {
Canvas { context, size in
for shape in state.shapes {
context.fill(
Circle().path(in: CGRect(
x: shape.position.x - 25,
y: shape.position.y - 25,
width: 50,
height: 50
)),
with: .color(shape.color)
)
}
}
.background(Color.gray.opacity(0.1))
.gesture(
DragGesture(minimumDistance: 0)
.onEnded { value in
let shape = DrawingState.Shape(
position: value.location,
color: .random
)
state.addShape(shape)
}
)
HStack {
Button {
undoManager?.undo()
} label: {
Image(systemName: "arrow.uturn.backward")
}
.disabled(!(undoManager?.canUndo ?? false))
Button {
undoManager?.redo()
} label: {
Image(systemName: "arrow.uturn.forward")
}
.disabled(!(undoManager?.canRedo ?? false))
Spacer()
Button("Clear All") {
state.shapes.removeAll()
}
}
.padding()
}
.onAppear {
state.undoManager = undoManager
}
}
}
extension Color {
static var random: Color {
Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
Grouping Multiple Changes
Sometimes you want to undo several related changes as a single action:
class DocumentState: ObservableObject {
@Published var title = ""
@Published var content = ""
@Published var tags: [String] = []
var undoManager: UndoManager?
func updateDocument(title: String, content: String, tags: [String]) {
let oldTitle = self.title
let oldContent = self.content
let oldTags = self.tags
undoManager?.beginUndoGrouping()
undoManager?.registerUndo(withTarget: self) { target in
target.title = oldTitle
target.content = oldContent
target.tags = oldTags
}
self.title = title
self.content = content
self.tags = tags
undoManager?.endUndoGrouping()
undoManager?.setActionName("Edit Document")
}
}
Keyboard Shortcuts
Add standard keyboard shortcuts for undo/redo:
struct EditorWithShortcuts: View {
@StateObject private var state = DrawingState()
@Environment(\.undoManager) var undoManager
var body: some View {
DrawingCanvas(state: state)
.onAppear {
state.undoManager = undoManager
}
// Cmd+Z for undo
.keyboardShortcut("z", modifiers: .command)
.onCommand(#selector(NSResponder.undo(_:))) {
undoManager?.undo()
}
// Cmd+Shift+Z for redo
.keyboardShortcut("z", modifiers: [.command, .shift])
.onCommand(#selector(NSResponder.redo(_:))) {
undoManager?.redo()
}
}
}
Gesture-Based Undo
Implement shake-to-undo (common on iOS):
struct ShakeDetector: UIViewControllerRepresentable {
let onShake: () -> Void
func makeUIViewController(context: Context) -> ShakeViewController {
ShakeViewController(onShake: onShake)
}
func updateUIViewController(_ uiViewController: ShakeViewController, context: Context) {}
}
class ShakeViewController: UIViewController {
let onShake: () -> Void
init(onShake: @escaping () -> Void) {
self.onShake = onShake
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
onShake()
}
}
}
struct ShakeToUndoView: View {
@Environment(\.undoManager) var undoManager
var body: some View {
ZStack {
// Your content
ShakeDetector {
undoManager?.undo()
}
.frame(width: 0, height: 0)
}
}
}
Undo/Redo Menu
Create a menu showing available undo/redo actions:
struct UndoRedoMenu: View {
@Environment(\.undoManager) var undoManager
var body: some View {
Menu {
Button {
undoManager?.undo()
} label: {
Label(
undoManager?.undoActionName ?? "Undo",
systemImage: "arrow.uturn.backward"
)
}
.disabled(!(undoManager?.canUndo ?? false))
Button {
undoManager?.redo()
} label: {
Label(
undoManager?.redoActionName ?? "Redo",
systemImage: "arrow.uturn.forward"
)
}
.disabled(!(undoManager?.canRedo ?? false))
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
Complete Example: Note Editor
Here's a full example with a note editor:
class Note: ObservableObject {
@Published var title: String
@Published var content: String
var undoManager: UndoManager?
init(title: String = "", content: String = "") {
self.title = title
self.content = content
}
func setTitle(_ newTitle: String) {
let oldTitle = title
undoManager?.registerUndo(withTarget: self) { target in
target.title = oldTitle
target.undoManager?.registerUndo(withTarget: target) { target in
target.title = newTitle
}
}
undoManager?.setActionName("Edit Title")
title = newTitle
}
func setContent(_ newContent: String) {
let oldContent = content
undoManager?.registerUndo(withTarget: self) { target in
target.content = oldContent
target.undoManager?.registerUndo(withTarget: target) { target in
target.content = newContent
}
}
undoManager?.setActionName("Edit Content")
content = newContent
}
}
struct NoteEditorView: View {
@StateObject private var note = Note()
@Environment(\.undoManager) var undoManager
var body: some View {
VStack {
TextField("Title", text: Binding(
get: { note.title },
set: { note.setTitle($0) }
))
.textFieldStyle(.roundedBorder)
.font(.title)
TextEditor(text: Binding(
get: { note.content },
set: { note.setContent($0) }
))
.border(Color.gray.opacity(0.3), width: 1)
HStack {
Button {
undoManager?.undo()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.disabled(!(undoManager?.canUndo ?? false))
Button {
undoManager?.redo()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.disabled(!(undoManager?.canRedo ?? false))
Spacer()
if let actionName = undoManager?.undoActionName {
Text(actionName)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
}
.padding()
.onAppear {
note.undoManager = undoManager
}
}
}
Important Considerations
Memory Management: UndoManager holds strong references to targets. Use [weak self] if registering closures that capture self to avoid retain cycles.
Action Names: Set descriptive names with setActionName() for better UX in undo menus.
Leveling: By default, UndoManager allows unlimited undo levels. Set levelsOfUndo to limit memory usage:
let undoManager = UndoManager()
undoManager.levelsOfUndo = 20
Groups: Use beginUndoGrouping() and endUndoGrouping() to combine related changes.
State Restoration: UndoManager doesn't persist across app launches. Save undo state if needed.
Best Practices
Do:
- Always register redo within your undo closure
- Set meaningful action names
- Group related changes
- Disable undo buttons when unavailable
- Test undo/redo thoroughly
Don't:
- Register trivial actions (like selection changes)
- Create circular undo references
- Forget to inject undoManager into your state objects
- Overcomplicate the undo logic
Adding undo/redo makes your SwiftUI app feel professional and gives users confidence to experiment without fear of losing work.
Continue Learning
Detecting When App Enters Background and Saving State in SwiftUI
Learn how to detect app lifecycle changes in SwiftUI, save state when your app enters the background, and restore it when users return, using scenePhase and UIApplication notifications.
Detect Successful Share Sheet Completion in SwiftUI
Learn how to detect when a user successfully shares content using a share sheet in SwiftUI by wrapping UIActivityViewController.
Prevent Drag-to-Dismiss on SwiftUI Sheets
Learn how to prevent users from dismissing modal sheets by swiping down in SwiftUI using interactiveDismissDisabled.
