Search code examples
core-dataswiftuicloudkit

UndoManager Environment nil until view change - SwiftUI


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?


Solution

  • 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.