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.
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:
override fun onProvideKeyboardShortcuts(
data: MutableList<KeyboardShortcutGroup>,
menu: Menu?,
deviceId: Int
) {
super.onProvideKeyboardShortcuts(data, menu, deviceId)
mutableListOf<KeyboardShortcutInfo>().apply {
add(
KeyboardShortcutInfo(
getString(R.string.say_hello),
KeyEvent.KEYCODE_H,
KeyEvent.META_CTRL_ON
)
)
data.add(KeyboardShortcutGroup(getString(R.string.general), this))
}
}
We define a mutable list of KeyboardShortcutInfo
instances, add one item to it, and 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 {
if (
keyCode == KeyEvent.KEYCODE_H &&
event.hasModifiers(KeyEvent.META_CTRL_ON)
) {
sayHello(channel)
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)
.
sayHello()
implements the logic to be executed upon keyboard shortcut presses. Here's how it is implemented:
private val channel = Channel<Unit>(Channel.CONFLATED)
…
fun sayHello(channel: Channel<Unit>) {
channel.trySend(Unit)
}
What we do with the channel 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 {
KeyboardShortcutDemo(
showKeyboardShortcuts = { requestShowKeyboardShortcuts() },
channel = channel
)
}
}
requestShowKeyboardShortcuts()
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, and how to react to shortcut presses. We can even summon a system dialog that lists it. But how do we visualise the shortcut in our user interface, and how do we mention the shortcut to the user so that they can remember it, similar to what I explained regarding classic menu bars?
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeyboardShortcutDemo(
showKeyboardShortcuts: () -> Unit,
channel: Channel<Unit>
) {
val snackBarHostState = remember { SnackbarHostState() }
var showMenu by remember { mutableStateOf(false) }
MaterialTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.more_options)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.say_hello)) },
onClick = {
showMenu = false
sayHello(channel)
}
)
}
}
)
},
snackbarHost = { SnackbarHost(snackBarHostState) }) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(), contentAlignment = Alignment.Center
) {
Button(onClick = showKeyboardShortcuts) {
Text(stringResource(R.string.show_keyboard_shortcuts))
}
}
stringResource(R.string.hello).let { hello ->
LaunchedEffect(channel) {
channel.receiveAsFlow().collect {
snackBarHostState.showSnackbar(hello)
}
}
}
}
}
}
The menu contains one item, Say »Hello«. When it is selected, a snack bar will appear. This also happens upon a keyboard shortcut press. This is the reason for passing the channel
to the composable. channel
is used with receiveAsFlow().collect {
to show the snack bar by invoking snackBarHostState.showSnackbar()
. It is also passed to sayHello()
inside onClick()
of the DropDownMenuItem()
. The onClick()
of the button just invokes the showKeyboardShortcuts
lambda.
There is only one piece of the puzzle missing. As you can see in the following screenshot, there is no mention of the keyboard shortcut.
Unlike the traditional Activity
-level options menu, DropDownMenuItem()
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 = {
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)
)
}
}
},
onClick = onClick,
modifier = modifier
)
}
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.
Here's how to use it:
DropdownMenuItemWithShortcut(
text = stringResource(R.string.say_hello),
shortcut = "Ctrl+H",
onClick = {
showMenu = false
sayHello(channel)
}
)
That looks pretty mundane, doesn't it? To my knowledge, there is 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.
fun getDisplayString(shortcutInfo: KeyboardShortcutInfo): String {
val parts = mutableListOf<String>()
if (shortcutInfo.modifiers and KeyEvent.META_CTRL_ON != 0) {
parts.add("Ctrl")
}
shortcutInfo.keycode.let { keyCode ->
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)