While exploring Cocoa and AppKit I have decided to make my drop-in replacement for NSPopover where I can set such things as custom background color, border color, popover corner radius, and other properties.
The idea was to encapsulate the whole logic in one class with just a few simple public methods. So setting an NSStatusItem, drawing a window, handling clicks, all should be inside one class. And here is my implementation
Now coming back to the topic š§ In my implementation I wanted to be able to show not only static NSImage as an NSStatusItem but also some custom views. There are multiple ways to do that.
.view
propertyNSStatusItem has a very nice property that we can use for that purpose. We can do something like that:
private func configureStatusBarButton(with view: NSView) {
// 1
item = NSStatusBar.system.statusItem(withLength: NSWidth(view.bounds))
// 2
let statusItemContainer = StatusItemContainerView()
statusItemContainer.embed(view)
// 3
item?.view = statusItemContainer
}
This code is pretty straight forward. What we are doing here is the following:
item
is an optional property to hold NSStatusItem
for other needs. Thatās why in step 3 it has a question mark.)StatusItemContainerView
just for layout purpose. Inside this class, Iām adding our custom view as a subview and layout to be in the center of the StatusItemContainerView
.Now if you run this code you will see your beautiful custom view sitting in the menu bar. But wait⦠Have you tried to click it? Nothing will work and here is why:
Well, we all know that developers are lazy (are we? š) and, I donāt know how you guys, but I donāt want to implement all of that appearance, mouse clicks and other features by myself. But what other options do we have?
.button
propertySo we agreed that we want to use system features such as processing mouse clicks, sending action messages, etc. What if we will just add our custom menu bar view on top of status itemās .button
property?
private func configureStatusBarButton(with view: NSView) {
item = NSStatusBar.system.statusItem(withLength: NSWidth(view.bounds))
// 1
guard let button = item.button else { return }
button.target = self
button.action = #selector(handleStatusItemButtonAction(_:))
// 2
let statusItemContainer = StatusItemContainerView()
statusItemContainer.embed(view)
button.addSubview(statusItemContainer)
}
.button
property is optional, we need to unwrap it first. Then setting target and action. Without that left mouse click wonāt work. Ok, cool! Now everything works. Clicking on our custom status item will trigger an action. But what if now we want to handle another mouse event? Letās say by doing right mouse click we want to show an NSMenu
?
Well luckily for us there is another property of NSStatusItem we can use. This property is called⦠guess what? Yeah, right, itās .menu
. The problem here would be that NSStatusItem can not handle .button
and .menu
at the same time. Either one of these two options will work. So if you set .menu
the property then whenever you click the status item - the system will show you the menu. And handleStatusItemButtonAction(_:)
method we defined before wonāt be triggered.
And now the question is: how can we have at the same time NSMenu and a custom view as a status item, showing them independently from each other and triggering their actions by clicking left and right mouse? Honestly, I donāt know the right way š¤·š¼āāļø But, I believe, the approach I came up with is good enough. It works, so letās dive into it!
Let me describe the idea first and then we will switch to code. So the idea is to use NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown], handler: { ... })
to āobserveā when the user performs right mouse click and before passing this event next to all other receivers, we just need to set the menu. Then, when this event reaches the status item, the system will show our beautiful menu. When the user clicks somewhere else or will pick any of the menu options, the system will hide the menu and then the NSMenuDelegate
method menuDidClose(_:)
will be called where we just need to set our status item menu back to nil. Sounds easy, isnāt it?
import Cocoa
class EventMonitor {
enum MonitorType {
case global
case local
}
typealias GlobalEventHandler = (NSEvent?) -> Void
typealias LocalEventHandler = (NSEvent?) -> NSEvent?
private var monitor: Any?
private let mask: NSEvent.EventTypeMask
private let monitorType: MonitorType
private let globalHandler: GlobalEventHandler?
private let localHandler: LocalEventHandler?
init(monitorType: MonitorType, mask: NSEvent.EventTypeMask, globalHandler: GlobalEventHandler?, localHandler: LocalEventHandler?) {
self.mask = mask
self.monitorType = monitorType
self.globalHandler = globalHandler
self.localHandler = localHandler
}
deinit {
stop()
}
func start() {
switch monitorType {
case .global:
startGlobalMonitor()
case .local:
startLocalMonitor()
}
}
func stop() {
guard let monitor = self.monitor else { return }
NSEvent.removeMonitor(monitor)
self.monitor = nil
}
private func startGlobalMonitor() {
guard let handler = globalHandler else {
assertionFailure("Global event handler is not set.")
return
}
monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler)
}
private func startLocalMonitor() {
guard let handler = localHandler else {
assertionFailure("Local event handler is not set.")
return
}
monitor = NSEvent.addLocalMonitorForEvents(matching: mask, handler: handler)
}
}
Above you can find Event monitor implementation which wraps both local and global monitors into a nice, easy to use, wrapper. First thing first we need to start monitoring for local events:
private func setupMonitors() {
// 1
localEventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown], globalHandler: nil, localHandler: { [weak self] event -> NSEvent? in
guard let self = self, let currentEvent = event else { return event }
// 2
let isRightMouseClick = currentEvent.type == .rightMouseDown
let isControlLeftClick = currentEvent.type == .leftMouseDown && currentEvent.modifierFlags.contains(.control)
if isRightMouseClick || isControlLeftClick {
self.item.menu = self.menu
}
// 3
return event
})
}
Donāt forget to call startLocalMonitor
method if you are going to use my implementation of the EventMonitor. Check here for reference.
localHandler
parameter.true
we set our menu.And thatās it. Left clicks now are going to trigger handleStatusItemButtonAction(_:)
but right-clicks will show a menu!
I hope this was helpful and if you have any questions you can always reach me at Twitter @iSapozhnik š