如何检测按键按下和释放?

Cam*_*ong 9 keyboard macos keypress swiftui

除了标题之外没什么可说的。我希望能够在按下按键和松开按键时(在 macOS 上)在 SwiftUI 视图中执行操作。在 SwiftUI 中是否有任何好的方法可以做到这一点,如果没有,是否有任何解决方法?

Chi*_*red 21

不幸的是,键盘事件处理是其中一个明显痛苦的领域,SwiftUI 首先是为 iOS 设计的,而 macOS 是事后才想到的。

如果您尝试检测的键是鼠标单击的修饰符,例如cmdoptionshift,则可以使用.modifierswithonTapGesture将其与未修改的 区分开来onTapGesture。在这种情况下,我的经验是,您希望.onTapGestureuse 的调用.modifiers位于未修改的调用之前。

处理任意视图的一般按键事件需要离开 SwiftUI。

如果您只需要一个视图,一种可能性是实现该视图,AppKit以便您可以通过普通的 Cocoa 机制接收键盘事件firstResponder,然后将该视图包装在 SwiftUI 的NSViewRepresentable. 在这种情况下,您的包装将NSView更新. 许多在 macOS 上使用 SwiftUI 的开发人员都是这样做的。虽然这对于少量视图来说没什么问题,但如果事实证明您必须在 AppKit 中实现大量视图才能使它们在 SwiftUI 中可用,那么您无论如何都失去了使用 SwiftUI 的意义。在这种情况下,只需将其设为普通的 Cocoa 应用程序即可。@StateNSViewRespresentable

但还有另一种方法...

您可以使用另一个线程,该线程CGEventSource与 SwiftUI 结合主动轮询键盘状态@EnvironmentObject,或者@StateObject将键盘状态更改传达给View对其感兴趣的 SwiftUI。

假设您想检测何时按下向上箭头。为了检测密钥,我使用了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)
    }
}
Run Code Online (Sandbox Code Playgroud)

当然,您必须使用正确的密钥代码。我有一个包含所有旧密钥代码的要点。如果您愿意,可以将它们重命名为更 Swifty。列出的名称可追溯到经典 MacOS,并在Inside Macintosh中定义。

定义该扩展后,您可以随时测试是否按下了某个键:

if CGKeyCode.kVK_UpArrow.isPressed { 
    // Do something in response to the key press.
}
Run Code Online (Sandbox Code Playgroud)

请注意,这些不是按键事件或按键事件。它只是一个布尔值,用于检测执行检查时是否按下了该键。为了使行为更像事件,您需要通过跟踪关键状态更改来自己完成该部分。

有多种方法可以做到这一点,以下代码并不意味着暗示这是“最佳”方法。这只是一种方式。无论如何,当应用程序启动时,无论您在哪里进行全局初始化,类似以下代码的内容都会被调用(或被调用)。

// 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))
    }
}
Run Code Online (Sandbox Code Playgroud)

这个想法只是让一些代码定期轮询关键状态,将它们与以前的状态进行比较,在它们发生变化时调度适当的“事件”,并更新以前的状态。上面的代码通过在并发任务中运行无限循环来实现这一点。它需要创建一个DispatchQueue具有该.concurrent属性的。您不能使用它,DispatchQueue.main因为该队列是串行的而不是并发的,因此无限循环会阻塞主线程,并且程序将变得无响应。如果您已经有一个出于其他原因而使用的并发DispatchQueue,您可以只使用该并发,而不必创建一个仅用于轮询的并发。

但是,任何实现定期轮询基本目标的代码都可以,因此,如果您还没有并发DispatchQueue并且不希望创建一个只是为了轮询键盘状态(这将是一个合理的反对意见),这里有一个替代版本使用DispatchQueue.main称为“异步链接”的技术来避免阻塞/睡眠:

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()
}

Run Code Online (Sandbox Code Playgroud)

有了代码来检测何时按下按键,您现在需要一种方法将其传达给您的 SwiftUI View。再说一遍,剥猫皮的方法不止一种。这是一个过于简单的,View每当按下向上箭头时就会更新 a ,但您可能想要实现一些更复杂的东西......可能允许视图指定它们有兴趣响应哪些键。

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)
    }
}
Run Code Online (Sandbox Code Playgroud)

我已经在这个要点中放置了一个完整的、可编译的、有效的示例,该示例以您在评论中链接到的代码为模式。它对上面的代码进行了稍微重构,但所有部分都在那里,包括启动轮询代码。

我希望这能为您指明一个有用的方向。