avatar
Published on

Implementing Undo/Redo in SwiftUI with UndoManager

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @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.