如何在macOS上使用SwiftUI检测键盘事件?

45

我如何在macOS上的SwiftUI视图中检测键盘事件?

我想要使用按键来控制特定屏幕上的元素,但是不清楚如何检测键盘事件,通常可以通过重写NSView中的keyDown(_ event: NSEvent)来完成。

3个回答

42
在 Xcode 12 中的 SwiftUI 新功能中,新增了commands修饰符,该修饰符允许我们使用keyboardShortcut 视图修饰符声明关键输入。接下来需要一些方式将按键输入转发给子视图。以下是使用 Subject 的解决方案,但由于它不是引用类型,因此无法使用 environmentObject 进行传递 - 这确实是我们想要做的,因此我创建了一个小包装器,符合ObservableObject并且为了方便起见,还包含 Subject 本身(通过 subject 转发)。
使用一些额外的便捷方法,可以这样编写:
.commands {
    CommandMenu("Input") {
        keyInput(.leftArrow)
        keyInput(.rightArrow)
        keyInput(.upArrow)
        keyInput(.downArrow)
        keyInput(.space)
    }
}

并将关键输入转发到所有子视图,可以像这样实现:
.environmentObject(keyInputSubject)

然后一个子视图 GameView 可以通过 onReceive 监听事件,如下所示:

struct GameView: View {
    
    @EnvironmentObject private var keyInputSubjectWrapper: KeyInputSubjectWrapper
    @StateObject var game: Game
        
    var body: some View {
        HStack {
            board
            info
        }.onReceive(keyInputSubjectWrapper) {
            game.keyInput($0)
        }
    }
}
< p > 在 < code > CommandMenu 构建器中声明键的方法只有这个 < code > keyInput :

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


1
没有以下两行代码,代码是不完整的:import SwiftUI import Combine - Tom Grushka
2
谢谢!我在添加像keyInput("a")这样的字符支持时遇到了麻烦。通过稍微修改您的代码,我成功解决了这个问题。如果你将lowerCaseName的默认值从return nil更改为return String(self.character).lowercased(),那么你就可以使用字符了。 - Matt54
1
@Sajjon 添加到命令中的.tab.escape似乎无法正常工作。 - Joannes
2
如果释放了一个键,怎么办? - Shaun Budhram
1
这是优秀的代码,但我想提醒读者,这是将给定菜单点击(例如:输入->左箭头)发布到任何感兴趣的子视图中的环境的代码。 - Klajd Deda

28

截至目前,SwiftUI中没有原生的API支持该功能。

以下仅是一个可能的解决方案演示。已在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())
    }
}

输出:

演示


谢谢 - 不幸的是,这会锁住,因为我需要创建多个形状并附加到形状。只有当形状被选择(长按)时,才应该启用键盘事件处理。怎样实现最好呢? - Duncan Groenewald
请忽略此内容,我刚意识到我需要在主布局窗口上执行此操作,并向所选对象发送命令,因为可能会选择多个对象。但是,焦点处理仍然存在问题 - 如何检测窗口何时失去焦点? - Duncan Groenewald
4
代码适用于 Xcode 11.6 和 macOS 10.15.6。但是,每次检测到按键操作时都会发出错误声音。 - XY Li
该解决方案可以工作,但它只能检测到字母和箭头键,像 fn、control、option、command 等键的修改键无法使用,而且无论按下哪个键都会发出错误声音。 - Neung Chung
5
关于错误声音:如果你删除super.keyDown(with: event)(这会告诉剩余的响应链该按键动作未被处理),那么错误声音就不再发出。 - Milos
对于您未处理的任何按键,仍应调用super.keyDown(with: event) - stef

8

还有一种非常简单的解决方案,但只适用于特定类型的按键-您需要进行实验。只需创建带有 .keyboardShortcut 修改器的 Buttons,但在视觉上隐藏它们。

Group {
    Button(action: { goAway() }) {}
        .keyboardShortcut(.escape, modifiers: [])
    Button(action: { goLeft() }) {}
        .keyboardShortcut(.upArrow, modifiers: [])
    Button(action: { goDown() }) {}
        .keyboardShortcut(.downArrow, modifiers: [])
}.opacity(0)

不错。比“更受尊重”的答案要干净得多。 - Chris Birch
这个不起作用。我将这段完全相同的代码复制到了一个ZStack的最顶层,按键甚至都不能触发动作。 - lazarevzubov
在ZStack中运行良好,这是一个更好的解决方案,因为它不会覆盖您应用程序的其余部分,也不会将您不想要的内容放入主菜单栏。 - codeisforeva

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接