Reading keyboard modifiers on iPad in SwiftUI
With the advent of desktop class iPadOS applications there are many macOS UX idioms I would like to bring to our iPad apps. One such UX pattern is hybrid mouse + keyboard interactions, such as command + click for adding or removing items from selection and shift + click for extending the selection. The standard List component does provide these modifier driven selection actions by default if we use the init(selection:content:) constructor. Unfortunately, there is no equivalent for grid or other custom layouts. So I needed to find a way to detect which keys are being pressed as the user takes an action.
In SwiftUI on macOS we have the ability to add modifiers(_:) to Gestures but the modifiers(_:)
API is not available on iPad. For iPadOS I have found that the Game Controller framework offers a solution.
# Get a reference to the keyboard
To check if shift
or command
are pressed I must first find the current attached keyboard, if any. GCKeyboard offers a static property coalesced, but it can sometimes return nil
when read within a button or gesture block, even when a keyboard is attached.
The GCKeyboardDidConnect notification provides another way to get the current keyboard. The notification will fire imminently on subscription if a keyboard is attached and the object
of the notification is the attached GCKeyboard
.
To ensure my entire app can read the keyboard's state I have created a top level ObservableObject
that acts as the GCKeyboardDidConnect
notification subscriber.
import GameController
class KeyboardObserver: ObservableObject {
@Published var keyboard: GCKeyboard?
var observer: Any? = nil
init() {
observer = NotificationCenter.default.addObserver(
forName: .GCKeyboardDidConnect,
object: nil,
queue: .main
) { [weak self] notification in
self?.keyboard = notification.object as? GCKeyboard
}
}
}
As there can only be one active keyboard, it makes sense for the observer to be an application wide StateObject
providing the same keyboard to all scenes. I pass it down the hierarchy as an environment object, so that all the views in my app have access to the keyboard.
@main
struct MyApp: App {
@StateObject var keyboardObserver = KeyboardObserver()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(keyboardObserver)
}
}
}
# Modify button behaviour
In my views I can now alter the behaviour of buttons and gestures by reading the current state of the GCKeyboardInput.
struct ContentView: View {
@EnvironmentObject var keyboardObserver: KeyboardObserver
var body: some View {
Button("Tap Me") {
guard let keyboardInput = keyboardObserver
.keyboard?.keyboardInput
else {
// default action, no keyboard found
return
}
let commandIsPressed = keyboardInput.button(
forKeyCode: .leftGUI
)?.isPressed ?? false || keyboardInput.button(
forKeyCode: .rightGUI
)?.isPressed ?? false
let shiftIsPressed = keyboardInput.button(
forKeyCode: .leftShift
)?.isPressed ?? false || keyboardInput.button(
forKeyCode: .rightShift
)?.isPressed ?? false
if commandIsPressed {
// action with CMD pressed
} else if shiftIsPressed {
// action with SHIFT pressed
} else {
// default action
}
}
}
}
When checking for pressed keys I found that it's important to check both the left and right versions of each GCKeyCode. GCKeyboardInput
also offers the ability to register a keyChangedHandler that I have not used yet. I expect to register the keyChangedHandler
so that I can expose additional advanced UI when the option
key is pressed, just like macOS.