Building a MacOS Menu Bar App with Swift
In the expansive world of macOS development, creating a non-intrusive and highly accessible utility app often requires a unique approach. In this tutorial, we will dive into the process of building a macOS menu bar app using Swift — a compact and potent application that springs to life with a mere click on its menu bar icon.
Why choose a menu bar app? Here are a few compelling reasons:
- Instant Access: With just a click, your app is right there — no need to switch between windows.
- Saves Space: It keeps your workspace clean, occupying only a tiny fraction of your screen real estate.
- Focus on Functionality: Without the distractions of a full interface, you can design a tool that’s efficient and straightforward to use.
In this guide, we’ll walk through:
- Setting Up Your Project: How to start a new macOS app project in Xcode tailored for a menu bar app.
- Custom
AppDelegate
: How to bypass the default app structure and leverageAppDelegate
for initial setup. - Creating and Managing the Menu Bar Item: Techniques for adding a clickable icon to the menu bar and handling its events effectively.
- Creating the UI Window: Methods for displaying and positioning a window right where your cursor clicks.
- Managing Window Visibility: Showing and hiding the main UI when the user clicks on the menu item.
- Optionally Disable Launcher and Application Switcher Icons: The Menu Bar icon is always visible and the UI is not always open, so the application switcher icon is redundant.
Ready to create a menu bar app that blends simplicity with efficiency? Let’s get started and bring your macOS development skills to the forefront with this practical and engaging project.
Setting Up the Project
Creating a menu bar app on macOS begins with setting up your development environment in Xcode, Apple’s integrated development environment (IDE). In this section, we’ll walk through the initial steps of creating a new project and configuring it specifically for our needs as a menu bar application.
1. Launch Xcode
First things first, open Xcode. If you don’t have it installed, you can download it from the Mac App Store. Once opened, you’ll be greeted with the welcome screen where you can create a new project.
2. Create a New Project
Click on “Create a new Xcode project”. This will open a new window prompting you to select a template for your project. For our purposes, navigate to the ‘macOS’ tab and select ‘App’. This template provides a solid foundation for building a macOS application.
3. Configure Your Project
After selecting the template, you need to configure your project settings:
- Product Name: Give your app a name, such as “JustTheMenuApp”. This will also be the name of your project.
- Team: If you have an Apple developer account, select your development team from the dropdown. If not, you can select ‘None’ or set this up later.
- Organization Identifier: Enter a reverse domain name notation style identifier, typically starting with com.yourdomain.
- Bundle Identifier: This is automatically generated based on your product name and organization identifier. It uniquely identifies your app on the App Store and throughout the system.
- Language: Make sure Swift is selected as the programming language.
- User Interface: Choose ‘SwiftUI’ as it integrates seamlessly with Swift for building user interfaces.
Choose where to save your project on your local machine. It’s a good practice to have a dedicated directory for your development projects to keep them organized. Once you’ve chosen the location, click ‘Create’.
5. Review the Project Structure
Xcode will now open your new project. Take a moment to familiarize yourself with the basic structure. The Navigator pane on the left side of the window shows all the files and resources associated with your project. The main files you’ll be working with are:
AppDelegate.swift
: We’ll modify this file to handle our app’s interaction with the system, particularly for creating and managing the menu bar item.ContentView.swift
: This file will contain the UI elements of your application, displayed when the menu bar item is clicked.JustTheMenuApp.swift
: This is the entry point of your app, which configures the application environment.
You’ve now successfully set up your project in Xcode, tailored specifically for building a macOS menu bar app. Next, we’ll dive into customizing the AppDelegate to manage our app’s behavior effectively.
Modifying the Default App Behavior
Once your project is set up, your next task is to structure the application to listen for interactions with its menu bar item instead of showing the main window . This involves customizing the AppDelegate
and tweaking the app's lifecycle to support our unique use case.
1. Bypass the Main Window Creation on Start
By default, a new macOS app project in Xcode is configured to open a main window. We need to override this so that the app doesn’t automatically display a window at launch. This makes the app less obtrusive, as it will only appear upon user interaction with the menu bar.
Open the JustTheMenuApp.swift
file. Here, you will notice the standard SwiftUI app lifecycle setup. We need to integrate our AppDelegate
to handle the application startup and lifecycle.
SwiftUI’s application life cycle management is handled by the @main
struct. Here, you integrate the AppDelegate by using the @NSApplicationDelegateAdaptor
property wrapper. This allows the AppDelegate to manage system-level interactions while SwiftUI handles the UI.
import SwiftUI
@main struct
JustTheMenuApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
Text("Settings or main app window")
}
}
}
This code snippet tells the app to use the custom AppDelegate
class to handle application-specific behaviors, such as setting up the menu bar item and managing window visibility.
2. Setting Up the Menu Bar Item
Open AppDelegate.swift
. We configure the status bar item and its behavior upon interaction in this AppDelegate
class.
The applicationDidFinishLaunching()
, is the callback method executed in AppDelegate
when the app completes launch. At this point in the loading sequence, we can define what UI elements and other app-specific details get loaded. In this case, we define a single status button that loads in into the MacOS Menu Bar.
This is done by creating a new NSStatusItem
Menu Bar item and inserting a button into it. The NSStatusTime
is assigned to the existing system status bar by accessing the NSStatusBar
system instance.
func applicationDidFinishLaunching(_ notification: Notification) {
statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusBarItem?.button {
button.action = #selector(statusBarButtonClicked(_:))
button.target = self
button.title = "Open Window"
}
}
This code will create a new status bar button like the one you see here on the left, “Open Window.”
We set the “Open Window” button to trigger statusBarButtonClicked(_:)
when clicked. This method will be responsible for managing the window's visibility, which we'll implement next.
3. Implement the Click Handler
When the user interacts with the status bar icon, it should trigger a specific sequence of events, in this example it will display a small window directly under the Menu Bar icon. This is done using the statusBarButtonClicked()
method we defined as the button’s .action
.
@objc func statusBarButtonClicked(_ sender: NSStatusBarButton) {
print("Menu item clicked")
// We'll implement the window handling logic here
}
This method will later include the logic for displaying and positioning the window based on the cursor’s location on the screen.
With these modifications, your app now diverges from the standard behavior of launching with a main window and instead quietly sits in the menu bar, waiting to be called into action. This setup is ideal for utilities that require minimal user interaction and need to remain accessible without being intrusive. In the following sections, we’ll delve into creating and managing the window that appears when the menu bar item is clicked.
4. Add Icons
To improve the user experience, consider customizing the appearance of the button. You might add an icon or use a more descriptive title depending on what your app does. This makes the app feel more integrated and intuitive for users.
Customizing the Button: You can set an image for the button instead of text, or even combine both for a more expressive interface.
if let button = statusBarItem?.button {
button.image = NSImage(named: NSImage.Name("YourIcon"))
button.title = "Your App Name"
}
Choose an icon that clearly represents your app’s functionality, making it easily recognizable for users who might have multiple status items.
These icons are available in the SF Symbols App, a free user interface for searching for configurable, built-in symbols on MacOS.
You can add these symbols by name to any button or icon by creating a new NSImage()
with the name NSImage.name()
of that icon, for example with the square.and.arrow.up
icon shown in the app screenshot:
button.image = NSImage(named: NSImage.Name("square.and.arrow.up"))
With these steps, your menu bar item is not just visible but also functional. This small icon is now a powerful tool that enhances user interaction without demanding focus away from their current tasks. As we move forward, we will focus on dynamically managing the window that appears when this status bar item is activated, ensuring a seamless user experience.
Creating the Window
Now that we have our menu bar item set up and ready to interact with, it’s time to focus on what happens when it’s clicked. Specifically, we need to create and manage a window that appears in response to the user’s interaction. This window will display our app’s content, housed within a SwiftUI view.
Defining the ContentView
The default app ContentView
is sufficient for our window UI. At the time of writing, the default app shows a single image of a globe and text reading “Hello World:”
1. Define the Window Creation Logic
We want to show and hide the ContentView
when the user clicks the “Open Window” Menu Bar Button.
To save memory, we don’t need the window and the ContentView
to be created until the first time the user clicks the button to create and show the window. On subsequent clicks, the UI must only be shown or hidden. We can put the logic for this in a function called getOrBuildWindow()
. Importantly, we want the following settings:
- We don’t want to close the app when the window closes, so
.isReleasedWhenClosed = false
- If the user has multiple workspaces, we want the app to use the current active workspace rather than moving the user to another workspace, so
.collectionBehavior = .moveToActiveSpace
- We want the app to show up on top of other apps instead of hiding behind the last app that was open before. It should act like a modal dialog, so
.level = .floating
- We want the window to contain the
ContentView
, so.contentView = NSHostingView(rootView: ContentView())
- We don’t want decorations such as a title bar or close button, etc, so
.styleMask: [ .borderless]
and.backing: .buffered
Together, this is what the Window creation function looks like:
@objc func getOrBuildWindow(size: NSRect) -> NSWindow {
if window != nil {
return window.unsafelyUnwrapped
}
let contentView = ContentView()
window = NSWindow(
contentRect: size,
styleMask: [.borderless],
backing: .buffered,
defer: false)
window?.contentView = NSHostingView(rootView: contentView)
window?.isReleasedWhenClosed = false
window?.collectionBehavior = .moveToActiveSpace
window?.level = .floating
return window.unsafelyUnwrapped;
}
2. Toggle the Window Visibility
We also want to toggle the visibility of the window when the user clicks the Menu Bar button, so we need a toggleWindowVisibility()
function to track if the window should be hidden or shown when the button is clicked:
func toggleWindowVisibility(location: NSPoint) {
// window hasn't been built yet, don't do anything
if window == nil {
return
}
if window!.isVisible {
// window is visible, hide it
window?.orderOut(nil)
} else {
// window is hidden. Position and show it on top of other windows
window?.setFrameOrigin(location)
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}
3. Create and Show Window on Button Click
To create and show (or hide) the window when the Menu Bar button is cliked, we simply need to update the statusBarButtonClicked()
method to call these functions.
By default, the window will appear at the center of the screen, but it is nicer for us if it opens under where the user’s cursor was during the time of click. We can do that by getting the mouse location at the time of the click and positioning the window.
The menu bar height can be retrieved by subtracting the height of the desktop screen from the screen height.
That calculation in code looks like this:
func getMenuBarHeight() -> CGFloat? {
guard let desktopFrame = NSScreen.main?.visibleFrame else {
return nil
}
let screenFrame = NSScreen.main?.frame
let menuBarHeight = screenFrame!.height - desktopFrame.height
return menuBarHeight
}
Knowing the menu bar height and the location of the mouse click, we can position the window under the menu bar, centered under the cursor:
let mouseLocation = NSEvent.mouseLocation
let screenHeight = NSScreen.main?.frame.height ?? 0
let windowWidth: CGFloat = 300 // Define your desired window width
let windowHeight: CGFloat = 200 // Define your desired window height
// center horizontally at mouse. Note that `0` is the left of screen
let windowX = mouseLocation.x - windowWidth / 2
// position directly under the menu bar. Note that `0` is the bottom of screen
let windowY = screenHeight - windowHeight - getMenuBarHeight()
4. Connect the Click Event to the Window
Tying it all together, we create a window when the user clicks the Menu Button for the first time, then hide or show it under the cursor depending on it’s current .isVisible
state.
struct JustTheMenuApp: App {
var window: NSWindow?
@objc func statusBarButtonClicked(_ sender: NSStatusBarButton) {
// set the window width and height
let windowWidth: CGFloat = 300
let windowHeight: CGFloat = 200
// center the window under the cursor
let mouseLocation = NSEvent.mouseLocation
let screenHeight = NSScreen.main?.frame.height ?? 0
let windowX = mouseLocation.x - windowWidth / 2
let windowY = screenHeight - windowHeight - getMenuBarHeight()
// construct the window
window = getOrBuildWindow(size: NSRect(
x: windowX, y: windowY, width: windowWidth, height: windowHeight)
)
// show or hide the window
toggleWindowVisibility(location: NSPoint(x: windowX, y: windowY))
}
// getOrBuildWindow() {}
// toggleWindowVisibility() {}
}
Now when the user clicks the icon, the app UI will show underneath the Menu Bar, where the user clicked the button.
Disabling Launcher and Application Switcher
Because the app is always available in the Menu Bar, it is redundant to also have a Launcher icon and Application Switcher icon when the app is running.
In general there is no window to cmd+tab
switch to and the user can open the UI by clicking the omni-present Menu Bar icon.
Do this by setting the LSUIElement
key in your Info.plist
file to false
. After the change, the key will be renamed to “Application is Agent (UIElement).” If you edit your Info.plist
as a “Property List” file, it will look like this:
Alternately if you edit your Info.plist
file as a “Source Code” file, the change looks like this:
<plist version="1.0">
<dict>
...
<key>LSUIElement</key>
<false/>
</dict>
</plist>
Conclusion
Building a macOS menu bar app that responds to user interactions with minimal disruption involves understanding how to manage the app lifecycle, create custom UI elements, and handle window visibility effectively. By following the steps outlined in this guide, you’ve transformed a basic macOS app into a responsive, user-friendly utility that sits quietly in the menu bar until needed.