Philip Pegden
Philip Pegden

Reputation: 2134

Capturing UndoManager from SwiftUI environment

I want to be able to access the UndoManager from inside my document model, so I can register undo actions from within the model:

// Assume I've extended MyDocument to conform to ReferenceFileDocument elsewhere...
final class MyDocument {
    private var undoManager: UndoManager?

    @Published var aNumber = 5 {
        willSet {
            if let undoManager = undoManager {
                let currentValue = self.aNumber
                undoManager.registerUndo(withTarget: self) { target in
                    target.aNumber = currentValue
                }
            }
        }
    }

    func setUndoManager(undoManager: UndoManager?) {
        self.undoManager = undoManager
    }
}

To be register the undoManager, I have tried this:

struct DocumentView: View {
    let document : MyDocument
    @Environment(\.undoManager) var undoManager
    
    var body: some View {
        MyDocumentEditor(document: document)
        .onAppear {
            document.setUndoManager(undoManager: undoManager)
        }
    }
}

When running my app and loading a saved document this works. But when starting from a new document the UndoManager is nil.

I've tried things like:

@Environment(\.undoManager) var undoManager { 
    didSet { 
        self.document.setUndoManager(undoManager: undoManager)
    }
}

My objective here is to try and keep as much logic in the model and the views focusing only on UI stuff as much as possible. I wish that ReferenceFileDocument gave a property to access its associated UndoManager as is available with NSDocument.

Upvotes: 3

Views: 2067

Answers (3)

MKasperczyk
MKasperczyk

Reputation: 76

After trying to figure this out for more than a day, my takeaway is that the UndoManager in the Environment is the one attached to the NSWindow where the view lives. My solution is:

protocol Undoable {
   func inverted() -> Self 
}

class Store<State, Action : Undoable> {

   var state : State 
   var reducer : (inout State, Action) -> Void 

   //...init...

   func send(_ action: Action, undoManager: UndoManager) {//passed as an argument
      reducer(&state, action)
      undoManager.registerUndo(withTarget: self){target in 
         target.send(action.inverted())
      }
   }

   //...other methods...

}

Store can of course be your document class. Now you can pass the UndoManager found in the environment from any view that sends actions (pay attentions to sheets and alerts though). Or you automate that step away:

class Dispatcher<State, Action : Undoable> : ObservableObject {

   let store : Store<State, Action> 
   let undoManager : UndoManager //see below
   //...init...

   func send(_ action: Action) {
      objectWillChange.send()
      store.send(action, undoManager: undoManager)
   }

}

struct ContentView<State, Action : Undoable> : View {

   @Environment(\.undoManager) var undoManager
   let document : Store<State, Action>

   var body : some View {
      ViewHierarchy().environmentObject(Dispatcher(store: document,
                                                   undoManager: undoManager)
   }

}

(maybe you'd need to put the Dispatcher into a StateObject, I didn't test that part because I'm happy passing the undo manager as a function argument in my small app).

Upvotes: 0

Asperi
Asperi

Reputation: 257729

It looks more natural for SwiftUI to use the following approach

var body: some View {
    TopLevelView(document: document, undoManager: undoManager)
}

and

struct TopLevelView: View {
    @ObservedObject var document : MyDocument
    var undoManager: UndoManager?

    init(document: MyDocument, undoManager: UndoManager?) {
       self.document = document
       self.undoManager = undoManager

       self.setUndoManager(undoManager: undoManager)
    }

    // ... other code
}

Upvotes: 2

Philip Pegden
Philip Pegden

Reputation: 2134

I have found a solution to this - although it doesn't feel right. At the top level of the View I pass the undoManager to a property I hold on the document:

struct ContentView: View {
    let document: MyDocument
    @Environment(\.undoManager) var undoManager

    var body: some View {
        document.setUndoManager(undoManager: undoManager)
        return TopLevelView(document: document)
    }
}

Upvotes: 0

Related Questions