Black Friday 2024 deal: 30% off our Swift and SwiftUI books! Learn more ...Black Friday 2024 deal:30% off our Swift and SwiftUI books >>

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.

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Black Friday 2024 offer: 30% off!$35$25

100+ tips to take your Swift code to the next level

Swift Gemsby Natalia Panferova

  • Advanced Swift techniques for experienced developers bypassing basic tutorials
  • Curated, actionable tips ready for immediate integration into any Swift project
  • Strategies to improve code quality, structure, and performance across all platforms
Black Friday 2024 offer: 30% off!

100+ tips to take your Swift code to the next level

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Swift Gems

by Natalia Panferova

$35$25