Have you already tried to create a macOS document based app with SwiftUI and the new App
protocol? Well, there are some problems to solve esp. if you want to use menus focused on the currently open document.
In the old app cycle with AppDelegate / SceneDelegate
you still had to use the storyboard to define the menu items and link them to your methods in your document class derived from NSDocument
. Enabling and disabling of the menu item according to the open documents was automatically managed by the frameworks.
That’s not the case in the new App
protocol of SwiftUI anymore. Instead, you can define your menus and menu items quite nicely in your App
struct conforming to the new App
protocol but there is no automatic link to the current document in focus.
I investigated a little bit about this problem and found this nice post (https://lostmoa.com/blog/AccessingTheDocumentIntheWindowCommands/) about passing the currently focused document to the menu commands.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct DocumentFocusedValueKey: FocusedValueKey { typealias Value = Binding<ExampleDocument> } extension FocusedValues { var document: DocumentFocusedValueKey.Value? { get { return self[DocumentFocusedValueKey.self] } set { self[DocumentFocusedValueKey.self] = newValue } } } |
This works fine for me and I have no need to implement the even more advanced window tracking strategy proposed in that blog post. With the above mentioned extension I have access to the currently focused document in my menu command. In the document struct I store a reference to my model and I can operate on that in the menu command. Very nice.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@main struct PropertyApp: App { var body: some Scene { DocumentGroup(newDocument: ExampleDocument()) { file in ContentView() .focusedValue(\.document, file.$document) } .commands { CommandMenu("My Menu") { MyCommand() } } } } struct MyCommand: View { @FocusedBinding(\.document) var document: ExampleDocument? var body: some View { Button("My Command…") { if document != nil { ... } } .disabled(document == nil) // not enabled is we do not have a document } } |
But I also want to use the undo manager in my menu command. Surprisingly, that’s not available in the command views (it’s set to nil
) when you try to access it in the proposed way:
1 |
@Environment(\.undoManager) var undoManager |
Too bad. Then I found this nice post (https://stackoverflow.com/questions/63273631/hosting-controller-when-using-ios-14-main/63276688#63276688) giving you access to the window the main content view belongs to.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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) { } } |
The window do know the undo manager and I can store that in my document struct to give access to it in my command views:
1 2 3 4 5 6 7 8 9 10 11 |
... ContentView() .withHostingWindow({ window in if let controller = window?.windowController { controller.windowFrameAutosaveName = NSWindow.FrameAutosaveName("MyWindow") } file.document.undoManager = window?.undoManager // save the undo manager for the commands file.document.fileURL = file.fileURL // and the url of this document }) .focusedValue(\.document, file.$document) ... |
As you see you can also do some other useful things there like saving the window sizes automatically with NSWindow.FrameAutsaveName(…)
and getting the url of your document file. Even nicer.
Btw: The undoManager?.setActionName(…)
seems to be broken or not implemented here as of Big Sur / Xcode 12.5 / SwiftUI 2. It has no effect.
Hi there
Thanks for the post.
I managed to set undoManager on my document using:
@Environment(\.undoManager) var undoManager
And…
.onChange(of: undoManager) { undoManager in
document.undoManager = undoManager }
…within my ContentView.
Best
Giles