Accessing the document in the SwiftUI macOS menu commands


Update: The solution in the article only works if the user is focused on a text field within your app. If you want a solution that will work regardless of the users focuse see KeyWindow a better way of exposing values from the Key window.
In macOS 12 and iPadOS 15 we also have a a new api to make this simpler.


With the SwiftUI Lifecycle apps there exists the .commands modifier of Scenes that enables us to populate the Menu in macOS. When working with a document app most (if not all) menu items that we will add to the menu need to interact with the document. The solution I present here only works if the user is focused on a text editing field.


For this example we will start with the template cross-platform-document app provided by Xcode 12.3.


@main
struct ExampleCommandsApp: App {
    var body: some Scene {
        DocumentGroup(
            newDocument: ExampleCommandsDocument()
        ) { file in
            ContentView(document: file.$document)
        }.commands {
            CommandMenu("Document") {
                Button("Test") {
                    print("Button Tapped")
                }
            }
        }
    }
}


However the closure we get here does not provide a binding to the document as this commands block is used for all the Scenes within the DocumentGroup. A useful post on apples developer forums provides a solution for this by using FocusedBinding, along with the FocusedValue.


FocusedValues work like EnvironmentValues but in in reverse. Rather than providing the value to the children it provides the value up to the parent. Unlike  Preferences however FocusedValues do not have a reduce function for combining multiple child values instead the child that is focused provides the value.


This means if we set a FocusedValue on our ContentView when the window for this view is focused then the FocusedValue it emits can be read elsewhere. To do this we need to first create a new type DocumentFocusedValueKey that conforms to FocusedValueKey.


Setting a FocusedValue


struct DocumentFocusedValueKey: FocusedValueKey {
    typealias Value = Binding<ExampleCommandsDocument>
}


Since some of our menus items will want to make changes to the document we should pass a Binding to the document rather than just the document itself. With this DocumentFocusedValueKey defined we now need to add an extension to the FocusedValues type so that we can read and write this value.


extension FocusedValues {
    var document: DocumentFocusedValueKey.Value? {
        get {
            return self[DocumentFocusedValueKey.self]
        }
        
        set {
            self[DocumentFocusedValueKey.self] = newValue
        }
    }
}


If you have used EnvironmentValues in the past you will see how similar this declaration is to that.


With this defined we can now provide a Binding to the document.


ContentView(
    document: file.$document
).focusedValue(\.document, file.$document)


Since we always want this value provided regardless what subview is focused we should set this on the top of our view hierarchy: directly on-top of the ContentView


Using the FocusedValue


To read a focused value that is a Binding<...> we should use the @ FocusedBinding property wrapper. However we can’t place this on properties of the App struct so instead we need to break out our commands into our own view so that we can wrap a property with this wrapper.


struct TestCommand: View {
    
    @FocusedBinding(\.document)
    var document: ExampleCommandsDocument?
    
    var body: some View {
        Button("ALL CAPS") {
            document?.text = document?.text.uppercased() ?? ""
        }.disabled(
            document?.text.uppercased() == document?.text
        )
    }
}


This command will be disabled if all the text is already uppercased, or if there is no open document window and when tapped will convert all the text in our document to be UPPER CASE.


This command can now be placed into our .commands block so that is shows up in the menu.


.commands {
    CommandMenu("Document") {
        TestCommand()
    }
}


You can find the full code example here.