4

Why does the @Environment UndoManager not update its canUndo property when it has actions in its stack? I have a view that has a child that can utilize the un/redo functionality, but for some reason I can't disable the undo button based on the manager.

struct MyView: View {
    @Environment(\.undoManager) var undoManager: UndoManager?

    var body: some View {
        Button("Undo") { ... }
            .disabled(!self.undoManager!.canUndo)
    }
}
PointOfNilReturn
  • 416
  • 4
  • 13

2 Answers2

13

UndoManager.canUndo is not KVO compliant, so use some notification publisher to track state, like below

struct MyView: View {
    @Environment(\.undoManager) var undoManager
    @State private var canUndo = false

    // consider also other similar notifications
    private let undoObserver = NotificationCenter.default.publisher(for: .NSUndoManagerDidCloseUndoGroup)

    var body: some View {
        Button("Undo") { }
            .disabled(!canUndo)
            .onReceive(undoObserver) { _ in
                self.canUndo = self.undoManager!.canUndo
            }
    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thanks for the comment! This mostly works. It seems that the `undoManager` never actually closes the group. Even when all actions have been undone, it still triggers the did close notification and returns true for `canUndo`. But this notification allowed for me to keep track of undoable actions in some other state variables. – PointOfNilReturn Apr 30 '20 at 05:12
  • 2
    I found this works better with [ ***private let undoObserver = NotificationCenter.default.publisher(for: .NSUndoManagerCheckpoint)*** ] You're then able to get the canUndo and canRedo states correctly – Paul Ollivier Feb 05 '21 at 11:36
  • 7
    @PaulOllivier if you query canRedo on receive NSUndoManagerCheckpoint this will cause an infinite loop, as canRedo fires the checkpoint notification: "Posted whenever an NSUndoManager object opens or closes an undo group (except when it opens a top-level group) and when checking the redo stack in canRedo." – OliverD Feb 27 '21 at 21:16
  • @OliverD Thanks I hadn't noticed that! – Paul Ollivier Feb 28 '21 at 23:14
0

When it comes to canRedo I tried multiple things, and what I ended up with is this - so observing viewModel (or document or any other undo-supporting data source) and updating canUndo/canRedo in reaction to it's change:

struct MyView: View {
    @ObservedObject var viewModel: ViewModel

    @Environment(\.undoManager) private var undoManger: UndoManager!
    @State private var canUndo = false
    @State private var canRedo = false
    
    var body: some View {
        RootView()
            .onReceive(viewModel.objectWillChange) { _ in
                canUndo = undoManger.canUndo
                canRedo = undoManger.canRedo
            }

        if canUndo {
            Button(
                action: { undoManger?.undo() },
                label: { Text("Undo") }
            )
        }
        if canRedo {
            Button(
                action: { undoManger?.redo() },
                label: { Text("Redo") }
            )
        }
        ...

I also wrapped it in a standalone button (without overgeneralizing the implementation above my own needs) that eliminates the boilerplate from my view and keeps complexity more private so it ends up like this for me:

struct MyView: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
       RootView()
       UndoManagerActionButton(
           .undo,
           willChangePublisher: viewModel.objectWillChange
       )
       UndoManagerActionButton(
           .redo,
           willChangePublisher: viewModel.objectWillChange
       )
       ...
Maciek Czarnik
  • 5,950
  • 2
  • 37
  • 50
  • It's not clear what you mean by "so observing viewModel (or document or any other undo-supporting data source)" as your code doesn't show what your ViewModel is. Anything you can point me to explain this? – chasew Dec 12 '21 at 20:47