Shake to undo in a SwiftUI app

Shake to undo is a more than common UI on iOS. From Notes, Reminder and, well, mostly all apps, users expect it.

This is why for my latest app SharePal, I decided it would be a nice to have for when the user manages his data.

TL;DR: if you want, you can jump directly to the final view modifier implementation

SharePal app icon

SharePal

Lighting speed sharing!

Learn more

All starts with UndoManager

UndoManager is the core technology for implementing a modern undo/redo stack on Apple platforms.

Although I’m not very familiar with this class, I know that in a Core Data backed app, assigning one to a NSManagedObjectContext is enough to make it work.

As of SwiftUI, an UndoManager is available as an Environment Value since first version. So, setting it up is pretty straightforward:

import SwiftUI

struct ContentView: View {
    @Environment(\.undoManager)
    private var undoManager

    @Environment(\.managedObjectContext)
    private var managedObjectContext

    var body: some View {
        content
            .onAppear {
                managedObjectContext.undoManager = undoManager
            }
            .onChange(of: undoManager) { newUndoManager in
                managedObjectContext.undoManager = newUndoManager
            }
    }
}

If you target iOS 17+ only, this can be simplified to a single modifier:

    var body: some View {
        content
            .onChange(of: undoManager, initial: true) {
                managedObjectContext.undoManager = undoManager
            }
    }

Now, the SwiftUI UndoManager is wired your Core Data context. But shaking won’t do anything … yet.

Of course, if your app is not Core Data based, it’s your responsibility to register the proper events and callbacks to the UndoManager so that it’s capable to undo and redo things as the documentation describes it.

Shake, shake, shake!

Surprisingly, there are no Shake modifier for SwiftUI yet. But thankfully we can always count on Paul Hudson to solve SwiftUI lacks for us!

So we can rely on his onShake implementation, as described on his website.


    var body: some View {
        content
            (...)
            .onShake {
                guard let undoManager else {
                    return
                }
                if undoManager.canRedo {
                    undoManager.redo()
                } else if undoManager.canUndo {
                    undoManager.undo()
                }
            }
    }

Now, when we shake the device, and it’ll undo the last action. Shake again, and it’ll redo it!

Progress!

But we’re not there yet. In apps, we often get an alert asking if you really wanna commit an undo action after a shake, since undoing something because of an unwanted shake could be unfortunate.

Alerting the end user

Instead of calling UndoManager.undo() directly, we’d like to have a system alert similar to the one we can find in stocks apps like note:

The undo alert in Notes app: “Undo typing”

Making an alert with SwiftUI isn’t hard at all. And we can take advantage of UndoManager.undoMenuItemTitle and UndoManager.redoMenuItemTitle to populate the Alert title.

To do so, we’ll use a small struct to store the title and the type of action:

struct UndoRedoAction: Identifiable {
    enum UndoOrRedo {
        case undo, redo

        var localizable: LocalizedStringKey {
            switch self {
                case .undo:
                    return "Undo"
                case .redo:
                    return "Redo"
            }
        }
    }
    let id: UUID
    let title: String
    let undoOrRedo: UndoOrRedo

    private init(_ undoOrRedo: UndoOrRedo, title: String) {
        self.id = UUID()
        self.title = title
        self.undoOrRedo = undoOrRedo
    }

    static func undo(title: String) -> Self {
        .init(.undo, title: title)
    }

    static func redo(title: String) -> Self {
        .init(.redo, title: title)
    }
}
struct ContentView: View {

    (...)
    @State private var action: UndoRedoAction?

    var body: some View {
        content
            .onShake {
                guard let undoManager, action == nil else {
                    return
                }
                if undoManager.canRedo {
                    action = .redo(title: undoManager.redoMenuItemTitle)
                } else if undoManager.canUndo {
                    action = .undo(title: undoManager.undoMenuItemTitle)
                }
            }
            .alert(item: $action) { info in
                Alert(title: Text(info.title), primaryButton: .default(info.undoOrRedo.localizable, action: {
                    switch info.undoOrRedo {
                    case .undo:
                        undoManager?.undo()
                    case .redo:
                        undoManager?.redo()
                    }
                }), secondaryButton: .cancel())
            }
    }
}

Taking accessibility settings in account

Accessibility is important, but it’s easy to put aside. Not because you don’t want to work for it, but because we are not always aware of the existence of a setting to take into account.

That was my case. I discovered UIAccessibility.isShakeToUndoEnabled while preparing this blog post!

Turns out it’s very easy to add. It’s also important not to forget to subscribe to changes with UIAccessibility.shakeToUndoDidChangeNotification.

    (...)
    @State private var isShakeToUndoEnabled = UIAccessibility.isShakeToUndoEnabled

    var body: some View {
        content
            (...)
            .onReceive(NotificationCenter.default.publisher(for: UIAccessibility.shakeToUndoDidChangeNotification)) { _ in
                isShakeToUndoEnabled = UIAccessibility.isShakeToUndoEnabled
            }
            .onShake {
                guard isShakeToUndoEnabled, let undoManager else {
                    return
                }
                if undoManager.canRedo {
                    undoManager.redo()
                } else if undoManager.canUndo {
                    undoManager.undo()
                }
            }
    }

Packing it up in a modifier

Now that we have the whole Undo/Redo logic and UI, we can encapsulate this re-usable logic within a view modifier, so that call site becomes as clean as it can be:

import SwiftUI

struct ContentView: View {
    @Environment(\.managedObjectContext)
    private var managedObjectContext

    var body: some View {
        content
            .withUndoRedo { undoManager in
                managedObjectContext.undoManager = undoManager
            }
    }
}

where the modifier code will only be a packed-up of everything we’ve seen above:

extension View {
    public func withUndoRedo(_ undoManager: @escaping (UndoManager) -> Void) -> some View {
        modifier(UndoRedoAwareModifier(register: undoManager))
    }
}

struct UndoRedoAction: Identifiable {
    enum UndoOrRedo {
        case undo, redo
    }
    let id: UUID
    let title: String
    let undoOrRedo: UndoOrRedo

    private init(_ undoOrRedo: UndoOrRedo, title: String) {
        self.id = UUID()
        self.title = title
        self.undoOrRedo = undoOrRedo
    }

    static func undo(title: String) -> Self {
        .init(.undo, title: title)
    }

    static func redo(title: String) -> Self {
        .init(.redo, title: title)
    }
}

struct UndoRedoAwareModifier: ViewModifier {
    @Environment(\.undoManager)
    private var undoManager

    let register: (UndoManager) -> Void

    @State private var isShakeToUndoEnabled = UIAccessibility.isShakeToUndoEnabled
    @State private var action: UndoRedoAction?

    func body(content: Content) -> some View {
        content
            .onAppear {
                guard let undoManager else {
                    return
                }
                register(undoManager)
            }
            .onChange(of: undoManager) { newUndoManager in
                guard let newUndoManager else {
                    return
                }
                register(newUndoManager)
            }
            .onReceive(NotificationCenter.default.publisher(for: UIAccessibility.shakeToUndoDidChangeNotification)) { _ in
                isShakeToUndoEnabled = UIAccessibility.isShakeToUndoEnabled
            }
            .onShake {
                guard isShakeToUndoEnabled else {
                    return
                }
                guard let undoManager, action == nil else {
                    return
                }
                if undoManager.canRedo {
                    action = .redo(title: undoManager.redoMenuItemTitle)
                } else if undoManager.canUndo {
                    action = .undo(title: undoManager.undoMenuItemTitle)
                }
            }
            .alert(item: $action) { info in
                Alert(title: Text(info.title), primaryButton: .default(Text("Yes"), action: {
                    switch info.undoOrRedo {
                    case .undo:
                        undoManager?.undo()
                    case .redo:
                        undoManager?.redo()
                    }
                }), secondaryButton: .cancel(Text("No")))
            }
    }
}

Final words

I love how composable SwiftUI can become, and how you can resume some complex UI behavior with a single modifier line at the end, hiding complex and related logic in a single purposed file.

Although I do think that SwiftUI could provide a more straightforward API to implement this, it’s still not that complex to achieve.

SharePal app icon

SharePal

Lighting speed sharing!

Learn more

Don’t miss a thing!

Don't miss any of my indie dev stories, app updates, or upcoming creations!
Stay in the loop and be the first to experience my apps, betas and stories of my indie journey.

Thank you for registering!
You’ll retrieve all of my latest news!

Your email is sadly invalid.
Can you try again?

An error occurred while registering.
Please try again