Ardent Swift

Spotlight-like hotkey window

02 Sep 2024

In this week’s article we will take a look at how to create a system-wide Spotlight-like hotkey window for our SwiftUI app.

What we want to achieve

A window that would float on top of all other windows and can be triggered on any macOS space by pressing a user-customizable hotkey window, just like Spotlight. Spotlight-like hotkey window

My approach

Let’s start by defining some simple View such as this HotkeyView that mimics the Spotlight search bar, but of course that can be any View of your liking.

struct HotkeyView: View {

    @State
    private var text: String = ""

    // Since we have a TextField here, we want to make it focused on appear
    @FocusState
    private var focused

    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: "magnifyingglass")
                .resizable()
                .frame(width: 24, height: 24)

            TextField("My hotkey window", text: $text)
                .font(.title)
                .textFieldStyle(.plain)
                .focused($focused)
        }
        .padding()
        .foregroundStyle(.primary)
        // Setting a static width will save us some headache when centering the NSPanel
        .frame(width: 750)
        .onAppear {
            focused = true
        }
    }
}

Hotkey Recording

While we can create our own implementation, I really recommend using a library called KeyboardShortcuts. It’s reliable, very easy to use, and can get you started in minutes.

Let’s add it to our project by going to Xcode > File > Add Package Dependencies… and pasting the KeyboardShortcuts GitHub URL in the search bar.

First, for convenience’s sake, we define an extension on the library’s KeyboardShortcut.Name like so 👇 . It is not only a good practice, but also it will prevent us from misspelling the name later on or in case it is renamed in the future.

extension KeyboardShortcuts.Name {
    static let openHotkeyWindow = Self("openHotkeyWindow")
}

Now we need to create some Settings Scene where we would let the user customize the hotkey. Of course, the setting doesn’t need to be inside native Settings Scene, however, that’s usually where the user would logically look for hotkey settings. The use of the native Settings allows the App Settings to be opened by pressing ⌘+. What’s most important is the use of KeyboardShortcuts.Recorder(for: .openHotkeyWindow) to have a SwiftUI View that allows us to record the hotkey with our custom name.

struct SettingsScreen: View {

    var body: some View {
        HStack {
            Text("Configure Hotkey")

            KeyboardShortcuts.Recorder(for: .openHotkeyWindow)
        }
        .padding()
    }
}

And then inside the var body: some Scene in our @main App struct we need to add this:

Settings {
    SettingsScreen()
}

Hotkey Handling

Since we have our convenient KeyboardShortcuts library, listening to our hotkey being pressed is as simple as this:

KeyboardShortcuts.onKeyUp(for: .openHotkeyWindow) {}

If you run your app and put some debugPrint or a breakpoint inside this closure, you should see it being called.

Now that we are listening to our hotkey presses and we have our HotkeyView in place, we can move on to making it appear when the hotkey is pressed.

Issues along the way 😢

I have attempted to use a WindowGroup(id:) and WindowGroup(value:), and then opening such window with @Environment(\.openWindow) in the hotkey handling closure, but unfortunately as of macOS Sequoia it still doesn’t have the desired effect, and UtilityWindow did not help either. For me the window wasn’t opening on the current space I was at with Mission Control, sometimes the window didn’t float on top of everything else and I had issues with having it appear in fullscreen mode.

Resorting to AppKit

Initially, I tried experimenting with NSWindow that would take in a SwiftUI View in the parameter, but then I have stumbled upon this article Make a floating panel in SwiftUI for macOS that suggested using NSPanel. When comparing the behavior with Spotlight, it seems like NSPanel is the way to go. It works well with multiple spaces and screens and it by default is dismissable by pressing the esc key. Taking from the article, I have slightly modified the proposed NSPanel subclass like so:

final class FloatingPanel<Content: View>: NSPanel {

    init(
        view: () -> Content,
        // We need to provide NSRect since the NSWindow doesn't inherit the size from the content
        // by default. Not setting the contentRect would result in incorrect positioning
        // when centering the window
        contentRect: NSRect,
        didClose: @escaping () -> Void
    ) {
        self.didClose = didClose

        super.init(
            contentRect: .zero,
            styleMask: [
                .borderless,
                .nonactivatingPanel,
                .titled,
                .fullSizeContentView
            ],
            backing: .buffered,
            defer: false
        )

        /// Allow the panel to be on top of other windows
        isFloatingPanel = true
        level = .statusBar

        /// Allow the pannel to be overlaid in a fullscreen space
        collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]

        /// Don't show a window title, even if it's set
        titleVisibility = .hidden
        titlebarAppearsTransparent = true

        /// Since there is no title bar make the window moveable by dragging on the background
        isMovableByWindowBackground = true

        /// Hide when unfocused
        hidesOnDeactivate = true

        /// Hide all traffic light buttons
        standardWindowButton(.closeButton)?.isHidden = true
        standardWindowButton(.miniaturizeButton)?.isHidden = true
        standardWindowButton(.zoomButton)?.isHidden = true

        /// Sets animations accordingly
        animationBehavior = .utilityWindow

        /// Set the content view.
        /// The safe area is ignored because the title bar still interferes with the geometry
        contentView = NSHostingView(
            rootView: view()
        )
    }

    private let didClose: () -> Void

    /// Close automatically when out of focus, e.g. outside click
    override func resignKey() {
        super.resignKey()
        close()
    }

    /// Close and toggle presentation, so that it matches the current state of the panel
    override func close() {
        super.close()
        didClose()
    }

    /// `canBecomeKey` is required so that text inputs inside the panel can receive focus
    override var canBecomeKey: Bool {
        return true
    }

    // For our use case, we don't want the window to become main and thus steal the focus from
    // the previously opened app completely
    override var canBecomeMain: Bool {
        return false
    }
}

In the original implementation there was an issue that the instance was never deallocated after closing the NSPanel, because there wasn’t any callback that would signal that the window was closed. For that I added didClose closure.

I have also modified it so that it doesn’t have to be tied to an existing View with a ViewModifier by a Binding.

Last steps

To encapsulate logic in one place, I have created a class FloatingPanelHandler that holds a reference to an instance of our NSPanel and in case our hotkey is pressed again, it would dismiss the NSPanel if already open, just like Spotlight.

final class FloatingPanelHandler {

    private var panel: NSPanel?

    init() {
        KeyboardShortcuts.onKeyUp(for: .openHotkeyWindow) {
            // We don't need [weak self] here since this class will be alive during the whole
            // lifecycle of the App.
            if self.panel == nil {
                let panel = FloatingPanel(
                    view: {
                        // Create the SwiftUI View that you want to be shown
                        // in the floating window
                        HotkeyView()
                    },
                    // If you want your window to be perfectly centered, we need to provide
                    // proper width and height. For our case, since the height is pretty small,
                    // it's ok-ish to just pass 0 as the height (Designers, please don't kill me)
                    contentRect: NSRect(x: 0, y: 0, width: 750, height: 0),
                    didClose: {
                        // When `didClose` gets called, make sure to remove the reference
                        // to allow it to deallocate
                        self.panel = nil
                    }
                )

                // It's important to activate the NSApplication so that our window
                // shows on top and takes the focus.
                NSApplication.shared.activate()
                panel.makeKeyAndOrderFront(nil)
                panel.orderFrontRegardless()
                panel.center()

                self.panel = panel
            } else {
                // If the panel is already shown and the hotkey is pressed again,
                // we close it and nullify the reference
                self.panel?.close()
                self.panel = nil
            }
        }
    }
}

As a last step we need to save an instance of the FloatingPanelHandler in our @main App struct, simply by adding:

private let floatingPanelHandler = FloatingPanelHandler()

Since reading the Hotkey is closure based, this is sufficient to make your App always listen to the keyboard shortcut the user has defined and open the hotkey window.

The result 🎉

Voila! Now we should be able to open the SwiftUI View on any space, regardless of full-screen mode, it should automatically take focus, and close when pressing esc or when clicking outside of the window. Spotlight-like hotkey window with padding As of macOS Sequoia there still seems to be an issue with SafeArea insets when a SwiftUI view is wrapped inside a NSHostingView 👀 To make sure that the SafeArea gets ignored properly for our SwiftUI View, check out my previous article How to hide toolbar in macOS App’s window in SwiftUI!

We can then change our FloatingPanel subclass’s init at the bottom as follows:

contentView = NSHostingViewSuppressingSafeArea(
    rootView: view()
)

This should give us the desired result 🚀 Spotlight-like hotkey window

Conclusion

While this approach has a lot of room for improvement, it’s the only thing that I got working with multiple spaces and monitors support after spending many hours on it (cries in building SwiftUI apps on macOS 🥲)

Happy coding! 💻

Coming Next 🔜

In the next article we will take a look at how to ~create a ScrollView with custom Pull actions~ programatically resolve dynamic UIColor/NSColor from Hex values in Light and Dark Mode!

Edit

When working on my app today (20th Sep 24) I found out that the code has been creating a strong reference to the SwiftUI View. I have modified the code so that strong reference cycle no longer happens 🙏