iOS Keyboard Shortcuts in SwiftUI


With the iPad getting more and more powerful and with the introduction of the new iPad keyboards with trackpad, it is really important to make sure that our apps work nicely with a hardware keyboard attached. In order to provide a smooth user experience, we should consider adding keyboard shortcuts for actions and navigation in our apps.


In this article we will look into how to add keyboard shortcuts to an iOS app written in SwiftUI.


At the moment there is no equivalent of UIKeyCommand in SwiftUI and no way to add key commands directly to SwiftUI views. After some research we found that the best way for us to add the shortcuts was to subclass the UIHostingController and override its keyCommands property.


We will look at an example app that shows a list of the most populous New Zealand cities. The user can add and remove cities from the list and select a city to view the description. The full code for this project can be found on GitHub. Here we will go through the part related to the keyboard shortcuts.


Activity Monitor screenshot



Subclass UIHostingController


We start by creating a subclass of UIHostingController that will be used as the root controller. It will accept the class responsible for data management in our app CityProvider and our SwiftUI root view.

class RootViewController<Content: View>: UIHostingController<Content> {

    let cityProvider: CityProvider

    init(rootView: Content, cityProvider: CityProvider) {
        self.cityProvider = cityProvider
        super.init(rootView: rootView)
    }
}


In SceneDelegate.swift we initialize our RootViewController passing it the SwiftUI ContentView as the rootView. Then, assign the it to the rootViewController property of the SceneDelegate window.


class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(
        _ scene: UIScene, willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        let cityProvider = CityProvider()
        let contentView = ContentView(cityProvider: cityProvider)

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let rootViewController = RootViewController(rootView: contentView, cityProvider: cityProvider)
            window.rootViewController = rootViewController
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}



Define Keyboard Shortcuts


Now we need to define the keyboard shortcuts that out app supports. We create a list of UIKeyCommands and return it from the RootViewController keyCommands computed property.


Our app will have a keyboard shortcut to add a new city. We have a limited number of cities that the user can add to the list, so the shortcut should only be available when not all of the cities have been added yet. We can define the logic for adding a particular shortcut inside the keyCommands and return a different list of commands depending on the app state.


class RootViewController<Content: View>: UIHostingController<Content> {

    let cityProvider: CityProvider
    
    override var keyCommands: [UIKeyCommand]? {
        
        var result: [UIKeyCommand] = []
        
        if cityProvider.addedCities.count < cityProvider.cities.count {
            result.append(
                UIKeyCommand(
                    action: #selector(addCity),
                    input: "N",
                    modifierFlags: [.shift, .command],
                    discoverabilityTitle: "New City"
                )
            )
        }
        
        return result
    }
}

extension UIKeyCommand {
    convenience init(
        action: Selector, input: String,
        modifierFlags: UIKeyModifierFlags,
        discoverabilityTitle: String
    ) {
        self.init(
            title: "", image: nil, saction: action,
            input: input, modifierFlags: modifierFlags,
            propertyList: nil, alternates: [],
            discoverabilityTitle: discoverabilityTitle,
            attributes: [], state: .on
        )
    }
}


We wrote a convenience initializer for UIKeyCommand because in our example we don't need all of the properties that the designated initializer accepts.


To discover what keyboard shortcuts are available the user can press and hold the command ⌘ key. The app will show a popup listing the available commands taking into account the current state of the app and the logic we defined inside the keyCommands property. The discoverabilityTitle that we pass into the UIKeyCommand initializer will be used in that popup to explain what the command does.



Perform Keyboard Shortcut Actions


The action to perform when the command is triggered has to be @objc method. We will define it inside the RootViewController and then pass it on to our data management layer (which for this app is the CityProvider).


@objc func addCity() {
    cityProvider.addCity()
}


Our SwiftUI ContentView then will observe the CityProvider and layout the views accordingly.


struct ContentView: View {
    
    @ObservedObject var cityProvider: CityProvider
    
    var body: some View {
        NavigationView {
            List {
                ForEach(cityProvider.addedCities) { city in
                    ...
                }
            }
        }
    }
}



Add Keyboard Shortcuts for Navigation and Deletion


Using the same pattern we can add other keyboard shortcuts, for example, for navigation or to delete a city from the list.


override var keyCommands: [UIKeyCommand]? {
    
    var result: [UIKeyCommand] = []
    
    if cityProvider.addedCities.count > 1,
        let selectedIndex = cityProvider.selectedIndex {
        
        if selectedIndex > 0 {
            result.append(
                UIKeyCommand(
                    action: #selector(selectPreviousCity),
                    input: UIKeyCommand.inputUpArrow,
                    modifierFlags: [.shift, .command],
                    discoverabilityTitle: "Select Previous City"
                )
            )
        }
        
        if selectedIndex < cityProvider.addedCities.count - 1 {
            result.append(
                UIKeyCommand(
                    action: #selector(selectNextCity),
                    input: UIKeyCommand.inputDownArrow,
                    modifierFlags: [.shift, .command],
                    discoverabilityTitle: "Select Next City"
                )
            )
        }
    }
    
    if cityProvider.selectedCity != nil {
        result.append(
            UIKeyCommand(
                action: #selector(deleteCity),
                input: "d", modifierFlags: [.command],
                discoverabilityTitle: "Delete City"
            )
        )
    }
    
    return result
}


If you support navigation with keyboard shortcuts in your app, you will need to implement programmatic navigation. You can learn how to do it in SwiftUI List from our article on Programmatic Navigation.


To scroll your List to the selected item, you can implement our solution described in Scroll List to Row in SwiftUI article.


Feel free to get the code for this project from GitHub and to play around with it. Try adding and deleting cities and navigating between them and check what shortcuts are available at what point by pressing and holding the command ⌘ key on the keyboard. For example, you can only see the navigation commands Select Previous City and Select Next City when there is more than one city in the list.


If you try it on the iPad simulator, the keyboard shortcuts might not be very reactive, but they work well when using a physical device with an attached hardware keyboard.