← Blog

Whid — The Origin

Chapter 6: Creating a Spotlight-like floating panel in Swift

3 June 2022

The Organizational Change Specialist Manager witch wants to have easy starting and stopping of the tracker. Because everything has to be easy these days. Not willing to put the work in anymore, these manager people. Expecting everything to just be done for them. Awful awful awful!

Hrmpfh, I’ll do it. But only because my mouth is watering when thinking about a juicy fly by now. Curse that witch!

Creating a custom panel

For entering new time entries, we want to have a Spotlight-like window that can be opened with a hotkey and gets focus, and disappears when clicking somewhere else or pressing Escape. Since custom window styling is not yet supported in SwiftUI at the time of writing, we have to use AppKit to achieve the behaviour we want. For a custom FloatingPanel, we are extending NSPanel with some custom styling.

import AppKit

class FloatingPanel: NSPanel {
    init(contentRect: NSRect, backing: NSWindow.BackingStoreType, defer flag: Bool) {
        // nonactivatingPanel makes it so the app is not set to active when the panel is opened or clicked
        // titled makes the window look like a standard Mac UI Window. Here mainly useful for the dropshadow.
        // fullSizeContentView allows the content to go over the space that is normally reserved for the titlebar.
        super.init(contentRect: contentRect, styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView], backing: backing, defer: flag)

        // Make sure that the panel is in front of almost all other windows
        self.isFloatingPanel = false
        self.level = .floating
        
        // Allow the panel to appear in a fullscreen space
        self.collectionBehavior.insert(.fullScreenAuxiliary)
        
        // Don't delete panel state when it's closed.
        self.isReleasedWhenClosed = false

        // Make it transparent, the view inside will have to set the background.
        // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want.
        self.isOpaque = false
        self.backgroundColor = .clear
        
        // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar.
        self.isMovableByWindowBackground = true
        self.titlebarAppearsTransparent = true
    }
}

With this, you can create a window that floats above others. Since it’s invisible due to the opaque background for now, it is not very usable in this state. We need to make sure to color the background of the content when using it later.

To make handling easier and add some additional functionality, we add a FloatingPanelController. It manages the opening and closing of the window. Since we want our time tracker to be quick and easy to open, we add a global hotkey using the KeyboardShortcuts library. We also add some additional functionality to make the panel disappear when we click outside of it or when it loses focus.

import KeyboardShortcuts
import AppKit
import SwiftUI

class CompactTrackerWindowController {
    private var compactTrackerPanel: FloatingPanel

    // Not following the convention of SwiftUI for constant naming here, but that's how we roll.
    private let DEFAULT_DIST_EDGE: CGFloat = 50
    // Height doesn't really matter since it stretches with content in our case.
    private let PANEL_HEIGHT: CGFloat = 50
    private let PANEL_WIDTH: CGFloat = 550

    init() {
        let contentView = CompactTrackerView().edgesIgnoringSafeArea(.top)

        // Create the window and set the content view.
        compactTrackerPanel = FloatingPanel(contentRect: NSRect(x: 0, y: 0, width: PANEL_WIDTH, height: PANEL_HEIGHT), backing: .buffered, defer: false)
        // Since we are using SwiftUI for our views, we need to encapsulate the view into an NSHostingView.
        compactTrackerPanel.contentView = NSHostingView(rootView: contentView)
        compactTrackerPanel.setFrameTopLeftPoint(getDefaultPosition())

        // Add keyboard shortcut (Command + period) for opening and closing the floating panel.
        KeyboardShortcuts.setShortcut(KeyboardShortcuts.Shortcut.init(KeyboardShortcuts.Key.period, modifiers: .command), for: .toggleTracker)
        KeyboardShortcuts.onKeyDown(for: .toggleTracker) { [self] in togglePopoverDefaultPosition() }

        // The panel should close when it loses focus, i.e. when we click outside of it.
        NotificationCenter.default.addObserver(self, selector: #selector(closeFloatingPanel), name: NSWindow.didResignKeyNotification, object: compactTrackerPanel)
        NotificationCenter.default.addObserver(self, selector: #selector(closeFloatingPanel), name: NSWindow.didResignMainNotification, object: compactTrackerPanel)
    }

    // The objc keyword is necessary for the #selector to work.
    @objc func closeFloatingPanel() {
        compactTrackerPanel.close()
    }
    
    func togglePopoverDefaultPosition() {
        togglePopover(position: getDefaultPosition())
    }

    func togglePopoverDynamicPosition() {
        togglePopover(position: getPositionOnScreen())
    }

    // Open or close popover
    private func togglePopover(position pos: NSPoint) {
        if (compactTrackerPanel.isVisible) {
            compactTrackerPanel.close()
        } else {
            compactTrackerPanel.orderFront(nil)
            compactTrackerPanel.makeKey()
            compactTrackerPanel.setFrameTopLeftPoint(pos)
        }
    }
    
    private func getDefaultPosition() -> NSPoint {
        let mouseLocation = NSEvent.mouseLocation
        let screens = NSScreen.screens
        if let screenWithMouse = (screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }) {
            let frame = screenWithMouse.frame
            return NSPoint(x: frame.minX + frame.width - (compactTrackerPanel.frame.width) - DEFAULT_DIST_EDGE, y: frame.maxY - DEFAULT_DIST_EDGE)
        }
        return NSPoint(x: 0, y: 0)
    }
}

That’s it, now you have the basic building block for a Spotlight-like floating panel. It’s very important that the content view has a non-transparent background, otherwise your whole panel will have no background.

When using SwiftUI, you have to add an AppDelegate to your application to support this type of Panel. You can achieve this with the NSApplicationDelegateAdaptor.

@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

Create the window controller in your AppDelegate and you are ready to go.

class AppDelegate: NSObject, NSApplicationDelegate {
    private var ctwc: CompactTrackerWindowController?

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        ctwc = CompactTrackerWindowController()
    }
}

For our time tracker, we used this panel along with some content to start and stop tracking time. Here it is as an example of what you can do with this: