[SC]()

iOS. Apple. Indies. Plus Things.

Handle Keyboard Presses Using SwiftUI in macOS

// Written by Jordan Morgan // May 9th, 2022 // Read it in about 2 minutes // RE: Tech Notes

This post is brought to you by Emerge Tools, the best way to build on mobile.

As I continue to venture further into uncharted territory with macOS development, primarily in SwiftUI, I made it a goal from the beginning to simply do one thing:

Not compromise.

My first mac app has to earn the title of being a mac app, ya know? It’s gotta do the platform proud and look and feel like a wonderful, native mac app — lock, stock and barrel.

Though I can’t sit here and say I know what a best-in-class macOS app looks like yet, I’ve been a macOS user myself since 2006 and have a good feel for the platform and what nice apps on it behave like. I frequent the H.I.G. quite often and I’m doing what I did to learn iOS design, look at the apps that do it well and learn from them.

Which brings me to today’s topic on tech notes - responding to arbitrary keyboard presses in macOS from SwiftUI views. Any mac-🍑’d mac app worth its salt can be navigated solely via the keyboard, and my app will be no different.

let concatenatedThoughts = """

Keep in mind, this is my first crack at AppKit. Anything you see here could be the completely incorrect approach, I'm not quite sure. If it is, definitely let me know - I'm always on the lookout for AppKit knowledge.

"""

However, when you’re doing a dance between AppKit wrapped NSView instances and SwiftUI views themselves, things get a little dicey. In AppKit, as I understand it, you can handle keyboard presses by way of NSResponder for regular views (i.e. ones not built for it out of the box, like text editing controls), not unlike how things work on iOS. So long as you have first responder status, you can do things like this:

override init(frame frameRect: NSRect) {
    super.init(frame: frameRect)
    NSEvent.addLocalMonitorForEvents(matching: .keyDown) { (aEvent) -> NSEvent? in
        self.keyDown(with: aEvent)
        return aEvent
    }
}

override func keyDown(with event: NSEvent) {
    // Handle keycode
}

In SwiftUI, so long as you have focus, you can handle whatever SwiftUI exposes to you via modifiers to do similar things. However, that doesn’t include handling any old keyboard press on any old view as far as I know. What I wanted was a modifier to do something like this:

var body: some View {
    VStack {
        // Child views
    }
    .focusable()
    .focused($someFocusBinding, equals: someFocusVar)
    .onKeyDown { keyCode in 
        // This would be perfect!
    }
}

You can apply keyboard shortcuts, but those aren’t the same thing. I want to handle any key press however I want in addition to adding keyboard shortcuts too. There’s a promising lead, or so I thought, with the onCommand modifier:

var body: some View {
    VStack {
        // Child views
    }
    .focusable()
    .focused($someFocusBinding, equals: someFocusVar)
    .onCommand(#selector(NSResponder.keyDown(with:))) {
        // I thought this might work
    }
}

Alas, that doesn’t seem to do it. Maybe I’m holding it wrong? Regardless, I ended up wrapping a primitive NSView to intercept these key down events, and that’s working. Then, I roll that into a custom modifier and apply it to the view in question:

var body: some View {
    VStack {
        // Child views
    }
    .focusable()
    .focused($someFocusBinding, equals: someFocusVar)
    .onKeyDown { keyCode in
        // Handle key press
    }
}

Voilà. I’m not crazy about wrapping up a view into another NSView, but such is life. As I mentioned, toggling SwiftUI’s focus setup and resolving that with the responder chain for wrapped views is a bit tricky, but that’s what I signed up for. While some may read this and think “How could SwiftUI not support that out of the box!”, I’m not losing any sleep over it. Representables exist for a reason, and I think this is one of them.

Until next time ✌️

···

Spot an issue, anything to add?

Reach Out.