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:
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
))
}
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)
}
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)
}
}
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(),
)
}
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.
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() }
}
}
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.
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 = "" })
}
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()
}
}
}
}
}
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)
)
}
}
}
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.
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("+")
}
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)