4

The @Environment(.undoManager) is nil when the app cold starts and remains nil until the user change views. When the view is changed, undoManager becomes set and undo functionality is available.

In the below gif you can see how undo is not available until the user switches to a new view "Focus" and then back.

Here's the SwiftUI code where the undoManager environment is set:

   @Environment(\.managedObjectContext) private var viewContext
   @Environment(\.undoManager) private var undoManager

[...]

listOfCards.task{
    viewContext.undoManager = undoManager //nil when starting from cold start
}

Example

The @main App instantiates a NSPersistentCloudKitContainer and it sets it as the managedObjectContext

mainView
   .environment(\.managedObjectContext, persistenceController.container.viewContext)

Here's some of the things I've tried so far to solve this:

A) Adding in each view the following...

.onChange(of: undoManager) { _ in
print(undoManager)
viewContext.undoManager = undoManager}

to detect when the undoManager is set and link it to the viewContext. No luck, does not get called.

B) Also have tried adding an observer:

private let undoObserver = NotificationCenter.default.publisher(for: .NSUndoManagerCheckpoint)


.onReceive(undoObserver, perform: { _ in
print(undoManager)
viewContext.undoManager = undoManager
})

Also without luck, the only thing it works is going to another view and back, then UndoManager is set correctly. Any ideas? Am I missing something obvious?

MMV
  • 297
  • 2
  • 9
  • With SwiftUI the best option is to use loose coupling and avoid maintaining mirrors of the “@“ variables inside a ViewModel. Instead pass the "@" references directly to the operation that needs them when they are required. e.g. something like viewModel.yourOp(undoManager: swiftUiUndoManager, focus: focus, otherAppOpSpecificParams ….) – shufflingb Aug 21 '22 at 16:21
  • Wherever you are creating the context can you not assign an undo manager to that context. And then pass that context and undo manager as environment variables? So that they remain connected. – user1046037 Aug 24 '22 at 03:40
  • Tried that as well as part of the @main App. Does not seem to work. – MMV Aug 24 '22 at 03:53
  • FWIW, I had a similar issue where `@Environment(\.undoManager)` was `nil` in a ` `View` at some times and not at other times. I believe I narrowed it down to being `nil` when another `View` was pushed onto my `NavigationView`, and this caused a problem for me because I was using a callback passed in from that parent `View` which accessed the `UndoManager`. When the callback executed, `@Environment(\.undoManager)` was `nil`. I eventually got around it by also putting `@Environment(\.undoManager)` into the child `View`, and passing it from there into the callback. ‍♂️ – Graham Lea May 16 '23 at 08:05

3 Answers3

5

Well, there is this nice extension to View which helped me a lot already: Hosting Controller When Using iOS 14 @main :

extension View {
    func withHostingWindow(_ callback: @escaping (NSWindow?) -> Void) -> some View {
        self.background(HostingWindowFinder(callback: callback))
    }
}

struct HostingWindowFinder: NSViewRepresentable {
    typealias NSViewType = NSView
    
    var callback: (NSWindow?) -> ()
    
    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }
    
    func updateNSView(_ nsView: NSView, context: Context) {
    }
}

With that you can do something like this:

@State private var undoManager: UndoManager? 

to replace the environment undo manager. And then in the body:

.withHostingWindow({ window in                                  // set the undo manager from window
    undoManager = window?.undoManager
})

The first few calls to this closure will give nil for the undoManager but before any interaction can occur the undoManager var will be properly set.

FPP
  • 278
  • 3
  • 7
  • This worked for me, I've been struggling for hours with undoManager == nil when using the recommended "@Environment(\.undoManager) var undoManager" pattern. Why does the solution in this answer work? Is there documentation anywhere about which contexts actually have access to the undoManager from the environment? – Andreas Jansson Dec 14 '22 at 19:37
1

task/onAppear doesn't run on an empty view, so you could show a different view when there are no items like a Text("No items") then use onAppear on that instead.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • Thanks for the answer, I did try that. However I get the error "Key path value type 'WritableKeyPath' cannot be converted to contextual type 'KeyPath'" and it does not allow me to set it. – MMV Aug 22 '22 at 14:54
  • Sorry I didn't realise `undoManager` can't be set. I've updated my answer with a possible workaround. – malhal Aug 22 '22 at 16:11
0

In the end I managed it by setting the following on my TextDetailView view (instead of doing it from the list view):

.onChange(of: focusedField, perform: {focus in
                        if focus == nil{
//                            print("UNDO NoteDetail OFF | CoreData ON")
                            //Means we're no longer editing - set core data undo
                            viewContext.undoManager = undoManager //When doing edits in text, remove the core data undo
                        }else{
//                            print("UNDO NoteDetail ON | CoreData OFF")
                            //Remove core data undo
                            viewContext.undoManager = nil //When doing edits in text, remove the core data undo
                        }
                    })
                    .onAppear(perform: {
                        if focusedField == nil{
//                            print("UNDO NoteDetail OFF | CoreData ON")
                            viewContext.undoManager = undoManager
                        }
                    })

Works great!

MMV
  • 297
  • 2
  • 9