MacOS menus are great and can offer a great deal of functionality without overloading the UI with toolbars and buttons. Some of the functionality will, however, require that your menu communicates with the active view. So we need a mechanism to make that connection.
I wrote about the basics of menus in my note on menus and the basics of context menus in my Context Menus note. This not expands on these (and their sample project) to add menu items that are aware of the active window and can communicate with it via the windows View Model. The principles are pretty straight forward, so I will only deal with the basics. What you do with your menu is entirely up to you but will follow these basic principles.
Menu Review
Since this was covered in the menus note, I won't go into any great detail. We need to tell our app that we have menus and where they are defined. For our purposes, I will define these in a new struct called VMMenus.
@main
struct MenusAppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
Menus()
VMMenus()
}
}
}
The basic VMMenus file starts out simply by defining a new menu structure for the View Model communication.
struct VMMenus: Commands {
var body: some Commands {
CommandMenu("View Model") {
Button("Action 1") {
print("Action 1")
}
Button("Action 2") {
print("Action 2")
}
Button("Action 3") {
print("Action 3")
}
}
}
}
This results in a basic menu.
At this point we have a menu, but no connection to the currently active window.
There is a mechanism built in that will do some of this for you based around @FocusedBinding and .focusedValue. The mechanism allows you to connect a value maintained in the application/menu to a bound value in the currently focused view. That works great for binding a value. If that value is a structure, we can even run methods in that structure, which sounds perfect for our needs.
It does not help us if what we want to do is run a function in the View Model. You cannot use @FocusedBinding to connect to an ObservableObject. For binding to a View Model, we will need a slightly different mechanism based around @FocusedObject and .focusedScemeObject.
View Model
So, the first place we need to start is by adding a View Model to the ContentView.
import SwiftUI
class ContentViewViewModel: ObservableObject {
@Published var vmId: UUID = UUID()
}
I have added a vmId property as we will use this later. We reference this view model in the ContentView. It is worth noting that the view model must conform to ObservableObject for this connection to work; you cannot use the new @Observable macro.
struct ContentView: View {
@StateObject var vm = ContentViewViewModel()
...
}
We now have menus and a view model, so the trick is to expose the view model to the menus to allow the menus to access values and functions in the view model. That is a two step process.
In the ContentView, we need to expose the view model. This is done with the .focusedObject modifier, which we can attach to any element in the view.
var body: some View {
VStack {
List(nameList, id: \.self, selection: $selection) { name in
Text(name.description(orderedBy: orderBy))
}
}
.padding()
.focusedSceneObject(vm)
}
The second step if to make the focusedObject available in the menu file using @FocusedObject.
struct VMMenus: Commands {
@FocusedObject private var vm: ContentViewViewModel?
var body: some Commands {
CommandMenu("View Model") {
Button("Action 1") {
print("Action 1")
}
Button("Action 2") {
print("Action 2")
}
Button("Action 3") {
print("Action 3")
}
}
}
}
The @FocusedObject variable is our bridge between the system level menus and the view specific view model. When the focus switches between windows, the vm variable will be updated to reference the view model of the currently active window. It needs to be defined as an optional because it is possible that the focused window will not have a view model of the correct type or there may not be a focused view.
Now we have the connection established, we can access public values and functions of the view model of the currently active view. We can start plugging functionality into the menus.
Accessing Values
Let's start with enabling and disabling the menu items.
Update the ContentViewViewModel to include some values to control the state of the menus.
class ContentViewViewModel: ObservableObject {
@Published var vmId: UUID = UUID()
@Published var action1Disabled: Bool = false
@Published var action2Disabled: Bool = true
@Published var action3Disabled: Bool = false
}
In the menu, we can now control the state of the menu items. We do this by updating the VMMenu file.
struct VMMenus: Commands {
@FocusedObject private var vm: ContentViewViewModel?
var body: some Commands {
CommandMenu("View Model") {
Button("Action 1") {
print("Action 1")
}
.disabled(vm?.action1Disabled ?? true)
Button("Action 2") {
print("Action 2")
}
.disabled(vm?.action2Disabled ?? true)
Button("Action 3") {
print("Action 3")
}
.disabled(vm?.action3Disabled ?? true)
}
}
}
If we have a view model, we will get the disabled state of the menu from there. If not, we default to disabling the menu on the assumption we do not have a view to communicate with. This results in two of our three menus being enabled.
Let's now add functionality to Action 1 that enables Action 2. In the real world, this could involve a lot of work before the menu item gets enabled, but we'll skip that distraction.
In the view model, we define a function to be called to do the work and result in the menu state being changed.
public func someAction1Action() {
print("Some action that results in Action 2 menu changing state")
action2Disabled = false
}
In the menus, we need to add code to call this function.
Button("Action 1") {
guard let vm else { return }
vm.someAction1Action()
}
.disabled(vm?.action1Disabled ?? true)
First we check that we have a view model and, if we do, we call the function we just defined to do whatever work we wanted done. When the function ends, Action 2 will be enabled.
That's pretty much all there is to it. Our menu items can now read published values in the view model and can call functions in the view model to change its state. Those functions might, for example, change values that are displayed on the current view or they might trigger a popup sheet. Whatever you need them to do.
Get The Code