Not much to say other than the title. I want to be able to take action in a SwiftUI view when a key is pressed and when it is released (on macOS). Is there any good way to do this in SwiftUI, and if not, is there any workaround?
Unfortunately keyboard event handling is one of those areas where it's painfully obvious that SwiftUI was designed first and foremost for iOS, with macOS being an afterthought.
If the key you're trying to detect is a modifier to a mouse click, such as cmd
, option
, or shift
, you can use the .modifiers
with onTapGesture
to distinguish it from an unmodified onTapGesture
. In that case, my experience with it is that you want the .onTapGesture
call that uses .modifiers
to precede the unmodified one.
Handling general key events for arbitrary views requires going outside of SwiftUI.
If you just need it for one View, one possibility is to implement that view with AppKit
so you can receive the keyboard events via the ordinary Cocoa firstResponder
mechanism, and then wrap that view in SwiftUI's NSViewRepresentable
. In that case your wrapped NSView
would update some @State
property in NSViewRespresentable
. A lot of developers using SwiftUI for macOS do it this way. While this is fine for a small number of views, if it turns out that you have to implement a lot of views in AppKit to make them usable in SwiftUI, then you're kind of defeating the point of using SwiftUI anyway. In that case, just make it an ordinary Cocoa app.
But there is another way...
You could use another thread that uses CGEventSource
to poll the keyboard state actively in conjunction with a SwiftUI @EnvironmentObject
or @StateObject
to communicate keyboard state changes to the SwiftUI View
s that are interested in them.
Let's say you want to detect when the up-arrow is pressed. To detect the key, I use an extension on CGKeyCode
.
import CoreGraphics
extension CGKeyCode
{
// Define whatever key codes you want to detect here
static let kVK_UpArrow: CGKeyCode = 0x7E
var isPressed: Bool {
CGEventSource.keyState(.combinedSessionState, key: self)
}
}
Of course, you have to use the right key codes. I have a gist containing all of the old key codes. Rename them to be more Swifty if you like. The names listed go back to classic MacOS and were defined in Inside Macintosh.
With that extension defined, you can test if a key is pressed anytime you like:
if CGKeyCode.kVK_UpArrow.isPressed {
// Do something in response to the key press.
}
Note these are not key-up or key-down events. It's simply a boolean detecting if the key is pressed when you perform the check. To behave more like events, you'll need to do that part yourself by keeping track of key state changes.
There are multiple ways of doing this, and the following code is not meant to imply that this is the "best" way. It is simply a way. In any case, something like the following code would go (or be called from) wherever you do global initialization when you app starts.
// These will handle sending the "event" and will be fleshed
// out further down
func dispatchKeyDown(_ key: CGKeyCode) {...}
func dispatchKeyUp(_ key: CGKeyCode) {...}
fileprivate var keyStates: [CGKeyCode: Bool] =
[
.kVK_UpArrow: false,
// populate with other key codes you're interested in
]
fileprivate let sleepSem = DispatchSemaphore(value: 0)
fileprivate let someConcurrentQueue = DispatchQueue(label: "polling", attributes: .concurrent)
someConcurrentQueue.async
{
while true
{
for (code, wasPressed) in keyStates
{
if code.isPressed
{
if !wasPressed
{
dispatchKeyDown(code)
keyStates[code] = true
}
}
else if wasPressed
{
dispatchKeyUp(code)
keyStates[code] = false
}
}
// Sleep long enough to avoid wasting CPU cycles, but
// not so long that you miss key presses. You may
// need to experiment with the .milliseconds value.
let_ = sleepSem.wait(timeout: .now() + .milliseconds(50))
}
}
The idea is just to have some code that periodically polls key states, compares them with previous states, dispatches an appropriate "event" when they change, and updates the previous states. The code above does that by running an infinite loop in a concurrent task. It requires creating a DispatchQueue
with the .concurrent
attribute. You can't use it on DispatchQueue.main
because that queue is serial not concurrent, so the infinite loop would block the main thread, and the program would become unresponsive. If you already have a concurrent DispatchQueue
you use for other reasons, you can just use that one instead of creating one just for polling.
However, any code that accomplishes the basic goal of periodic polling will do, so if you don't already have a concurrent DispatchQueue
and would prefer not to create one just to poll for keyboard states, which would be a reasonable objection, here's an alternate version that uses DispatchQueue.main
with a technique called "async chaining" to avoid blocking/sleeping:
fileprivate var keyStates: [CGKeyCode: Bool] =
[
.kVK_UpArrow: false,
// populate with other key codes you're interested in
]
fileprivate func pollKeyStates()
{
for (code, wasPressed) in keyStates
{
if code.isPressed
{
if !wasPressed
{
dispatchKeyDown(code)
keyStates[code] = true
}
}
else if wasPressed
{
dispatchKeyUp(code)
keyStates[code] = false
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50))
{
// The infinite loop from previous code is replaced by
// infinite chaining.
pollKeyStates()
}
}
// Start up key state polling
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
pollKeyStates()
}
With code in place to detect when keys are pressed, you now need a way to communicate that to your SwiftUI View
s. Again, there's more than one way to skin that cat. Here's an overly simplistic one that will update a View
whenever the up-arrow is pressed, but you'll probably want to implement something a bit more sophisticated... probably something that allows views to specify what keys they're interested in responding to.
class UpArrowDetector: ObservableObject
{
@Published var isPressed: Bool = false
}
let upArrowDetector = UpArrowDetector()
func dispatchKeyDown(_ key: CGKeyCode)
{
if key == .kVK_UpArrow {
upArrowDetector.isPressed = true
}
}
func dispatchKeyUp(_ key: CGKeyCode) {
if key == .kVK_UpArrow {
upArrowDetector.isPressed = false
}
}
// Now we hook it into SwiftUI
struct UpArrowDetectorView: View
{
@StateObject var detector: UpArrowDetector
var body: some View
{
Text(
detector.isPressed
? "Up-Arrow is pressed"
: "Up-Arrow is NOT pressed"
)
}
}
// Use the .environmentObject() method of `View` to inject the
// `upArrowDetector`
struct ContentView: View
{
var body: some View
{
UpArrowDetectorView()
.environmentObject(upArrowDetector)
}
}
I've put a full, compilable, and working example at this gist patterned on code you linked to in comments. It's slightly refactored from the above code, but all the parts are there, including starting up the polling code.
I hope this points you in a useful direction.
someConcurrentQueue.async {
. Can someConcurrentQueue
just be whatever I want, like keyboardManagerQueue
? Sorry if this is a dumb question. Thanks! –
Herophilus someConcurrentQueue
is just a stand-in for a DispatchQueue
that was created with the .concurrent
attribute (let someConcurrentQueue = DispatchQueue(label: "queueNameGoesHere", attributes: .concurrent)
). You could use an existing queue as long as it's not a serial queue, which means you can't use DispatchQueue.main
with my code example. –
Coronado DispatchQueue.main
. Instead of an finite loop in the async
closure, you could use "async chaining". To do that put the code from the closure into a named function, and then use DispatchQueue.main.async
in the function to call it self instead of an infinite loop. In that case, you don't want to sleep, because that would block the main thread. As I said, there are multiple ways to do it. –
Coronado DispatchQueue.main
. –
Coronado NSViewRepresentable
, but I had the problem that I have a NagivationView
, and the destination of that navigationView is where I need the keyboard input. So keyboard input would either not work when the NavigationView was focused, or cause the NavigationView to lose focus instantly after selecting an item on it, leading to it just flashing blue before turning to a darker gray. Do you know if this approach with CGEventSource
would have the same problem? –
Herophilus AppDelegate.didFinishLaunching()
but a lot of projects use WindowGroup
. If you don't have an obvious place to put it, you could put it in a static
function and use static
properties instead of globals for keyStates
and upArrowDetector
(or you version of it). Make make sure the code that polls for the key states is started before ContentView.body
is called. You will still need to inject the upArrowDetector
(or your equivalent) as instance properties though. –
Coronado NSViewRepresentable
solution has to do with the way NavigationView
steals/sets the firstResponder
behind the scenes. The good and bad thing about using CGEventSource
to detect keys is that it's global... meaning it doesn't care about firstResponder
, but that means if you use it in multiple Views, you will need to add some code to manage how the "events" are dispatched that makes sense for your app. –
Coronado MacMenuBar
that handles the menu bar in a SwiftUI way (another pain point for SwiftUI on macOS), and I provide a Sudoku app to demonstrate how to use it. I wanted the Sudoku puzzle to respond to key events so I took another approach that intercepts events from the AppKit event loop before SwiftUI gets its grubby paws on them. –
Coronado NSGameWindow.swift
, NSGameHostingView.swift
in Cocoa-BasedTypes
folder, and in Views/KeyboardHacks
there is KeyResponder.swift
and KeyResponderGroup.swift
. It's all very special case for that particular app, but it may give you ideas. The purpose was to demonstrate MacMenuBar
. Keyboard handling was just an incidental thing. I'm not sure I'd do it that way again. –
Coronado Missing argument for parameter 'detector' in call
. I tried passing KeyboardManager.spaceDetector
, which got rid of the error. –
Herophilus KeyboardManager.pollKeyStates()
in Window Group
, but WindowGroup
throws Type '()' cannot conform to 'View'
. –
Herophilus ContentView
unless you changed it. I don't mean where it is defined. I mean there is some place where an instance of it is created. Do you have it in a repo on Github I can take a look? –
Coronado static var isInitialized = false
in your KeyboardManager
. Rename your spaceDetector
property and make it private. Add a public
computed property that calls pollKeyStates()
if isInitialized
is false
, sets it to true
and returns the private spaceDetector –
Coronado return
the view. Adding non-View
code messes up View
's result builder. You can run into similar issues putting non-View
code in View.body
. –
Coronado Cannot use explicit 'return' statement in the body of result builder 'ViewBuilder'
. Here's code: pastebin.com/UGgxt1Nc. Also tysm for all the help so far, I would've been stuck ages ago without it –
Herophilus onKeyDown
event firing by subclassing NSTextView
with override func keyDown(...)
method override. For some reason, the keyDown handler doesn't fire with NSTextField but it does with NSTextView. It seems to be the least hacky solution that I've come across (e.g. doesn't require polling). –
Continence NSViewRepresentable
which might be objectionable when there many different View
types that need the functionality, and though I didn’t originally mention it, it also doesn’t address if it’s needed globally, not just in certain views. –
Coronado CGEvent
by subclassing (or swizzling) NSHostingView
, but that means having to intercept/forward to/restore firstResponder
. That’s too much messing with Cocoa innards to be a clean solution. –
Coronado in the meanwhile (Xcode 15, iOS 17, macOS 14) we have some support for detecting key press events in SwiftUI, that we can use, when developing for macOS. There is a new view modifier .onKeyPress, that helps in such cases.
Here is an example code, how to implement:
TextField("new name of the player", text: $newName)
.focused($isTextFieldFocussed)
.autocorrectionDisabled()
.onSubmit { confirmRenaming() } // <RETURN> key pressed
.onKeyPress(.escape) { // <ESCAPE> key tracking
cancelRenaming()
return .handled
}
The return options are .handeled OR .ignored and tells the system, that the key input is already fully handled or not, which means the system will still take care for the key input (and do whatever with it).
Try to clean and rebuild because the canvas view sometimes does not really work with the key events.
.onKeyPress(.escape)
on a TextField
doesn't work for me on macOS 14.5 (Sonoma). It never enters the block. –
Almatadema I'm building a complex macOS(13|14) app and the 'default' .keyboardShortcut() was not triggering my button action as explained in the headers.
I wanted to display a custom view in a .sheet modifier and there I would dismiss the sheet by hitting the .esc key or hitting .return key.
.keyboardShortcut() was not working consistently and often would beep. The beep in Cocoa is indicative of system events not properly handled through the responder chain. Well yes there is still NSResponder under the hood on SwiftUI.
The following code allows you to get key downs into your view. Since this solved my issue I wanted to post it here as another simpler mechanism for handling any key down in any view. This is obviously macOS code and relatively brief.
At the heart I'm using 2 old Cocoa APIs NSEvent.addLocalMonitorForEvents and NSEvent.removeMonitor
MyView, will now be able to distinguish between a .mouseClick or .return and call whatever logic.
struct Demo {
// Convenience
// https://gist.github.com/rdev/627a254417687a90c493528639465943
//
extension UInt16 {
// Layout-independent Keys
// eg.These key codes are always the same key on all layouts.
static let returnKey: UInt16 = 0x24
static let enter: UInt16 = 0x4C
static let space: UInt16 = 0x31
}
struct MyView: View {
@State var monitorID: Any?
func handleAction(_ type: String) {
// do your work here
}
var body: some View {
VStack {
Button(action: {
handleAction(".mouseClick")
}) {
Text("Hit return")
}
}
.onAppear(perform: {
self.monitorID = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
if event.keyCode == .returnKey || event.keyCode == .enter {
handleAction(".return")
return nil
} else if event.keyCode == .space {
handleAction(".space")
return nil
}
// allow others to handle this event
return event
}
})
.onDisappear(perform: {
if let id = self.monitorID {
// clean up or else we are called with older captured values
NSEvent.removeMonitor(id)
}
})
}
}
}
© 2022 - 2024 — McMap. All rights reserved.