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.
Related
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() }
// }
// }
}
}
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.
I have created a method in an NSWindow extension that allows me to add a button next to the text in the title bar. This is similar to the "down chevron" button that appears in the title bar of Pages and Numbers. When the button is clicked, an arbitrary code, expressed as a closure, is run.
While I have that part working fine, I would also like the button to be invisible most of the time and only become visible when the mouse is scrolled into the title bar area. This would be mimicking the way that Pages and Numbers displays the button.
However, I'm having difficulties getting the show/hide to work properly. I believe I can do it if I make it completely custom in the application delegate, and possibly by subclassing NSWindow, but I would really like to keep it as a single method in an NSWindow extension. In this way the code would be easily reusable in multiple applications.
To accomplish this I believe I need to inject an additional handler/listener that will tell me when the mouse enters and leaves the appropriate area. I can define the necessary area using an NSTrackingArea, but I haven't figured out how to "inject" an event listener without the need of subclasses. Does anyone know how (or if) such a thing is possible?
The key to handling the show/hide based on the mouse position was to use an NSTrackingArea to signify the portion that we are interested in, and to handle the mouse enter and mouse exit events. But since this can't be done directly on the title bar view (since we have to subclass the view in order to add the event handlers) we need to create an additional NSView that is invisible but covers the area we want to track.
I'll post the full code below, but the key parts related to this question are the TrackingHelper class defined near the bottom of the file and the way it is added to the titleBarView with its constrains set to be equal to the size of the title bar. The class itself is designed to take three closures, one for the mouse enter event, one for the mouse exit, and one for the action to take when the button is pressed. (Technically the latter doesn't really need to be part of the TrackingHelper, but it is a convenient place to put it to ensure it does not go out of scope while the UI still exists. A more correct solution would be to subclass NSButton to keep the closure, but I have always found subclassing NSButton to be a royal pain.)
Here is the full text of the solution. Note that this has a couple of things that depend on another library of mine - but they are not necessary for the understanding of this problem and are used to deal with the button image. If you wish to use this code you will need to replace the getImage function with one that creates the image you want. (And if you want to see what KSSCocoa is adding, you can obtain it from https://github.com/klassen-software-solutions/KSSCore)
//
// NSWindowExtension.swift
//
// Created by Steven W. Klassen on 2020-02-24.
//
import os
import Cocoa
import KSSCocoa
public extension NSWindow {
/**
Add an action button to the title bar. This will add a "down chevron" icon, similar to the one used in
Numbers and Pages, just to the right of the title in the title bar. When clicked it will run the given
lambda.
*/
#available(OSX 10.14, *)
func addTitleActionButton(_ lambda: #escaping () -> Void) -> NSButton {
guard let titleBarView = getTitleBarView() else {
fatalError("You can only add a title action to an app that has a title bar")
}
guard let titleTextField = getTextFieldChild(of: titleBarView) else {
fatalError("You can only add a title action to an app that has a title field")
}
let trackingHelper = TrackingHelper()
let actionButton = NSButton(image: getImage(),
target: trackingHelper,
action: #selector(trackingHelper.action))
actionButton.setButtonType(.momentaryPushIn)
actionButton.translatesAutoresizingMaskIntoConstraints = false
actionButton.isBordered = false
actionButton.isEnabled = false
actionButton.alphaValue = 0
trackingHelper.translatesAutoresizingMaskIntoConstraints = false
trackingHelper.onButtonAction = lambda
trackingHelper.onMouseEntered = {
actionButton.isEnabled = true
actionButton.alphaValue = 1
}
trackingHelper.onMouseExited = {
actionButton.isEnabled = false
actionButton.alphaValue = 0
}
titleBarView.addSubview(trackingHelper)
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[trackingHelper]-0-|",
options: [], metrics: nil,
views: ["trackingHelper": trackingHelper]))
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[trackingHelper]-0-|",
options: [], metrics: nil,
views: ["trackingHelper": trackingHelper]))
titleBarView.addSubview(actionButton)
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:[titleTextField]-[actionButton(==7)]",
options: [], metrics: nil,
views: ["actionButton": actionButton,
"titleTextField": titleTextField]))
titleBarView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-1-[actionButton]-3-|",
options: [], metrics: nil,
views: ["actionButton": actionButton]))
DistributedNotificationCenter.default().addObserver(
actionButton,
selector: #selector(actionButton.onThemeChanged(notification:)),
name: NSNotification.Name(rawValue: "AppleInterfaceThemeChangedNotification"),
object: nil
)
return actionButton
}
fileprivate func getTitleBarView() -> NSView? {
return standardWindowButton(.closeButton)?.superview
}
fileprivate func getTextFieldChild(of view: NSView) -> NSTextField? {
for subview in view.subviews {
if let textField = subview as? NSTextField {
return textField
}
}
return nil
}
}
fileprivate extension NSButton {
#available(OSX 10.14, *)
#objc func onThemeChanged(notification: NSNotification) {
image = image?.inverted()
}
}
#available(OSX 10.14, *)
fileprivate func getImage() -> NSImage {
var image = NSImage(sfSymbolName: "chevron.down")!
if NSApplication.shared.isDarkMode {
image = image.inverted()
}
return image
}
fileprivate final class TrackingHelper : NSView {
typealias Callback = ()->Void
var onMouseEntered: Callback? = nil
var onMouseExited: Callback? = nil
var onButtonAction: Callback? = nil
override func mouseEntered(with event: NSEvent) {
onMouseEntered?()
}
override func mouseExited(with event: NSEvent) {
onMouseExited?()
}
#objc func action() {
onButtonAction?()
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
for trackingArea in self.trackingAreas {
self.removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(trackingArea)
}
}
I'm trying to create a macOS app like Photos.app. The NSWindowController has a toolbar with a segmented control. When you tap on the segmented control, it changes out the the NSViewController within the NSWindowController.
What I have so far is an NSWindowController with an NSViewController. I have subclassed NSWindowController where I have the method that gets called whenever the user taps on the segmented control.
Essentially, whatever segment is clicked, it will instantiate the view controller that is needed and set it to the NSWindowController's contentViewController property.
Is this the correct way of doing it?
Also, the NSWindowController, I am thinking, should have properties for each of the NSViewControllers it can switch to that get lazy loaded (loaded when the user taps them and they get held around to be re-used to prevent re-initializing).
Code:
import Cocoa
class MainWindowController: NSWindowController
{
var secondaryViewController:NSViewController?
override func windowDidLoad()
{
super.windowDidLoad()
// Implement this method to handle any initialization after your window controller's window has been loaded from its nib file.
}
#IBAction func segmentedControlDidChange(_ sender: NSSegmentedControl)
{
print("Index: \(sender.selectedSegment)")
if sender.selectedSegment == 3 {
if secondaryViewController == nil {
let viewController = storyboard?.instantiateController(withIdentifier: "SecondaryViewController") as! NSViewController
secondaryViewController = viewController
}
self.window?.contentViewController = self.secondaryViewController
}
}
}
I'm new to macOS development, however, I've been doing iOS for quite some time. If there is a better way, I'd like to know about it. Thanks!!!
to move the tab/segmented-control to the titlebar, you need:
add toolbar to window, and add the controls to the toolbar,
hide title:
class TopLevelWindowController: NSWindowController {
override func windowDidLoad() {
super.windowDidLoad()
if let window = window {
// reminder like style
// window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
// window.styleMask.insert(.fullSizeContentView)
}
}
}
now, toolbar will be merged into the top bar position.
I have an app that shows a custom view on the menu bar of macOS. When the custom view is clicked, I want to show a context menu (NSMenu).
My code looks like this:
private let statusItem = NSStatusBar.system().statusItem(withLength: NSVariableStatusItemLength)
private var timer: Timer?
private var view: MenuView!
private var menu: NSMenu!
private let popover = NSPopover()
private var popoverController: PopoverViewController?
func applicationDidFinishLaunching(_ aNotification: Notification)
{
self.view = createView()
self.statusItem.view = self.view
self.menu = NSMenu()
self.menu.addItem(NSMenuItem(title: "Quit", action: nil, keyEquivalent: ""))
self.statusItem.menu = self.menu
self.statusItem.menu = menu
}
However, when I click the view, the menu isn't shown like I would expect (well, it isn't shown at all).
When I don't use my custom view and only set up an image, the context menu opens when I click it.
How can I show the menu when using a custom view?
If you use a custom view you are responsible to handle all events, drawing and the highlighting.
In the init(frame:) method of the view pass the NSStatusBar instance. Assign the menu to the view rather than to statusItem.
At least you have to override mouseDown
override func mouseDown(with theEvent: NSEvent) {
statusItem.popUpMenu(menu!)
needsDisplay = true
}