DEV Community

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

Posted 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.

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))
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
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).

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

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 {
    KeyboardShortcutDemo(
      showKeyboardShortcuts = { requestShowKeyboardShortcuts() },
      channel = channel
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot of the KeyboardShortcutDemo sample

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)
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Opened menu

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
  )
}
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, this time with a menu item showing the keyboard shortcut

Here's how to use it:

DropdownMenuItemWithShortcut(
  text = stringResource(R.string.say_hello),
  shortcut = "Ctrl+H",
  onClick = {
    showMenu = false
    sayHello(channel)
  }
)
Enter fullscreen mode Exit fullscreen mode

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("+")
}
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)