Reputation: 8988
How can I detect keyboard events in a SwiftUI view on macOS?
I want to be able to use key strokes to control items on a particular screen but it's not clear how I detect keyboard events, which is usually done by overriding the keyDown(_ event: NSEvent)
in NSView
.
Upvotes: 48
Views: 23566
Reputation: 490
You can add this to any Vstack
, Button
etc
// adding focusable() enables onKeyPress to work
.focusable()
// optionally remove blue outline from focus on TextFields etc
.focusEffectDisabled()
// here I am detecting the escape, space, return keys
.onKeyPress(keys: [.escape, .space, .return]) { press in
// perform your logic here
// .handled signifies that key press has been successfully handled
return .handled
}
```
Upvotes: 2
Reputation: 381
Starting in macOS 14.0+, we now have onKeyPress(_:). Huzzah!
An example, with the custom popover I'm implementing:
import SwiftUI
struct AlbumModal: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Text("Hello, World!")
Text("Press `escape` to dismiss.")
}
.onKeyPress(.escape) {
dismiss()
return .handled
}
}
}
Upvotes: 3
Reputation: 2070
There's another solution that is very simple but only works for particular types of keys - you'd have to experiment. Just create Buttons
with .keyboardShortcut
modifier, but hide them visually.
Group {
Button(action: { goAway() }) {}
.keyboardShortcut(.escape, modifiers: [])
Button(action: { goLeft() }) {}
.keyboardShortcut(.upArrow, modifiers: [])
Button(action: { goDown() }) {}
.keyboardShortcut(.downArrow, modifiers: [])
}.opacity(0)
Upvotes: 13
Reputation: 9887
New in SwiftUI bundled with Xcode 12 is the commands
modifier, which allows us to declare key input with keyboardShortcut
view modifier. You then need some way of forwarding the key inputs to your child views. Below is a solution using a Subject
, but since it is not a reference type it cannot be passed using environmentObject
- which is really what we wanna do, so I've made a small wrapper, conforming to ObservableObject
and for conveninece Subject
itself (forwarding via the subject
).
Using some additional convenience sugar methods, I can just write like this:
.commands {
CommandMenu("Input") {
keyInput(.leftArrow)
keyInput(.rightArrow)
keyInput(.upArrow)
keyInput(.downArrow)
keyInput(.space)
}
}
And forward key inputs to all subviews like this:
.environmentObject(keyInputSubject)
And then a child view, here GameView
can listen to the events with onReceive
, like so:
struct GameView: View {
@EnvironmentObject private var keyInputSubjectWrapper: KeyInputSubjectWrapper
@StateObject var game: Game
var body: some View {
HStack {
board
info
}.onReceive(keyInputSubjectWrapper) {
game.keyInput($0)
}
}
}
The keyInput
method used to declare the keys inside CommandMenu
builder is just this:
private extension ItsRainingPolygonsApp {
func keyInput(_ key: KeyEquivalent, modifiers: EventModifiers = .none) -> some View {
keyboardShortcut(key, sender: keyInputSubject, modifiers: modifiers)
}
}
extension KeyEquivalent: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.character == rhs.character
}
}
public typealias KeyInputSubject = PassthroughSubject<KeyEquivalent, Never>
public final class KeyInputSubjectWrapper: ObservableObject, Subject {
public func send(_ value: Output) {
objectWillChange.send(value)
}
public func send(completion: Subscribers.Completion<Failure>) {
objectWillChange.send(completion: completion)
}
public func send(subscription: Subscription) {
objectWillChange.send(subscription: subscription)
}
public typealias ObjectWillChangePublisher = KeyInputSubject
public let objectWillChange: ObjectWillChangePublisher
public init(subject: ObjectWillChangePublisher = .init()) {
objectWillChange = subject
}
}
// MARK: Publisher Conformance
public extension KeyInputSubjectWrapper {
typealias Output = KeyInputSubject.Output
typealias Failure = KeyInputSubject.Failure
func receive<S>(subscriber: S) where S : Subscriber, S.Failure == Failure, S.Input == Output {
objectWillChange.receive(subscriber: subscriber)
}
}
@main
struct ItsRainingPolygonsApp: App {
private let keyInputSubject = KeyInputSubjectWrapper()
var body: some Scene {
WindowGroup {
#if os(macOS)
ContentView()
.frame(idealWidth: .infinity, idealHeight: .infinity)
.onReceive(keyInputSubject) {
print("Key pressed: \($0)")
}
.environmentObject(keyInputSubject)
#else
ContentView()
#endif
}
.commands {
CommandMenu("Input") {
keyInput(.leftArrow)
keyInput(.rightArrow)
keyInput(.upArrow)
keyInput(.downArrow)
keyInput(.space)
}
}
}
}
private extension ItsRainingPolygonsApp {
func keyInput(_ key: KeyEquivalent, modifiers: EventModifiers = .none) -> some View {
keyboardShortcut(key, sender: keyInputSubject, modifiers: modifiers)
}
}
public func keyboardShortcut<Sender, Label>(
_ key: KeyEquivalent,
sender: Sender,
modifiers: EventModifiers = .none,
@ViewBuilder label: () -> Label
) -> some View where Label: View, Sender: Subject, Sender.Output == KeyEquivalent {
Button(action: { sender.send(key) }, label: label)
.keyboardShortcut(key, modifiers: modifiers)
}
public func keyboardShortcut<Sender>(
_ key: KeyEquivalent,
sender: Sender,
modifiers: EventModifiers = .none
) -> some View where Sender: Subject, Sender.Output == KeyEquivalent {
guard let nameFromKey = key.name else {
return AnyView(EmptyView())
}
return AnyView(keyboardShortcut(key, sender: sender, modifiers: modifiers) {
Text("\(nameFromKey)")
})
}
extension KeyEquivalent {
var lowerCaseName: String? {
switch self {
case .space: return "space"
case .clear: return "clear"
case .delete: return "delete"
case .deleteForward: return "delete forward"
case .downArrow: return "down arrow"
case .end: return "end"
case .escape: return "escape"
case .home: return "home"
case .leftArrow: return "left arrow"
case .pageDown: return "page down"
case .pageUp: return "page up"
case .return: return "return"
case .rightArrow: return "right arrow"
case .space: return "space"
case .tab: return "tab"
case .upArrow: return "up arrow"
default: return nil
}
}
var name: String? {
lowerCaseName?.capitalizingFirstLetter()
}
}
public extension EventModifiers {
static let none = Self()
}
extension String {
func capitalizingFirstLetter() -> String {
return prefix(1).uppercased() + self.lowercased().dropFirst()
}
mutating func capitalizeFirstLetter() {
self = self.capitalizingFirstLetter()
}
}
extension KeyEquivalent: CustomStringConvertible {
public var description: String {
name ?? "\(character)"
}
}
Upvotes: 44
Reputation: 257533
There is no built-in native SwiftUI API for this, so far.
Here is just a demo of a possible approach. Tested with Xcode 11.4 / macOS 10.15.4
struct KeyEventHandling: NSViewRepresentable {
class KeyView: NSView {
override var acceptsFirstResponder: Bool { true }
override func keyDown(with event: NSEvent) {
print(">> key \(event.charactersIgnoringModifiers ?? "")")
}
}
func makeNSView(context: Context) -> NSView {
let view = KeyView()
DispatchQueue.main.async { // wait till next event cycle
view.window?.makeFirstResponder(view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
}
}
struct TestKeyboardEventHandling: View {
var body: some View {
Text("Hello, World!")
.background(KeyEventHandling())
}
}
Output:
Upvotes: 28