Engineering

John Nastos

·

May 30, 2023

Pushing the limits of NSStatusItem beyond what Apple wants you to do

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:

let statusItem: NSStatusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

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:

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)

You might notice that we're setting a hardcoded width of 80 on the frame of the NSHostingView and NSStatusBarButton. In SwiftUI, it's easy to get used to elements autosizing themselves correctly, but NSStatusBarButton will require a little more attention if we want dynamic sizes. Let's take a look at what's involved in thatit's quite a bit more code, but I'll break it down by section after the block.

final class StatusItemManager {

    // 1
    private var hostingView: NSHostingView<StatusItem>?
    private var statusItem: NSStatusItem?

    

    // 2
    private var sizePassthrough = PassthroughSubject<CGSize, Never>()
    private var sizeCancellable: AnyCancellable?

    func createStatusItem() {
        // 3
        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)

        // 4
        self.statusItem = statusItem
        self.hostingView = hostingView 

        // 5
        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
        }
    }
}

// 6
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>

    // 7
    @ViewBuilder
    var mainContent: some View {
        Text("Hello, world! 👋")
            .fixedSize()
    }

    var body: some View {
        mainContent
            // 8
            .overlay(
                GeometryReader { geometryProxy in
                    Color.clear
                        .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
                }
            )
            .onPreferenceChange(SizePreferenceKey.self, perform: { size in
                sizePassthrough.send(size)
            })
    }
}
  1. Because we need to modify the NSStatusItem and NSHostingView later, we will store references to them.

  2. In order to communicate the size of the SwiftUI view back to our StatusItemManager, we'll use Combine. Here, we have a PassthroughSubject that we'll pass to the View and a AnyCancellable that we'll use when we create the Combine chain.

  3. This is the code that we saw before — nothing has changed here.

  4. Store the references to the status item and hosting view.

  5. 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 the NSHostingView and then NSStatusBarButton — both get the same frame with an origin of .zero and a size that is communicated from the View (actually, we just get the width from the View — the height is set at a constant 24 which is the max height of a menubar item).

  6. With our View, we'll want to read the size dynamically and send it back to the parent, so we'll do this with a PreferenceKey. How these work is beyond the scope of this post, but you can read more about them at The SwiftUI Lab.

  7. The main content of our StatusItem that will be displayed to the user in the menu bar.

  8. We measure the mainContent by using a GeometryReader and then pass it back through the sizePassthrough using the PreferenceKey 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.

Engineering

John Nastos

May 30, 2023

·

Pushing the limits of NSStatusItem beyond what Apple wants you to do

© Multi Software Co. 2024

© Multi Software Co. 2024