How to create Status Bar icon and menu in macOS using SwiftUI - swift

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.
EDIT:
Posted the solution as a separate answer.

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 swiftui 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
}

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.
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)
}
}
}

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.
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 on the Dock
In Xcode's Info tab, choose Application is agent (UIElement) and set its value to YES.

Related

NSStatusItem icon randomly dissappearing

I'm trying to figure out a weird problem that's happening with my menu bar app. It would run perfectly fine for a couple of hours or even days but would randomly disappear from the menu bar.
In activity monitor, it's still running in the background. There's a global keyboard shortcut in the app to show the window and it brings out the app no problem but the menu bar icon is still missing.
I'm on macOS Monterery 12.2.1
StatusBarController
class StatusBarController {
private var statusBar: NSStatusBar
private var statusItem: NSStatusItem
public var popover: NSPopover
private var eventMonitor: EventMonitor?
init(_ popover: NSPopover)
{
self.popover = popover
statusBar = NSStatusBar.init()
statusItem = statusBar.statusItem(withLength: 28.0)
if let statusBarButton = statusItem.button {
statusBarButton.image = #imageLiteral(resourceName: "link")
statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0)
statusBarButton.image?.isTemplate = true
statusBarButton.action = #selector(togglePopover(sender:))
statusBarButton.target = self
}
}
.......}
App Delegate
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var popover = NSPopover.init()
var statusBar: StatusBarController?
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the contents
let contentView = ContentView()
// Set the SwiftUI's ContentView to the Popover's ContentViewController
popover.contentViewController = MainViewController()
popover.contentViewController?.view = NSHostingView(rootView: contentView)
popover.animates = false
KeyboardShortcuts.onKeyUp(for: .triggerPopover, action: {
self.statusBar?.togglePopover(sender: self)
})
// Create the Status Bar Item with the Popover
statusBar = StatusBarController.init(popover)
}
}
From Status Bar Programming Topics:
A system-wide status bar resides at the right side of the menu bar and is the only status bar currently available.
NSStatusBar.init() doesn't return the system status bar. Use NSStatusBar.system instead.

Request key for TextField in macOS status bar app

I am writing a little status bar app, which includes a text input.
The app is added to the status bar as a NSMenuItem which holds a NSHostingViewController with the SwiftUI-View. The status bar menu is added through an App delegate inside SwiftUIs #main scene. I've set "Application is agent" in the info.plist to true and I have an empty scene as #main in SwiftUI.
Problem: When I try to edit text, in the status bar app, the text field is not clickable and does not receive text input. When I add a window, the text field in the status bar app works as expected as long as the window is in foreground.
From my understanding, this is caused by the text field not being inside a window marked as key window. Is there any workaround to make the text field work as expected without the need for an additional app window?
Minimal example for the app delegate:
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
func applicationDidFinishLaunching(_ notification: Notification) {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let statusBarButton = statusItem?.button {
statusBarButton.image = NSImage(systemSymbolName: "6.circle", accessibilityDescription: "")
statusBarButton.image?.isTemplate = true
}
let menuItem = NSMenuItem()
menuItem.view = createView()
let menu = NSMenu()
menu.addItem(menuItem)
statusItem?.menu = menu
}
private func createView() -> NSView {
let view = HostingView(rootView: ContentView())
view.frame = NSRect(x: 0, y: 0, width: 520, height: 260)
return view
}
}
SwiftUI #main
import SwiftUI
import AppKit
#main
struct MacApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
EmptyView()
}
}
}
Edit: I figured out, that there are two windows registered for my app. One for the status bar icon and one for the menu. However, the code below does not result in the menu window to become the key window. (although it is in fact the only window which returns true for .canBecomeKey)
.onAppear {
let window = NSApp.windows.first { $0.canBecomeKey }
window?.makeKey()
}
Second Edit: Apparently, the problem is not, that the window is not able to become key, but that the app is not set as the active app when the status bar menu is opened. I came up with this rather ugly hack, which causes the menu to close and re-open when another app was in focus before, but at least buttons and textfields work as expected with this:
func applicationDidFinishLaunching(_ notification: Notification) {
...
// Also let AppDelegate implement NSMenuDelegate
menu.delegate = self
...
}
func menuWillOpen(_ menu: NSMenu) {
#warning("This is a hack to activate the app when the menu is shown. This will cause the menu to close and re-open when another window was focused before.")
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
DispatchQueue.main.async {
self.statusItem?.button?.performClick(nil)
}
}
}
I am still looking for a proper solution.

Is there a way to get keyboard input inside an NSMenuItem?

I'm trying to make an NSMenu which contains an NSMenuItem, and inside that NSMenuItem I want a TextField that I can interact with. My goal is to make a menubar note taking app, so in theory I could just make a window and align it with the top edge, but I want to use an NSMenu if possible for the aesthetic.
Problem is, the text field doesn't seem to be receiving input properly. The text cursor doesn't appear when I click on it, and no characters appear when I type. Also, as soon as I press a key, the menu disappears.
Here's my applicationDidFinishLaunching, which creates the menu itself and assigns it to a status bar item:
func applicationDidFinishLaunching(_ notification: Notification) {
let contentView = ContentView()
let view = NSHostingView(rootView: contentView)
view.frame = NSRect(x: 0, y: 0, width: 350, height: 100)
let item = NSMenuItem()
item.view = view
let menu = NSMenu()
menu.addItem(item)
self.statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.statusBarItem.menu = menu
self.statusBarItem?.button?.title = "Test"
}
And, here's my content view:
struct ContentView: View {
var body: some View {
TextField("Placeholder", text: ...some swiftUI thing...)
.padding(10)
}
}
Is there any way to do what I want, or will I have to resort to making a regular window and pretending it's an NSMenu?

pull down to refresh data in swift UI

I am trying to develop my first IOS app in SwiftUI and I want to have a pull down to refresh List. I already know that at the moment Apple hasn't implemented it but I found the following solution here on stackoverflow (Pull down to refresh data in SwiftUI) and I am implemented the solution from the first answer. And this works fine.
But now I want to have a SwiftUI View in this refreshable View. The solution says I have to:
wrapping them in a UIHostingController and dropping them in makeUIView
And here is my problem. What did I have to do exactly? I tried the following but it isn't working.
func makeUIView(context: Context) -> UIScrollView {
let control = UIScrollView()
control.refreshControl = UIRefreshControl()
control.refreshControl?.addTarget(context.coordinator, action:
#selector(Coordinator.handleRefreshControl),
for: .valueChanged)
// Simply to give some content to see in the app
/*let label = UILabel(frame: CGRect(x: 0, y: 0, width: 200, height: 30))
label.text = "Scroll View Content"
control.addSubview(label)*/
let parent = UIViewController()
let child = UIHostingController(rootView: RecipeList())
child.view.translatesAutoresizingMaskIntoConstraints = false
child.view.frame = parent.view.bounds
// First, add the view of the child to the view of the parent
parent.view.addSubview(child.view)
// Then, add the child to the parent
parent.addChild(child)
return control
}
Has anyone an idea what I am doing wrong and can tell me what I have to change?
Thanks and best regards
Henrik
iOS 15+
struct ContentView: View {
var body: some View {
NavigationView {
if #available(iOS 15.0, *) {
List(1..<100) { row in
Text("Row \(row)")
}
.refreshable {
print("write your pull to refresh logic here")
}
} else {
// Fallback on earlier versions
}
}
}
}

Custom View in Status Bar is Not Appearing Disabled on Secondary Screen

I have an app that uses a custom view in the menu bar of macOS. macOS has a feature that items in the menu bar will appear disabled on a secondary screen (I think an alpha value will be added to the view).
When I remove the custom view from the Button, everything works fine. But when I use my custom view, the view always looks the same, no matter if it is the primary or the secondary monitor.
Even setting the property "appearsDisabled" does not change the look of the view.
This is the code that I am using:
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private var view: HostView?
func applicationDidFinishLaunching(_ aNotification: Notification)
{
self.createMainView()
self.createMenuBarView()
}
fileprivate func createMenuBarView()
{
// Remove all sub views from the view and create new ones.
self.view?.subviews.removeAll()
var width: CGFloat = 0
for device in self.controller.model.devices
{
if let newView = self.createView(for: device.value, x: width)
{
self.view?.addSubview(newView.view)
width += newView.width
}
}
self.view?.frame = NSRect(x: 0, y: 0, width: width, height: MenuBar.height)
self.statusItem.image = nil
self.statusItem.length = width
if let view = self.view
{
// Do I have to set some properties here?
self.statusItem.button?.addSubview(view)
}
}
fileprivate func createMainView()
{
let view = HostView(frame: NSRect(x: 0, y: 0, width: 32.0, height: MenuBar.height))
view.menu = self.menu
self.view = view
}
The problem seems to be that I am adding a NSView to the button of the NSStatusItem as a subview.
self.statusBarItem.button?.addSubview(myView)
When I set my custom view to the view-Property of the NSStatusItem, the view is grayed out on a secondary screen (note that this is deprecated since macOS 10.14).