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
Lighting speed sharing!
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:
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
Lighting speed sharing!
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.