【问题标题】:How to detect key press and release in swiftUI (macOS) [closed]如何在swiftUI(macOS)中检测按键和释放[关闭]
【发布时间】:2021-09-13 05:10:16
【问题描述】:

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

【问题讨论】:

    标签: swift macos swiftui keyboard keypress


    【解决方案1】:

    不幸的是,键盘事件处理是其中一个非常明显的领域,即 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 Views。

    假设您想检测何时按下向上箭头。为了检测密钥,我在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,并在Macintosh 内部中定义。

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

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

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

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

    // 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 上使用它,因为该队列 serial 不是并发的,因此无限循环会阻塞主线程,并且程序会变得无响应。如果您已经有一个并发 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()
    }
    
    

    有了检测按键何时被按下的代码,您现在需要一种方法将其传达给您的 SwiftUI Views。同样,给猫剥皮的方法不止一种。这是一个过于简单的方法,只要按下向上箭头就会更新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 上放置了一个完整的、可编译的、可工作的示例,该示例以您在 cmets 中链接到的代码为模式。上面的代码稍微重构了一下,但是所有的部分都在那里,包括启动轮询代码。

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

    【讨论】:

    • 这非常有用!非常感谢您的详细回复!我真的希望苹果对 macOS 产生更多的兴趣,并在 swiftUI 中添加一种方法来实现这一点,但目前这是一个很好的解决方案。谢谢!
    • 我正在努力实现这一点,我有一个问题。我以前从未使用过并发,我不确定我应该在someConcurrentQueue.async { 上做什么。 someConcurrentQueue 可以是我想要的任何东西,比如keyboardManagerQueue?对不起,如果这是一个愚蠢的问题。谢谢!
    • someConcurrentQueue 只是使用 .concurrent 属性 (let someConcurrentQueue = DispatchQueue(label: "queueNameGoesHere", attributes: .concurrent)) 创建的 DispatchQueue 的替代。只要不是串行队列,您就可以使用现有队列,这意味着您不能在我的代码示例中使用DispatchQueue.main
    • 顺便说一句 - 您可以通过多种方式使用 DispatchQueue.main。您可以使用“异步链接”,而不是 async 闭包中的有限循环。为此,将闭包中的代码放入一个命名函数中,然后在函数中使用DispatchQueue.main.async 来调用它,而不是无限循环。在这种情况下,你不想睡觉,因为那会阻塞主线程。正如我所说,有多种方法可以做到这一点。
    • @CameronDelong,我更新了我的答案,包括创建并发队列,以及可与DispatchQueue.main 一起使用的替代实现。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-09-20
    • 2018-01-17
    • 1970-01-01
    • 2017-03-22
    • 1970-01-01
    相关资源
    最近更新 更多