32

What is SwiftUI API for creating status bar menus?

Apple seems to use SwiftUI views in Battery & WiFi menus according to the accessibility inspector. Screenshot of a battery menu attached, also its view hierarchy.

Battery menu screenshot View hierarchy of the battery menu

EDIT:

Posted the solution as a separate answer.

Andy Jazz
  • 49,178
  • 17
  • 136
  • 220
Ruzard
  • 1,167
  • 3
  • 16
  • 33
  • Could you clarify where exactly in the AppDelegate this code goes? I'm getting crashes and status bar icons that work temporarily, but disappear quickly. – Dominic Holmes Nov 28 '20 at 01:19
  • Where does it crash exactly? Here "NSStatusBar.system"? – Ruzard Nov 28 '20 at 22:36
  • Yep -- I solved it though. I misunderstood your answer and thought the NSStatusBar was something already created and provided by the system. – Dominic Holmes Nov 28 '20 at 23:54

3 Answers3

26

Since this question received more attention lately, and the only reply doesn't fully solve the issue I would like to repeat the edited part of my question and mark it as resolved.

Edit2: Added an additional piece of code that allows using a SwiftUI view as the status bar icon. Might be handy for displaying dynamic badges.

Found a way to show this in without an annoying NSPopover. Even though I used AppDelegate's applicationDidFinishLaunching to execute the code, it can be called from any place of your app, even in a SwiftUI lifecycle app.

Here is the code:

func applicationDidFinishLaunching(_ aNotification: Notification) {
        // SwiftUI content view & a hosting view
        // Don't forget to set the frame, otherwise it won't be shown.
        //
        let contentViewSwiftUI = VStack {
            Color.blue
            Text("Test Text")
            Color.white
        }
        let contentView = NSHostingView(rootView: contentViewSwiftUI)
        contentView.frame = NSRect(x: 0, y: 0, width: 200, height: 200)
        
        // Status bar icon SwiftUI view & a hosting view.
        //
        let iconSwiftUI = ZStack(alignment:.center) {
            Rectangle()
                .fill(Color.green)
                .cornerRadius(5)
                .padding(2)
                
            Text("3")
                .background(
                    Circle()
                        .fill(Color.blue)
                        .frame(width: 15, height: 15)
                )
                .frame(maxWidth: .infinity, maxHeight: .infinity,  alignment: .bottomTrailing)
                .padding(.trailing, 5)
        }
        let iconView = NSHostingView(rootView: iconSwiftUI)
        iconView.frame = NSRect(x: 0, y: 0, width: 40, height: 22)
        
        // Creating a menu item & the menu to add them later into the status bar
        //
        let menuItem = NSMenuItem()
        menuItem.view = contentView
        let menu = NSMenu()
        menu.addItem(menuItem)
        
        // Adding content view to the status bar
        //
        let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.menu = menu
        
        // Adding the status bar icon
        //
        statusItem.button?.addSubview(iconView)
        statusItem.button?.frame = iconView.frame

        // StatusItem is stored as a property.
        self.statusItem = statusItem
    }

enter image description here

Ruzard
  • 1,167
  • 3
  • 16
  • 33
  • 1
    Thank you for this! Exactly what I was looking for. However, I can't figure out how to get any interactive elements in the view (buttons, TextFields) to work. Any thoughts? – Brad G. Jan 11 '21 at 13:54
  • 1
    @BradG. If you add a button to the content view it's interactable. In my case it prints data https://gist.github.com/kmalyshev/7ef834efaeed83014f6ba851582fca0d . Is there anything that blocks interaction in your case? – Ruzard Jan 11 '21 at 20:27
  • You're right, buttons do work. TextFields don't, though. I also have a Menu in the view that isn't working either. – Brad G. Jan 11 '21 at 23:32
  • 1
    I created a minimal project to demonstrate. The TextField actually does work initially, but at some point stops accepting new input (typing anything in it closes the menu). Menu never works. https://github.com/bgreenlee/MinimalMenuBarApp – Brad G. Jan 12 '21 at 00:40
  • 1
    @BradG. Unfortunately I didn't got that far. Having some custom view was sufficient for me. There is a documentation on that topic. It says about limitations of such an approach. Perhaps there is a way to overcome limitations, handle mouse and keyboard events manually. https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MenuList/Articles/ViewsInMenuItems.html#//apple_ref/doc/uid/TP40005166-SW1 – Ruzard Jan 12 '21 at 10:11
  • Hey @Ruzard, thanks for the answer, can we update the icon like Telegram did in macOS client, for example there are envelope icon, but when notification has arrived , icon will be changed with number of notifications? – eemrah Nov 09 '22 at 16:24
  • 1
    @eemrah These badges are usually rendered manually, there are examples on stackoverflow how to draw them with UIKit. However the same approach with a HostingView can be used for the icon. I updated the code, please take a look. – Ruzard Nov 11 '22 at 10:58
  • It seems that iconSwiftUI does not sit in the center of statusItem. I have checked with frame. You have added the size and a trailing, are those on purpose? – chunyang.wen Nov 14 '22 at 22:47
9

MenuBarExtra (macOS Ventura)

In macOS 13.0+ and Xcode 14.0+, the MenuBarExtra struct allows you create a system menu bar, that is similar to NSStatusBar's icons and menus. Use a MenuBarExtra when you want to provide access to commonly used functionality, even when your app is not active.

enter image description here

import SwiftUI

@available(macOS 13.0, *)                       // macOS Ventura
@main struct StatusBarApp: App {
    
    @State private var command: String = "a"
       
    var body: some Scene {

        MenuBarExtra(command, systemImage: "\(command).circle") {
           
            Button("Uno") { command = "a" }
                .keyboardShortcut("U")
           
            Button("Dos") { command = "b" }
                .keyboardShortcut("D")
           
            Divider()

            Button("Salir") { NSApplication.shared.terminate(nil) }
                .keyboardShortcut("S")
        }
    }
}

Getting rid of the App's icon in the Dock

In Xcode's Info tab, choose Application is agent (UIElement) and set its value to YES.

enter image description here

Or you can hide App's icon programmatically.

Andy Jazz
  • 49,178
  • 17
  • 136
  • 220
  • 2
    Thanks for the update. It took Apple some time to get to the status bar API. I'd like to add to your reply - it's possible to add any SwiftUI views to 'MenuBarExtra', not just buttons. – Ruzard Aug 29 '22 at 09:05
  • Do you have any idea how to change the size of `systemImage` or `image` in `MenuBarExtra`? Use the system image, the size is small compare to other icons, such as WiFi or other regular app which has an icon. – chunyang.wen Nov 10 '22 at 09:47
  • @chunyang.wen, I hope it'll work: https://stackoverflow.com/questions/57342170/how-do-i-set-the-size-of-a-sf-symbol-in-swiftui/73205360#73205360 – Andy Jazz Nov 10 '22 at 10:00
  • @AndyJazz They are not the same. `MenuBarExtra` creates the image itself. We can use `MenuBarExtra(content, label)`. But it seems that it is not working. https://developer.apple.com/documentation/swiftui/menubarextra/init(content:label:) Not working when I change the size – chunyang.wen Nov 11 '22 at 08:15
  • 1
    Do you have any idea on how we can set a max frame for a `MenuBarExtra` ? I already tried to use the `defaultSize` modifier but it seems that it's not working – jackson89 Nov 14 '22 at 23:10
  • 1
    When I use a custom SVG icon, it suffers from scaling issues - it is ugly and pixelated :( – Pavel Lobodinský Nov 17 '22 at 09:47
  • @jackson89, I have no idea how to set a max frame for it. – Andy Jazz Jan 04 '23 at 12:09
  • 1
    @PavelLobodinský What did you end up doing for MacMenuBarExtra label? It's literally impossible to create a dynamic label without suffering from blurriness, odd scaling and certain views not being rendered at all. I think I'm going to just use the solution below instead of MacMenuBarExtra right now – Rahul Bir Jan 29 '23 at 06:18
  • 1
    @RahulBir I've ended up with the good old NSStatusItem. – Pavel Lobodinský Jan 29 '23 at 10:05
  • 1
    @PavelLobodinský If you don’t mind, could you help me out? I wrote my full app in SwiftUI using the Mac MenuBarExtra, but if I try to use the solution above buttons and text fields don’t seem to work at all. – Rahul Bir Jan 29 '23 at 19:39
  • 1
    @RahulBir Not sure if I can help. I just went through a few online tutorials on how to create a menu bar app. If you open a window/popover on menu-bar-item click, try to call `NSApp.activate(ignoringOtherApps: true)` and `appWindow?.makeKeyAndOrderFront(nil)`. – Pavel Lobodinský Jan 30 '23 at 07:37
  • 1
    I found this blogpost that fixes this issue for me: https://mirzoyan.dev/mirzoyan%20dev%20d62e6ab9344e4ab8a9c14205257ea2cc/Blog%20208f3509a5b74655b973d4dbaaf500e6/Custom%20icon%20for%20SwiftUI%20MenuBarExtra%20fdd31ec3e0af46adb3f7f69129bd6172 Basically it says to use NSImage to initialise an Image as a label. There you can set the ratio on, so it scales properly – Niels Hoogendoorn Aug 08 '23 at 16:19
8

Inside the AppDelegate add the following code:

// Create the status item in the Menu bar 
self.statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))

// Add a menu and a menu item
let menu = NSMenu()
let editMenuItem = NSMenuItem()
editMenuItem.title = "Edit"
menu.addItem(editMenuItem)

//Set the menu 
self.statusBarItem.menu = menu

//This is the button which appears in the Status bar
if let button = self.statusBarItem.button {
    button.title = "Here"
}

This will add a Button with a custom Menu to your MenuBar.

enter image description here

Edit - How to use SwiftUI View

As you asked, here is the answer how to use a SwiftUI View.

First create a NSPopover and then wrap your SwiftUI view inside a NSHostingController.

var popover: NSPopover


let popover = NSPopover()
popover.contentSize = NSSize(width: 350, height: 350)
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: contentView)
    self.popover = popover

Then instead of showing a NSMenu, toggle the popover:

self.statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let button = self.statusBarItem.button {
     button.title = "Click"
     button.action = #selector(showPopover(_:))
}

With following action:

@objc func showPopover(_ sender: AnyObject?) {
    if let button = self.statusBarItem.button
    {
        if self.popover.isShown {
            self.popover.performClose(sender)
        } else {
            self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
        }
    }
}

enter image description here

davidev
  • 7,694
  • 5
  • 21
  • 56
  • 6
    Unfortunately the code you posted is an AppKit way to create those menus. It's not SwiftUI way nor it supports SwiftIUI views. – Ruzard Nov 22 '20 at 13:50
  • Well, if you create a project with AppKit App Delegate Life cycle, you still got an AppDelegate. – davidev Nov 22 '20 at 13:51
  • 3
    Yes, but it's not what I asked. I am interested in showing swiftUI in that top menu. – Ruzard Nov 22 '20 at 13:54
  • Thank you, the initial version of my question that got eventually blocked for not being direct enough contained a line about NSPopover. That NSPopover seems like a workaround which I wanted to avoid. I really appreciate your response and I will try to give you a bounty the moment I am able to give them. The code you shown definitely will help other people. I'll update my question with more details just in case. – Ruzard Nov 22 '20 at 14:31
  • 1
    Happy it helped. I already voted to open it again because that is a really good and helpful question I was searching about aswell – davidev Nov 22 '20 at 14:32
  • 3
    I added an alternative solution without NSPopover. Perhaps you find it useful too. – Ruzard Nov 22 '20 at 17:32