Apple's Human Interface Guidelines (HIG) make macOS great. Developers should know and follow them. But there are places where the HIG has fallen behind modern computing needs. Take FaceTime’s macOS app, which elegantly brings others onto your desktop in real time.
In obvious contradiction of “Avoid relying on the presence of menu bar extras,” FaceTime’s call controls rely on a menu bar extra, which ensures it is always shown by occluding other items when there’s no room for it.
FaceTime's menu bar extra (in green):
We think FaceTime’s designers made a good choice. A powerful menu item is great for displaying realtime status, and for quick, predicable access to controls like mute/unmute. On iOS, there are recent additions like Live Activities that can help with this, but macOS has been stagnant and doesn't provide what they need.
That’s certainly true for us at Remotion. We're building video calls that make apps like Xcode multiplayer. Our pairing use case means we want to use minimal screen real estate, but we still need to display realtime information, and provide quick access to controls. So, we too decided to expand beyond HIG guidance and push the limits of what menu bar extras are meant to do.
Remotion's menu bar extra (in green):
If you’re interested in creating a status item with dynamic content & size, that supports a background and multiple tap targets, read on.
Implementation details
The HIG suggests that apps use SwiftUI's MenubarExtra
to create a symbol that reveals a menu when clicked. This isn't nearly powerful enough for what we need. So, that means exploring NSStatusItem
from AppKit instead. NSStatusItem
can be hacked into what we need, allowing dynamic sizing, multiple click targets, colors beyond what template images offer, and more. Initially, we didn't think some of this was possible because of the limited API footprint that NSStatusItem
offers, but we were pleasantly surprised once we figured out how to stretch it a bit. Read on to learn how we did it.
First, let's look at how to create a new NSStatusItem
:
This simple API gets us a variable-width status item. Then, we're faced with a choice of how to add content to this status item: view
and button
. view
returns an NSView
and seems like it would be the obvious choice for our multiple-click target requirement, but unfortunately, it was deprecated in 10.14, leaving button
(which returns an NSStatusBarButton
) the only choice. This seems like it may be too inflexible for what we want, but it turns out there are some workarounds that we'll take a look at later in the post that make it usable.
Here's what the code would look like to generate a simple “Hello, world” view in the NSStatusBarButton
:
Because we need to modify the
NSStatusItem
andNSHostingView
later, we will store references to them.In order to communicate the size of the SwiftUI view back to our
StatusItemManager
, we'll use Combine. Here, we have aPassthroughSubject
that we'll pass to theView
and aAnyCancellable
that we'll use when we create the Combine chain.This is the code that we saw before — nothing has changed here.
Store the references to the status item and hosting view.
Here's where we use Combine to watch for size changes communicated from the
View
. When we receive one of these changes, we set the frame on both theNSHostingView
and thenNSStatusBarButton
— both get the same frame with an origin of.zero
and a size that is communicated from theView
(actually, we just get thewidth
from theView
— the height is set at a constant24
which is the max height of a menubar item).With our
View
, we'll want to read the size dynamically and send it back to the parent, so we'll do this with aPreferenceKey
. How these work is beyond the scope of this post, but you can read more about them at The SwiftUI Lab.The main content of our
StatusItem
that will be displayed to the user in the menu bar.We measure the
mainContent
by using aGeometryReader
and then pass it back through thesizePassthrough
using thePreferenceKey
from section 6.
This is a lot of code, but it gives us a fairly robust setup. Any time the mainContent
changes, the NSStatusItem
will automatically change size to fit the new width. We'll take advantage of this later in the post.
Note: it's also possible to attach the size of the NSHostingView
to its container using Auto Layout constraints. I've found that constraints and their auto-sizing behavior are less reliable than using GeometryReader
with NSHostingView
specifically, but your milage may vary.
Now that we have a way to display dynamically-sized content, let's take a look at how we can get user interactions from the component. The most basic way is just to attach an action to the NSStatusBarButton
.
statusItem.button?.target = self
statusItem.button?.action = #selector(buttonPressed)
@objc func buttonPressed(_ sender: NSStatusBarButton) {
print("Pressed")
}
If all you need is a simple click action, this is enough. However, if you need multiple click targets, we can get a little more advanced. It turns out that we can add multiple SwiftUI Buttons
inside the NSStatusBarButton
.
@ViewBuilder
var mainContent: some View {
HStack(spacing: 0) {
Button(action: {
print("Click target 1")
}) {
Text("Hello, world!")
.foregroundColor(.white)
}
.buttonStyle(.borderless)
Button(action: {
print("Click target 2")
}) {
Text("👋")
.padding(2)
}
.buttonStyle(.borderless)
}
.fixedSize()
}
This gets us part of the way there — now, the different click targets register, but we've lost the highlighted state of the NSStatusBarButton
on click because we're covering up that button with our own custom buttons. To fix that, we can use a custom button style instead of .borderless
:
struct StatusItemButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(2)
.frame(maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(configuration.isPressed ? 0.3 : 0))
)
}
}
Note that there is a bit of a compromise here: the highlighted area doesn't extend quite as much in the vertical direction as the native NSStatusBarButton
does. I haven't found a way around this, but would love to hear from someone that has a solution!
Now that we have multiple click targets, let's take advantage of the dynamic width that we set up before. We could, for example, show the 👋 emoji based on some state that is set when the “Hello, world!” is clicked. That looks like this:
@State private var showWave: Bool = false
@ViewBuilder
var mainContent: some View {
HStack(spacing: 0) {
Button(action: {
showWave.toggle()
}) {
Text("Hello, world!")
.foregroundColor(.white)
}
.buttonStyle(StatusItemButtonStyle())
if showWave {
Button(action: {
// wave
}) {
Text("👋")
}
.buttonStyle(StatusItemButtonStyle())
}
}
.fixedSize()
}
Now, when you click on the text, you'll see that the NSStatusItem
changes widths appropriately as the size of the view changes.
Lastly, let's show some custom UI (not just a menu) when that emoji gets pressed.
@State private var menuShown: Bool = false
// ...
Button(action: {
menuShown.toggle()
}) {
Text("👋")
}
.buttonStyle(StatusItemButtonStyle())
.popover(isPresented: $menuShown) {
Image(systemName: "hand.wave")
.resizable()
.frame(width: 100, height: 100)
.padding()
}
That's it — now we have a fully functioning NSStatusBarItem
built with SwiftUI with multiple click targets, dynamic width, and a popover — certainly more than one might think is initially possible with MenuBarExtra
or the basic NSStatusItem
APIs. Perhaps in the future, macOS will grow to have a more robust realtime notification system and we can rethink some of this use of the menubar, but until then, this gives us the opportunity to have a powerful menubar item.
Here's the complete solution:
import Combine
import SwiftUI
final class StatusItemManager: ObservableObject {
private var hostingView: NSHostingView<StatusItem>?
private var statusItem: NSStatusItem?
private var sizePassthrough = PassthroughSubject<CGSize, Never>()
private var sizeCancellable: AnyCancellable?
func createStatusItem() {
let statusItem: NSStatusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let hostingView = NSHostingView(rootView: StatusItem(sizePassthrough: sizePassthrough))
hostingView.frame = NSRect(x: 0, y: 0, width: 80, height: 24)
statusItem.button?.frame = hostingView.frame
statusItem.button?.addSubview(hostingView)
self.statusItem = statusItem
self.hostingView = hostingView
sizeCancellable = sizePassthrough.sink { [weak self] size in
let frame = NSRect(origin: .zero, size: .init(width: size.width, height: 24))
self?.hostingView?.frame = frame
self?.statusItem?.button?.frame = frame
}
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}
struct StatusItem: View {
var sizePassthrough: PassthroughSubject<CGSize, Never>
@State private var showWave: Bool = false
@State private var menuShown: Bool = false
@ViewBuilder
var mainContent: some View {
HStack(spacing: 0) {
Button(action: {
showWave.toggle()
}) {
Text("Hello, world!")
.foregroundColor(.white)
}
.buttonStyle(StatusItemButtonStyle())
if showWave {
Button(action: {
menuShown.toggle()
}) {
Text("👋")
}
.buttonStyle(StatusItemButtonStyle())
.popover(isPresented: $menuShown) {
Image(systemName: "hand.wave")
.resizable()
.frame(width: 100, height: 100)
.padding()
}
}
}
.fixedSize()
}
var body: some View {
mainContent
.overlay(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: { size in
sizePassthrough.send(size)
})
}
}
struct StatusItemButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(2)
.frame(maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(configuration.isPressed ? 0.3 : 0))
)
}
}
struct ContentView: View {
@StateObject private var manager = StatusItemManager()
var body: some View {
Text("Look at the menu ⬆️")
.fixedSize()
.padding()
.onAppear {
manager.createStatusItem()
}
}
}
John Nastos is a macOS engineer at Remotion. He lives in Portland, Oregon where he also has an avid performing career as a jazz musician. You can find him on Twitter at @jnpdx where he'll happily answer questions about this post and entertain suggestions for future articles.