DEV Community

Cover image for Some thoughts on keyboard shortcuts on Android
Thomas Künneth
Thomas Künneth

Posted on • Edited on

Some thoughts on keyboard shortcuts on Android

When you think about which cool new feature you may want to add to your app, you probably won't immediately shout Hell yes, my app desperately needs keyboard shortcut support. I mean, how often do we use physical keyboards with our mobile devices anyway? The days of Android phones having a keyboard built in are long gone, aren't they?

Well, not really. Chromebooks run Android apps. Many Android tablets are sold with physical keyboards. And Android's desktop mode, which hopefully will gain some traction eventually, allows us to connect our smartphone to big screens. This setup only makes sense when you also pair a mouse and keyboard. So, keyboards most certainly are not a thing of the past. They may not have been very common on Android in recent years, but other platforms have always relied on them. And still do. Why? Because physical keyboards are productivity boosters.

That's where keyboard shortcuts come in. They allow us to trigger an action by simultaneously pressing a few keys. Prime examples are the clipboard-related commands Cut (Control-X), Copy (Control-C), and Paste (Control-V). And yes, it's Command on the Mac.

Have you noticed that I said command? Keyboard shortcuts allow us to trigger an action or command fast. That's why they are called shortcuts. The important point here is: there should always be another way of executing that command. Typically, this other way also advertises the corresponding keyboard shortcut. Take a look:

The macOS menu bar with the Edit menu being opened

When we open a menu, we see the command and its associated shortcut, which helps us remember it (eventually). Actually using the shortcut helps us remember it even better, but that's another topic.

But Android doesn't have a menu bar

Traditional menu bars work best with a mouse and a mouse pointer. That's why Android does not have one. However, Android most certainly allows apps to show menus. And these can contain keyboard shortcuts, too. We'll tackle this shortly. But first, let me show you how to define and consume keyboard shortcuts on an app level. The source code of my sample app KeyboardShortcutDemo is available on GitHub. It is a Compose Multiplatform project that targets Android, IOS, and the Desktop. In this article, I focus on Android.

Global shortcuts

Working with global keyboard shortcuts consists of two steps:

  • Defining the shortcuts
  • Receiving shortcut activations

Both can be implemented on an Activity level. Here's how to define a keyboard shortcut:

private lateinit var listKeyboardShortcutInfo: List<KeyboardShortcutInfo>

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    listKeyboardShortcutInfo = listOf(
        KeyboardShortcutInfo(
            getString(R.string.say_hello),
            KeyEvent.KEYCODE_H,
            KeyEvent.META_CTRL_ON
        )
    )
}

override fun onProvideKeyboardShortcuts(
    data: MutableList<KeyboardShortcutGroup>, menu: Menu?, deviceId: Int
) {
    super.onProvideKeyboardShortcuts(data, menu, deviceId)
    data.add(KeyboardShortcutGroup(
                getString(R.string.general),
                listKeyboardShortcutInfo
            ))
}
Enter fullscreen mode Exit fullscreen mode

In onCreate(), we define a list of KeyboardShortcutInfo instances with one item, and, inside onProvideKeyboardShortcuts(), add it to data, which has been passed to us by Android. Please notice the Menu which will usually be null in a Compose-only app.

Next, let's look how to receive keyboard shortcut presses.

override fun onKeyShortcut(
    keyCode: Int, event: KeyEvent
): Boolean {
    val keyboardShortcutInfo = listKeyboardShortcutInfo.find {
        it.keycode == keyCode && event.hasModifiers(it.modifiers)
    }
    val shortcut = mapKeyboardShortcuts[keyboardShortcutInfo]
    if (shortcut != null) {
        shortcut.triggerAction()
        return true
    }
    return super.onKeyShortcut(keyCode, event)
}
Enter fullscreen mode Exit fullscreen mode

The onKeyShortcut() function receives a keyCode and an event. With both it is simple to check if a shortcut defined by our app has been pressed. If this is the case, we return true, otherwise super.onKeyShortcut(keyCode, event).

But what does shortcut.triggerAction() do? And what is mapKeyboardShortcuts? Here's how it is implemented:

data class KeyboardShortcut(
    val label: String,
    val shortcutAsText: String
) {
    private val channel = Channel<Unit>(Channel.CONFLATED)
    val flow = channel.receiveAsFlow()

    fun triggerAction() {
        channel.trySend(Unit)
    }
}
Enter fullscreen mode Exit fullscreen mode

Since KeyboardShortcutInfo is specific to Android, it makes sense to provide an alternative that we can use across platforms. But even on Android we need a modern mechanism that allows us to trigger and consume keyboard shortcuts in a modern Kotlin way. That's what KeyboardShortcut is for. label and shortcutAsText will be used by composables. flow allows us to react upon invocations of the keyboard shortcut. Finally, triggerAction(): as its name implies, it triggers an action by trying to send something to a channel. Kindly recall that we invoke this function inside onKeyShortcut(). Now, what does this mean? Once we have determined that a keyboard shortcut has been invoked by pressing the corresponding keys, we make sure to notify everyone interested in the event. We'll, by the way, also call this function from inside our Compose hierarchy. I'll show you shortly. For now, let's continue.

private lateinit var mapKeyboardShortcuts: Map<KeyboardShortcutInfo,
                                               KeyboardShortcut>

// inside onCreate()
mapKeyboardShortcuts = listKeyboardShortcutInfo.associateWith {
    shortcutInfo -> KeyboardShortcut(
        label = shortcutInfo.label.toString(),
        shortcutAsText = shortcutInfo.getDisplayString(),
    )
}
Enter fullscreen mode Exit fullscreen mode

When we receive a key press in onKeyShortcut(), we first check if it is in our listKeyboardShortcutInfo list. If so, we can easily get our modern KeyboardShortcut instance and work with it.

What I mean by this will become clearer soon. But before that, let's take a user's perspective. Android and Chrome OS can display a list of available shortcuts. On most devices, this help screen can be opened by pressing Meta-/, which is known as Search-/ on Chromebooks.

The keyboard shortcuts dialog on Android

Since not all users may be familiar with this system shortcut, consider allowing the user to summon it from within your app. Here's how my sample app does it:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
    
    setContent {
        val listKeyboardShortcuts = remember {
            mapKeyboardShortcuts.values.toList()
        }
        
        MainScreen(
            listKeyboardShortcuts = listKeyboardShortcuts,
            
        ) { requestShowKeyboardShortcuts() }
    }
}
Enter fullscreen mode Exit fullscreen mode

requestShowKeyboardShortcuts() (this method is defined in android.app.Activity) requests the Keyboard Shortcuts screen to show up. This will trigger onProvideKeyboardShortcuts() to retrieve the shortcuts for the foreground activity. KeyboardShortcutDemo just shows a Show keyboard shortcuts button. Certainly, there are more clever places to incorporate it.

Screenshot of the KeyboardShortcutDemo sample

Let's recap. I showed you how to provide keyboard shortcuts on an Activity level. We can even summon a system dialog that lists them. But how do we react to shortcut presses and how do we visualise the shortcut in our user interface? Finally, how do we mention the shortcut to the user so that they can remember it, similar to what I explained regarding classic menu bars?

@Composable
fun MainScreen(
    listKeyboardShortcuts: List<KeyboardShortcut>,
    hardKeyboardHidden: Boolean,
    showKeyboardShortcuts: () -> Unit,
) {
    var snackbarMessage by remember { mutableStateOf("") }
    val helloMessage = stringResource(Res.string.hello)
    LaunchedEffect(listKeyboardShortcuts) {
        listKeyboardShortcuts.forEach { shortcut ->
            launch {
                shortcut.flow.collectLatest {
                    snackbarMessage = helloMessage
                }
            }
        }
    }
    KeyboardShortcutDemo(
        hardwareKeyboardHidden = hardKeyboardHidden,
        snackbarMessage = snackbarMessage,
        shortcuts = listKeyboardShortcuts,
        showKeyboardShortcuts = showKeyboardShortcuts,
        clearSnackbarMessage = { snackbarMessage = "" })
}
Enter fullscreen mode Exit fullscreen mode

When a keyboard shortcut is invoked, the sample app shows a snackbar message. We keep the text in snackbarMessage. listKeyboardShortcuts is the list of keyboard shortcuts. KeyboardShortcutDemo() (this composable is invoked from MainScreen()) uses it to populate a menu. hardKeyboardHidden is used to determine if a physical keyboard is ready to use. Kindly just ignore it, I will detail on this in a follow-up article.

So far, I still haven't explained how we react to shortcut presses. The magic happens inside LaunchedEffect(). For each keyboard shortcut, we invoke shortcut.flow.collectLatest {}. In my example, we always set the snackbar message. Real-world apps would certainly provide different implementations, depending on the shortcut.

Menus and keyboard shortcuts in Jetpack Compose

When looking at the KeyboardShortcutDemo() composable, kindly recall the screenshot of the app to understand its structure:

  • an app bar at the top, including the app name and a menu
  • a button to open the keyboard shortcuts dialog
  • (not visible) a text when no physical keyboard is ready to use
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeyboardShortcutDemo(
    hardwareKeyboardHidden: Boolean,
    shortcuts: List<KeyboardShortcut>,
    snackbarMessage: String,
    showKeyboardShortcuts: () -> Unit,
    clearSnackbarMessage: () -> Unit
) {
    val snackBarHostState = remember { SnackbarHostState() }
    var showMenu by remember { mutableStateOf(false) }
    MaterialTheme {
        Scaffold(
            modifier = Modifier.fillMaxSize(),
            topBar = {
                TopAppBar(
                    title = {
                        Text(stringResource(Res.string.app_name))
                    },
                    actions = {
                        IconButton(
                            onClick = { showMenu = !showMenu }
                        ) {
                            Icon(
                                imageVector = Icons.Filled.MoreVert,
                                contentDescription = stringResource(
                                    Res.string.more_options
                                )
                            )
                        }
                        DropdownMenu(
                            expanded = showMenu,
                            onDismissRequest = { showMenu = false }
                        ) {
                            shortcuts.forEach { shortcut ->
                                DropdownMenuItemWithShortcut(
                                    text = shortcut.label,
                                    shortcut =
                                        shortcut.shortcutAsText,
                                    onClick = {
                                        showMenu = false
                                        shortcut.triggerAction()
                                    }
                                )
                            }
                        }
                    }
                )
            },
            snackbarHost = {
                SnackbarHost(snackBarHostState)
            }) { innerPadding ->
            Box(
                modifier = Modifier
                    .padding(innerPadding)
                    .fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Button(onClick = showKeyboardShortcuts) {
                    Text(stringResource(
                            Res.string.show_keyboard_shortcuts
                        ))
                }
                if (hardwareKeyboardHidden) {
                    Text(
                        text = stringResource(
                            Res.string.hardware_keyboard_hidden
                        ),
                        modifier = Modifier
                            .align(Alignment.BottomCenter)
                            .safeContentPadding(),
                        style =
                            MaterialTheme.typography.headlineSmall,
                        color = MaterialTheme.colorScheme.error
                    )
                }
            }
            LaunchedEffect(snackbarMessage) {
                if (snackbarMessage.isNotBlank()) {
                    snackBarHostState.showSnackbar(snackbarMessage)
                    clearSnackbarMessage()
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The menu will contain as many items as shortcuts has elements. When an element is selected, a snack bar will appear. That's because onClick() of each DropdownMenuItemWithShortcut() invokes shortcut.triggerAction(). The onClick() of the button just invokes the showKeyboardShortcuts lambda.

That's been quite a bit to digest, right? Fortunately, there is only one piece of the puzzle missing. What is DropdownMenuItemWithShortcut()?

Unlike the traditional Activity-level options menu, DropDownMenuItem() (which comes with Jetpack Compose) does not support shortcuts out of the box. This means we need to somehow add this to the Text() composable. The most basic approach is to just add the shortcut at the end of the String. While this works, this does not look particularly pleasing. Besides, that's not how we build UIs with Jetpack Compose.

Here's a composable that provides a DropDownMenuItem() with a shortcut at the end of the text:

@Composable
fun DropdownMenuItemWithShortcut(
    text: String,
    shortcut: String?,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    DropdownMenuItem(
        text = { ShortcutText(text = text, shortcut = shortcut) },
        onClick = onClick,
        modifier = modifier
    )
}

@Composable
fun ShortcutText(
    text: String,
    shortcut: String?,
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            text = text,
            modifier = Modifier.alignByBaseline()
        )
        shortcut?.let { shortcut ->
            Text(
                text = shortcut,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurface
                            .copy(alpha = 0.6f),
                modifier = Modifier
                    .alignByBaseline()
                    .padding(start = 16.dp)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Have you spotted the alignByBaseline() modifier? While most examples using Row() just vertically center the children, this is a nightmare from a UX perspective. The texts have to be baseline-aligned to be readable nicely.

Opened menu with a menu item showing the keyboard shortcut

That looks pretty nice, doesn't it?

You may be wondering where the shortcut text is created. I was surprised to learn that there seems to be no public-facing API in Android that provides a locale-aware String representation of KeyboardShortcutInfo. However, a function that achieves this must be around somewhere, since the often-mentioned keyboard shortcuts dialog needs something similar, too.

Checking the modifiers and the key code isn't too difficult, but there are some nuances that need to be taken into account. For example, on Chromebooks, Meta should be Search or some corresponding symbol.

private fun KeyboardShortcutInfo.getDisplayString(): String {
    val parts = mutableListOf<String>()
    if (modifiers and KeyEvent.META_CTRL_ON != 0) {
        parts.add("Ctrl")
    }
    if (keycode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) {
        parts.add(('A' + (keycode - KeyEvent.KEYCODE_A)).toString())
    }
    return parts.joinToString("+")
}
Enter fullscreen mode Exit fullscreen mode

This code suffices for my sample app, which uses only one shortcut. In a real-world app, you would want to take the string from the resources, accept more modifiers and key codes, and handle device-specific variations.

Conclusion

Adding keyboard shortcut support to your app may not sound particularly fancy at first sight, but in my opinion brings a lot of added value. Do your apps already support them? How did you handle the visual representation of the shortcuts? Please share your thoughts in the comments.

Top comments (0)