Request key for TextField in macOS status bar app - swift

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.

Related

How to change a macOS menubar icon?

I'm trying to change a macOS menubar icon at the click of a button based on this question.
However, when I run the app and click the button, I get a Could not cast value of type 'SwiftUI.AppDelegate' (0x20dfafd68) to 'MenuBarTest.AppDelegate' (0x10442c580). error on this line:
let appDelegate = NSApplication.shared.delegate as! AppDelegate
Main Swift File
#main
struct MenuBarTestApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popOver = NSPopover()
func applicationDidFinishLaunching(_ notification: Notification) {
let menuView = MenuBarView()
popOver.behavior = .transient
popOver.animates = true
popOver.contentViewController = NSViewController()
popOver.contentViewController?.view = NSHostingView(rootView: menuView)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let MenuButton = statusItem?.button {
MenuButton.image = NSImage(named: "settings_bw")
MenuButton.image?.size = NSSize(width: 18.0, height: 18.0)
MenuButton.action = #selector(MenuButtonToggle)
}
}
func setIcon(colorType:String) {
var icon = NSImage(named: "settings_color")
if colorType != "color" {
icon = NSImage(named: "settings_bw")
}
statusItem?.button?.image = icon
}
#objc
func MenuButtonToggle(sender: AnyObject) {
if popOver.isShown {
popOver.performClose(sender)
} else {
if let MenuButton = statusItem?.button {
self.popOver.show(relativeTo: MenuButton.bounds, of: MenuButton, preferredEdge: NSRectEdge.minY)
popOver.contentViewController?.view.window?.makeKey()
}
}
}
}
MenuBar View
struct MenuBarView: View {
var body: some View {
VStack {
Button(action: {
let appDelegate = NSApplication.shared.delegate as! AppDelegate
appDelegate.setIcon(colorType: "color")
}, label: {
Text("Change Icon")
})
}
.frame(width: 150, height: 100)
}
}
First, I'll answer the question as asked, but then I'll follow up with why it actually doesn't matter because it's solving the wrong problem.
Accessing the App Delegate in a SwiftUI lifecycle app
When you're using SwiftUI to manage your app lifecycle, you don't use an app delegate directly. SwiftUI will set the application's app delegate to an instance of its own class, SwiftUI.AppDelegate. This is what you saw when trying to cast it to MenuBarTest.AppDelegate. You can't cast it to your class, because it's not an instance of your class.
As a backwards compatibility feature, SwiftUI's app delegate lets you provide your own delegate instance to that it will forward to. This is wired up with the NSApplicationDelegateAdaptor property wrapper. If you want to access an instance, that delegate instance property is the way to get at it (not NSApplication.shared.delegate).
Don't even use the App Delegate
You can disregard everything I said so far, because this is just a bad design, and you shouldn't use it. The App Delegate should be strictly limited to code related to managing the life cycle of your app and how it interacts with the system. It should not be a kitchen-junk-drawer of any code that fits.
Having random code right in the App Delegate is the AppKit equivalent to putting all your code in the main() function of a C program. It's done for brevity, but rarely a good idea. This is what you'll often see in tutorials, which aim to keep file and line count at a minimum, to illustrate some other point (e.g. Related to menu bar configuration, in this case).
As you'll notice, none of the code in this app delegate does much that's specific to the App life cycle. It just calls out to NSStatusBar. This code can just be put in any other appropriate place, like any other stateful SwiftUI code.

Menu Bar Popover is missing from application's elements tree on macOS

I'm currently trying to write simple UI Tests for an App that comes with a popover in the macOS menu bar. One of the tests is supposed to open the menu bar popover and interact with its content. The problem is that the content seems to be completely absence from the application's element tree.
I'm creating the pop-up like so:
let view = MenuBarPopUp()
self.popover.animates = false
self.popover.behavior = .transient
self.popover.contentViewController = NSHostingController(rootView: view)
…and show/hide it on menu bar, click like this:
if let button = statusItem.button {
button.image = NSImage(named: NSImage.Name("MenuBarButtonImage"))
button.action = #selector(togglePopover(_:))
}
#objc func togglePopover(_ sender: AnyObject?) {
if self.popover.isShown {
popover.performClose(sender)
} else {
openToolbar()
}
}
func openToolbar() {
guard let button = menuBarItem.menuBarItem.button else { return }
self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
NSApp.activate(ignoringOtherApps: true)
}
When I dump the element tree, the popover is not present:
[…]
MenuBar, 0x7fef747219d0, {{1089.0, 1.0}, {34.0, 22.0}}
StatusItem, 0x7fef74721b00, {{1089.0, 1.0}, {34.0, 22.0}}
[…]
Everything works when I compile the app and click around, I just can't make it work when it comes to automated UI testing. Any ideas?
Okay, after spending a lot of time with it, this solved my problem.
First, I had to add the Popover to the app's accessibility children like so:
var accessibilityChildren = NSApp.accessibilityChildren() ?? [Any]()
accessibilityChildren.append(popover.contentViewController?.view as Any)
NSApp.setAccessibilityChildren(accessibilityChildren)
However, this didn't seem to solve my problem at first. I'm using an App Delegate in a SwiftUI application. After tinkering with it for quite some time, I figured out that the commands I've added in my App.swift didn't go too well with my changes to the accessibility children in the App Delegate. After removing the commands from the Window Group, everything worked as expected.
#main
struct MyApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
// .commands {
// CommandGroup(replacing: CommandGroupPlacement.appSettings) {
// Button("Preferences...") { showPreferences() }
// }
// }
}
}

Status button highlight when popover is active (Swift, macOS)

I am making a status bar macOS app. On clicking on the status bar icon, I am showing an NSPopover (not an NSMenu). However, when my NSPopover is shown, my status menu icon is not highlighted. It is only highlighted for a moment when I click it. I want it to stay highlighted, much like how it behaves with the wifi status bar icon.
I know that if I use a NSMenu instead of a NSPopover, it can probably be fixed. But the requirement is such that I need to use a NSPopover.
I have tried the following approaches, but to no avail:
1.
let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)
if let button = statusItem.button {
button.setButtonType(.pushOnPushOff)
}
statusItem.highlightMode = true
statusItem.button?.highlight(true)
statusItem.button?.isHighlighted = true
I am not very experienced with status bar apps. So I am not really sure about which approach to take here.
The left most icon is my status bar app icon. The popover is currently active but the icon is not highlighted. I had to crop out the popover.
This can be done, but to do it reliably requires some tight coupling. In this example, I assume you have a NSViewController named PopoverController that has a togglePopover() method and that you've set this controller as the target of the status bar button.
Step 0: Context
Here's the basic setup of the class that controls the popover:
final class PopoverController: NSViewController
{
private(set) var statusItem: NSStatusItem!
private(set) var popover: NSPopover
// You can set up your StatusItem and Popover anywhere; but here's an example with -awakeFromNib()
override func awakeFromNib()
{
super.awakeFromNib()
statusItem = NSStatusBar.system.statusItem(withLength: 20.0)
statusItem.button?.sendAction(on: [.leftMouseDown, .rightMouseDown])
statusItem.button?.image = NSImage(named: "statusBar-icon")
statusItem.button?.target = self
statusItem.button?.action = #selector(togglePopover)
popover = NSPopover()
popover.behavior = .transient
popover.delegate = self
popover.contentViewController = self
popover.setValue(true, forKeyPath: "shouldHideAnchor") // Private API
}
#objc func togglePopover()
{
if popover.isShown
{
popover.performClose(nil)
}
else if !popover.isShown,
let button: NSButton = statusItem.button,
button.window?.contentView != nil, // Exception thrown if view is nil or not part of a window.
button.superview != nil
{
popover.show(relativeTo: .zero, of: button, preferredEdge: .minY)
}
}
}
Step 1: Override the Status Button
extension NSStatusBarButton
{
public override func mouseDown(with event: NSEvent)
{
if event.modifierFlags.contains(.control)
{
self.rightMouseDown(with: event)
return
}
if let controller: PopoverController = self.target as? PopoverController
{
controller.togglePopover()
self.highlight(controller.popover.isShown)
}
}
}
Step 2: Handle Popover Closing
Make sure PopoverController conforms to NSPopoverDelegate and implement this delegate method:
func popoverDidClose(_ notification: Notification)
{
statusItem.button?.highlight(false)
}
Outcome
With all of that in place, the button highlighting now works just as it does for Apple's system status bar items like Control Center, Wifi, Battery, etc.
Note: you'll also need to add a global event monitor to listen for clicks that happen outside of your popover to ensure that it closes properly when the user clicks away from it. But that's outside the scope of this question and available elsewhere on SO.

Creating a macos windowless menu bar application with SwiftUI

I am looking for a solution to create a macos windowless menu bar application with SwiftUI.
I have implement the function ralated to the menu bar, the problem is removing the main window and remove the application from the dock.
I have tried to set Application is agent (UIElement) to YES in Info.plist but it only hide the application from the dock while the window is still there.
And I have tried to modify #main but it doesn't work too.
Is there any way to achieve that? Thank you so much!
My code:
App.swift
import SwiftUI
#main
struct DiskHealthApp: App {
#NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popOver = NSPopover()
func applicationDidFinishLaunching(_ notification: Notification) {
let menuView = ContentView()
popOver.behavior = .transient
popOver.animates = true
popOver.contentViewController = NSViewController()
popOver.contentViewController?.view = NSHostingView(rootView: menuView)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let menuButton = statusItem?.button {
menuButton.image = NSImage(systemSymbolName: "externaldrive", accessibilityDescription: nil)
menuButton.action = #selector(menuButtonToggle)
}
}
#objc func menuButtonToggle() {
if let menuButton = statusItem?.button {
self.popOver.show(relativeTo: menuButton.bounds, of: menuButton, preferredEdge: NSRectEdge.minY)
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Setting just the Application is agent (UIElement) to YES will not be sufficient. You will also have to change your AppDelegate by adding the following,
A NSPopover
Add a NSStatusItem
into your AppDelegate for things to work
How to make an NSPopover?
Go to your app delegate. ( If you don't have an AppDelegate. Create an AppDelegate class and delegate it into the starting point of your app which will be annotated with #main. Add your AppDelegate as follows )
#main
struct SomeApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
}
After doing so, you can begin making your Menubar app, by changing your Appdelegate to represent the following
class AppDelegate: NSObject, NSApplicationDelegate {
// popover
var popover: NSPopover!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view (i.e. the content).
let contentView = ContentView()
// Create the popover and sets ContentView as the rootView
let popover = NSPopover()
popover.contentSize = NSSize(width: 400, height: 500)
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: contentView)
self.popover = popover
// Create the status bar item
self.statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let button = self.statusBarItem.button {
button.image = NSImage(named: "Icon")
button.action = #selector(togglePopover(_:))
}
}
// Toggles popover
#objc func togglePopover(_ 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)
}
}
}
}
After doing so you should/can set Application is agent(UIElement) to YES
Final Step
This section will be broken down into 2 sections, namely 4.1 and 4.2
4.1 is for those who used AppDelegate life cycle to initialize their project
4.2 is for those who created a project using SwiftUI life cycle.
4.1 - AppDelegate Life cycle
Go to your Main.storyboard and to delete the Window Controller scene If you have the Main.storyboard. This should get rid of the NSWindow that pops up.
(image credit)
4.2 - SwiftUI Life cycle
Here, since you don't have a Storyboard file to delete scenes, at this point your app will launch with NSWindow and NSPopover. To remove the NSWindow that opens, go to your app starting point which is annotated as #main, and make the following changes to the code
#main
struct SomeApp: App {
// Linking a created AppDelegate
#NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
// IMPORTANT
Settings {
AnyView()
}
}
}
For more info, refer this article

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?