MacMenuBar

main

Bringing the Mac Menu Bar into the SwiftUI age.
chipjarred/MacMenuBar

MacMenuBar

MacMenuBar is a Swift Package for creating and working with macOS's main menu in SwiftUI apps without a Storyboard. It let's you use the same declarative style you use for your SwiftUI views to create your menus.

Let's dive directly into how to use it.

Creating a MacMenuBar-based Project.

To use MacMenuBar you will need to create an Xcode project configured to use it. The easiest way to do that is by installing the Xcode project templates from this repo using the command line:

git clone https://github.com/chipjarred/MacMenuBar.git
cd MacMenuBar/Templates
./install.bash

Once the templates are installed, when you create a new macOS app in Xcode, a template named "App using MacMenuBar" will be one of your options. When you create a new project using that template, the only thing you'll need to do is to add a package dependency for MacMenuBar. If you don't already know how to do that, the README.md file in the newly created project contains the instructions you need.

If you'd prefer to set up your project manually, see the instructions in ManualSetup.md.

Creating a Simple Menu Bar

Now we'll create a minimalist menu bar with just the application menu and the usual File menu to get started.

Create a new file called MenuBar.swift with the following code

import MacMenuBar

struct MainMenuBar: MenuBar
{
    public var body: StandardMenuBar
    {
        StandardMenuBar
        {
            StandardMenu(title: "$(AppName)")
            {
                TextMenuItem(title: "About $(AppName)", action: .about)
                                
                MenuSeparator()
                
                TextMenuItem(title: "Quit $(AppName)", action: .quit)
            }
            
            StandardMenu(title: "File")
            {
                TextMenuItem(title: "New", action: .new)
                TextMenuItem(title: "Open...", action: .open)
                StandardMenu(title: "Open Recent...")
                
                MenuSeparator()
                
                TextMenuItem(title: "Close", action: .close)
                TextMenuItem(title: "Save...", action: .save)
                TextMenuItem(title: "Save As...", action: .saveAs)
                TextMenuItem(title: "Revert to Saved", action: .revert)
                
                MenuSeparator()
                
                TextMenuItem(title: "Page Setup...", action: .pageSetup)
                TextMenuItem(title: "Print", action: .print)
            }
        }
    }
}

This kind of looks like how you write your SwiftUI View code, doesn't it?

At the moment, MacMenuBar isn't hooked into SwiftUI. For a pure SwiftUI project, we do that in our @main App:

import SwiftUI
import MacMenuBar

@main
struct MyApp: App
{
    init() { setMenuBar(to: MainMenuBar()) } // <- ADD THIS
    
    var body: some Scene {
        WindowGroup {
            return ContentView()
        }
    }
}

If your project uses the old model of providing an app delegate instead of @main, add the call to setMenuBar(to:) at the end of AppDelegate.applicationDidFinishLaunching():

func applicationDidFinishLaunching(_ aNotification: Notification)
{
    // Create the SwiftUI view that provides the window contents.
    let contentView = MainContentView()

    // Create the window and set the content view.
    window = NSWindow(
        contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
        styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
        backing: .buffered,
        defer: false
    )
    window.isReleasedWhenClosed = false
    window.center()
    window.setFrameAutosaveName("Main Window")
    window.contentView = NSHostingView(rootView: contentView)
    window.makeKeyAndOrderFront(nil)
        
    setMenuBar(to: MainMenuBar()) // <- ADD THIS
}

Now your shiny new, albeit bare bones menu bar is set up. Run the application to see it work. Of course, the only thing you can do from it right now is to quit, and display the About box, but in a brand new project that's all you could really do with the one Apple provided in the Main.storyboard file we deleted.

What have we done?

Before we start adding functionality let's take a closer look at a few things, so go back to MenuBar.swift.

You'll see that MainMenuBar.body returns a StandardMenuBar. Think of it as sort of analogous to HStack, but for menus and we don't nest it inside other menus. It's strictly a top-level thing representing the menu bar.

Our actual menus in the menu bar are declared as StandardMenu. These correspond to the main items you see in the menu bar when you haven't clicked on anything. You provide each one with the String to use for its title, however, that text is a bit smarter than your average String, because it actually does two things for you automatically.

  1. It looks up a localized version of the String you specify as the title. It uses the Menus.strings file in your app's bundle (or one of the appropriate localization subdirectories), if you include it. If it finds one, it uses the localized string from it. If not, it uses the string as-is for the next step.

  2. It does string substituation within the string returned from step 1, whether it's the localized string or not. The title string for our application menu contains "$(AppName)" . This is automatically replaced by your program's name. The substitution strings take the form $(SomeName). MacMenuBar looks for a value to substitute for whatever is in the parentheses. It first checks to see if it's a pre-defined symbol, which AppName is, and if not it looks for a variable with a matching name in your application's process environment (ProcessInfo.processInfo.environment). So "$(PATH)" evaluates to whatever the PATH variable is set to in your app's environment. If no such variable is found, then it evaluates to itself, as-is.

The titles for all menus and menu items in MacMenuBar work this way. This makes localization easy. More substitution options are planned as well.

Within the top-level StandardMenu instances we have two kinds of menu items: TextMenuItem and MenuSeparator.

TextMenuItem is your basic, most commonly used menu item. You give it a title and an action. The code above uses standard menu actions, but as you'll see later, you can define your own. For a complete list of the standard ones, see StandardMenuItemAction.swift. These standard menu item actions use the usual responder chain you're familiar with from ordinary Cocoa apps, and the usual key equivalents that Mac users expect.

MenuItemSeparator gives the familiar dividing line that separates groups of related menu items within a menu.

In addition to menu items, we can also nest menus within menus to create submenus by simply using another StandardMenu instead of a menu item type. The "Open Recent..." submenu in our "File" menu is an example of this.

Finally the initializer we added to MyApp is the thing that actually sets the application's main menu to our MainMenuBar.

Custom Menu Actions

It's great that we have a menu bar now, and that we've created it in a simple declarative way, but it doesn't do much. Let's remedy that. You do that by creating a menu item with an action associated with it.

The easiest way to define a custom action is with a closure. Let's say you have a showLog() function that displays a log window, and you want to add a Debug menu item to show the log. At the end of MainMenuBar.body you can add your conditionally-compiled Debug menu:

import MacMenuBar

struct MainMenuBar: MenuBar
{
    public var body: StandardMenuBar
    {
        StandardMenuBar
        {
            StandardMenu(title: "$(AppName)")
            {
                TextMenuItem(title: "About $(AppName)", action: .about)
                                
                MenuSeparator()
                
                TextMenuItem(title: "Quit $(AppName)", action: .quit)
            }
            
            StandardMenu(title: "File")
            {
                TextMenuItem(title: "New", action: .new)
                TextMenuItem(title: "Open...", action: .open)
                StandardMenu(title: "Open Recent...")
                
                MenuSeparator()
                
                TextMenuItem(title: "Close", action: .close)
                TextMenuItem(title: "Save...", action: .save)
                TextMenuItem(title: "Save As...", action: .saveAs)
                TextMenuItem(title: "Revert to Saved", action: .revert)
                
                MenuSeparator()
                
                TextMenuItem(title: "Page Setup...", action: .pageSetup)
                TextMenuItem(title: "Print", action: .print)
            }
        }
        
        // >>>>>>>> ADDED THE FOLLOWING BIT <<<<<<<<<<
        #if DEBUG
        StandardMenu(title: "Debug")
        {
            TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
                showLog()
            }
        }
        #endif
    }
}

As you can see, this adds a new menu called Debug to the menu bar. It contains a menu item called Show Log, but what's different from our previous TextMenuItem examples is that now we're specifying both a key equivalent for the menu item, and an action closure that is called when the menu item is selected.

Note that when specifying the key equivalent, we used a lowercase "L". Using uppercase would imply that the shift key would also need to be pressed. "L" and .shift + "l" are equivalent in this context. If at least one of .command, .option or .control is not specified, .command is implied, so you if you use just "l" for your key equivalent, it will be treated as .command + "l". Also note that we don't need to muck about with NSEvent modifier flags. We can specify the modifiers by naming them and adding them to the base key.

The StandardMenuItemAction examples we delcared in our application and File menus already have the standard key equivalents implicitly associated with them, so they don't need to be specified.

If you don't want a key equivalent for your closure menu item, you can specify .none.

Of course, you can also specify an action using an arbitrary Selector.

Dynamically Enabling/Disabling Menu Items

A lot of menu item updating, especially enabling and disabling them, happens automatically via Cocoa's Responder Chain, but that works based whether some object in the responder chain responds to the Objective-C selector associated with a given menu. That's the way Cocoa apps work in Swift too, and it applies to Selector-based menus actions in MacMenuBar, if the AppKit objects underlying your SwiftUI views respond to the appropriate selectors. On the other hand, closure-based menu actions in MacMenuBar, such as the one we wrote in the previous example, require more explicit handling.

Suppose we just want to disable the "Show Log" menu item once the log is shown. We can specify that behavior using the afterAction method, which takes the menu item itself as its parameter:

            #if DEBUG
            StandardMenu(title: "Debug")
            {
                TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
                    showLog()
                }
                .afterAction { $0.canBeEnabled = false } // <- ADDED THIS
            }
            #endif

As its name suggests, .afterAction is called after the menu item calls its action closure. There is also a .beforeAction method that works the same way, except that it is called immediately before the action closure is called.

Now when you select "Show Log" from the "Debug" menu, that item will become disabled. Of course, that's not actually what we want, because it will remain disabled even after you close the log window. We'd prefer for it to be enabled or disabled based on whether the log window is currently visible. Instead of .afterAction we can use .enabledWhen:

            #if DEBUG
            StandardMenu(title: "Debug")
            {
                TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
                    showLog()
                }
                .enabledWhen { !logWindow.isVisible } // <- CHANGED THIS
            }
            #endif

.enabledWhen is called whenever the item's parent menu is opened to determine whether or not that item is enabled. The "Show Log" menu item will now be disabled whenever the log window is visible, and enabled whenever it's hidden.

The actual process MacMenuBar uses for determining whether the menu should be enabled or disabled is:

  1. If the menu item does not have an associated action, then it is disabled. If it does have an action, validation proceeds to the next step.

  2. If the menu item's action is a Selector-based action and no object in the responder chain responds to that selector, then the menu item is disabled. If some responder in the chain does respond to that selector, or if the action is a closure action, validation proceeds to the next step.

  3. If the menu item's .canBeEnabled property is false, then the menu item is disabled. If it's true, which is the default, the validation process continues to the next step.

  4. If .enabledWhen has not been used to set a validation closure for the item, then the item is enabled. If it does have a validation closure, validation proceeds to the next step.

  5. The validation closure set with .enabledWhen is used to determine whether or not the menu item is enabled. If the closure returns true, it is enabled. If it returns false, it is disabled.

The above list extends the logic of Cocoa's built-in menu-enabling rules. Of special note is that if both .canBeEnabled and .enabledWhen are used and .canBeEnabled is false, that overrules the closure for .enabledWhen. In most cases, it makes sense to use one or the other, but not both, but it does give you a way to tell MacMenuBar to unconditionally disable a menu item, even if it is using .enableWhen.

Dynamically Updating Menu Item Names

In its current state, our "Show Log" menu item is certainly usable now, but is it what Mac users really expect?

Maybe it would be better to change the menu item to "Hide Log" when the log is visible, and back to "Show Log" when it's not. That way we can toggle the log window's visible state with a key equivalent. We can do that by modifying our action closure to either show or hide the log window based on its current visibility, and use the .updatingTitleWith method to specify a closure for updating the title:

            #if DEBUG
            StandardMenu(title: "Debug")
            {
                TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
                    if logWindow.isVisible {
                        hideLog()
                    }
                    else { showLog() }
                }
                .updatingTitleWith { logWindow.isVisible ? "Hide Log" : "Show Log" }
            }
            #endif

The closure you pass to .updatingTitleWith is called when the user opens the item's parent menu before determining the item's enabled state. This gives you a chance to update the item's appearance by changing the title.

Dynamically Updating Menu Item State

Some menus items represent an application setting that can be enabled or disabled by the user. That state appears as check-mark next in the menu item when the setting is enabled.

Suppose our logger allows us to select whether or not we want more detailed logging than usual by setting its .detailedLogging property to true. We can implement that with the .updatingStateWith method in TextMenuItem . Let's add a new menu item to our "Debug" menu to do that.

            #if DEBUG
            StandardMenu(title: "Debug")
            {
                TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
                    if logWindow.isVisible {
                        hideLog()
                    }
                    else { showLog() }
                }
                .updatingTitleWith { logWindow.isVisible ? "Hide Log" : "Show Log" }
                
                // >>>>>>>> ADDED THE FOLLOWING BIT <<<<<<<<<<
                TextMenuItem(title: "Detailed Logging")  { _ in
                    logger.detailedLogging.toggle()
                }
                .updatingStateWith { logger.detailedLogging ? .on : .off }
            }
            #endif

Now we have a "Detailed Logging" menu item that will display whether detailed logging is currently enabled, and allows us to toggle that setting by selecting it.

The closure passed to .updatingStateWith is called when the menu is opened by the user, just like for .updatingTitleWith. In addition to .on and .off, the closure can return .mixed, which is displayed as a dash in the menu item instead of a check-mark.

Dynamically Populating Menus

Let's leave our "Debug" for now, and provide menu functionality our app's users would use. We'll add a new "Themes" menu so that users can select the color scheme used by our app, and we'll populate with the names of our themes. Instead of declaring each menu item individually, we can use ForEach to generate them for us from an array of the theme names:

            StandardMenu(title: "Themes")
            {
                ForEach(["Light", "Dark", "Sahara", "Congo", "Ocean"])
                { themeName in
                    TextMenuItem(title: themeName) { _ in setTheme(to: themeName) }
                        .updatingStateWith { currentTheme == themeName ? .on : .off }
                }
            }

This automatically generated "Themes" menu is a lot better than typing out a declaration for each theme's menu item, but it still leaves something to be desired. Suppose we later allow the user to define and save their own custom themes. We'd want to show those too. ForEach is able to do that too. In fact, it's already dynamically populating the menu each time it's opened, it's just that we can't tell because we're giving it static input. The parameter where we pass in our array is actually an @autoclosure that is called whenever the menu is opened. So if the thing we pass in is dynamic, the contents of our "Themes" menu will be too.

Let's define a dynamic themes list called, unimaginitively, themesList. For the sake of this example, we'll just make it a computed global variable that randomly selects a subset of our existing themes.

var fixedThemes = ["Light", "Dark", "Sahara", "Congo", "Ocean"]
var themesList: [String] {
    return fixedThemes.filter { currentTheme == $0 || Bool.random() }
}

Then we modify our "Themes" menu declaration to use it:

            StandardMenu(title: "Themes")
            {
                ForEach(themesList) // <- CHANGED THIS
                { themeName in
                    TextMenuItem(title: themeName) { _ in setTheme(to: themeName) }
                        .updatingStateWith { currentTheme == themeName ? .on : .off }
                }
            }

Now our menu will list our current theme plus a different random selection of the other available themes each time it's opened.

The ForEach being used here is purposefully named to match the one in SwiftUI, because it serves a similar purpose, but we're using MacMenuBar.ForEach not SwiftUI.ForEach. Whereas SwiftUI's ForEach needs to respond to dynamically changing data at any time during execution, MacMenuBar's ForEach only needs to do that when the user opens its parent menu, and of course, it generates menu items not SwiftUI Views.

Special Topics

macOS Injected Menu Items

If you're used to Mac development, you probably are aware that macOS automatically inserts a small number of menu items of its own into your menus. It injects Start Dictation... and Emoji & Symbols into your Edit menu. If you provide a View menu, it will inject Enter Full Screen there. If you don't provide a View menu, but you provide a Window menu, it will insert Enter Full Screen there.

If you're happy with those inserted menus as they are, MacMenuBar handles them just fine. The problem comes when you decide to override some aspect of those menu items. For example, the injected Enter Full Screen item has a command key equivalent to enter full screen mode, but it doesn't have one for exiting it. Quite a few applications like for the escape key to exit full screen mode, and that is convenient for the user. To do this in MacMenuBar, you implement the Enter Full Screen item yourself. For example:

    StandardMenu(title: "View")
    {
        TextMenuItem(title: "Show Toolbar", action: .showToolbar).enabled(false)
        TextMenuItem(title: "Customize Toolbar...", action: .customizeToolbar).enabled(false)
        
        MenuSeparator()
        
        TextMenuItem(title: "Show Sidebar", action: .showSidebar).enabled(false)
        
        TextMenuItem(title: "Enter Full Screen", action: .enterFullScreen)
            .afterAction
            { menuItem in
                if AppDelegate.isFullScreen
                {
                    menuItem.title = "Exit Full Screen"
                    KeyEquivalent.escape.set(in: menuItem)
                }
                else
                {
                    menuItem.title = "Enter Full Screen"
                    (.command + .control + "f").set(in: menuItem)
                }
            }
    }

Now the user can enter full screen mode with command-control-f as they would normally do, but they can also exit it by pressing the escape key.

There is a small problem with this though. Because MacMenuBar supports dynamically populated menus, and because for Enter Full Screen macOS checks to see if it should insert the menu item every time the menu is opened, it can try to insert its menu item while MacMenuBar is updating the menu's contents. That can lead to the menu item being added twice. It can be even more frustrating when you dig into it to find that macOS handles its insertions into your Edit menu differently from your View or Window menu, with the former happening when your app sets the main menu in your AppDelegate, and the latter happening everytime the users opens the menu. MacMenuBar captures the insertions and tries to only allow the macOS-inserted items if you haven't already implemented a menu item for the same selector. However, if you provide your own selector, you can still end up with duplicate menu items, and there are some other edge cases because of how MacMenuBar dynamically populates menus.

To handle these potential issues, MacMenuBar allows you to simply refuse to allow all auto-injected items for a particular menu. For example to refuse the allow macOS to automatically insert its Enter Full Screen item into your View menu, add .refuseAutoinjectedMenuItems() to the declaration of that menu, like this:

    StandardMenu(title: "View")
    {
        TextMenuItem(title: "Show Toolbar", action: .showToolbar).enabled(false)
        TextMenuItem(title: "Customize Toolbar...", action: .customizeToolbar).enabled(false)
        
        MenuSeparator()
        
        TextMenuItem(title: "Show Sidebar", action: .showSidebar).enabled(false)
        
        TextMenuItem(title: "Enter Full Screen", action: .enterFullScreen)
            .afterAction
            { menuItem in
                if AppDelegate.isFullScreen
                {
                    menuItem.title = "Exit Full Screen"
                    KeyEquivalent.escape.set(in: menuItem)
                }
                else
                {
                    menuItem.title = "Enter Full Screen"
                    (.command + .control + "f").set(in: menuItem)
                }
            }
    }.refuseAutoinjectedMenuItems() // <-- ADDED THIS

In this case, because macOS may try to add the item to the Window menu, append .refuseAutoinjectedMenuItems() to your Window menu delcaration too.

To maintain the experience Mac users expect, only do this when you implement replacements for the auto-injected items.

Description

  • Swift Tools 5.3.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sun Oct 20 2024 15:27:33 GMT-0900 (Hawaii-Aleutian Daylight Time)