WWDC23 deal: 40% off our book "Integrating SwiftUI into UIKit apps"! Learn more ...WWDC23 deal:40% off "Integrating SwiftUI into UIKit apps" >>

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.

Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover
WWDC23 offer
Our book "Integrating SwiftUI into UIKit apps" is now 40% off!

Discover various ways to adopt SwiftUI in existing UIKit projects and take full advantage of the new iOS 16 frameworks and APIs such as Swift Charts.

The offer is active until the 19th of June.