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.
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.
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 🚀
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 🙏