如何在 swiftUI (macOS) 中检测按键按下和释放

How to detect key press and release in swiftUI (macOS)

除了标题,没什么好说的。我希望能够在按下和释放键时(在 macOS 上)在 swiftUI 视图中执行操作。在 swiftUI 中有什么好的方法可以做到这一点,如果没有,有什么解决方法吗?

不幸的是,键盘事件处理是其中一个令人痛苦的领域,SwiftUI 最初是为 iOS 设计的,而 macOS 是事后才想到的。

如果您尝试检测的键是鼠标点击的修饰键,例如 cmdoptionshift,您可以使用 .modifiersonTapGesture 以区别于未修改的 onTapGesture。在那种情况下,我的经验是您希望使用 .modifiers.onTapGesture 调用 先于 未修改的调用。

处理任意视图的一般按键事件需要在 SwiftUI 之外。

如果你只需要它用于一个视图,一种可能性是使用 AppKit 实现该视图,这样你就可以通过普通的 Cocoa firstResponder 机制接收键盘事件,并且然后将该视图包装在 SwiftUI 的 NSViewRepresentable 中。在这种情况下,您的包装 NSView 将在 NSViewRespresentable 中更新一些 @State 属性。许多使用 SwiftUI for macOS 的开发者都是这样做的。虽然这对于少数视图来说很好,但如果事实证明您必须在 AppKit 中实现大量视图才能使它们在 SwiftUI 中可用,那么无论如何您都在某种程度上破坏了使用 SwiftUI 的意义。在这种情况下,只需将其设为普通 Cocoa 应用即可。

但还有另一种方式...

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

假设您想检测向上箭头何时被按下。为了检测密钥,我在 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)
    }
}

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

中定义

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

if CGKeyCode.kVK_UpArrow.isPressed { 
    // Do something in response to the key press.
}

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

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

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

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

但是,任何实现定期轮询基本目标的代码都可以,所以如果您还没有并发 [​​=34=] 并且不希望创建一个只是为了轮询键盘状态,这将是一个合理的反对意见,这里有一个替代版本,它使用 DispatchQueue.main 和一种称为“异步链接”的技术来避免 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()
}

有了用于检测何时按下按键的代码,您现在需要一种方法将其与您的 SwiftUI View 进行通信。同样,给猫剥皮的方法不止一种。这是一个过于简单的方法,只要按下向上箭头,它就会更新 View,但您可能想要实现一些更复杂的东西……可能是允许视图指定它们是什么键的东西有兴趣回应。

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

我已经在此 gist 中放置了一个完整的、可编译的、有效的示例,该示例基于您在评论中链接到的代码。它是从上面的代码稍微重构的,但是所有的部分都在那里,包括启动轮询代码。

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