[Tutorial] D-pad navigation in Jetpack Compose

Overview

dpad-compose

D-pad navigation in Jetpack Compose

Navigating and clicking on grid items

The problem

While Android is mostly used on touch devices, the operating system can also be used with arrow keys and d-pads (directional pads). At the time of writing, the upcoming UI toolkit for writing native Android apps, Jetpack Compose, only has partial support for such navigation in its latest release 1.0.0-rc01. The library supports adding focusable() modifiers to elements and is able to move focus between items based on directional key presses, but it's unable to handle clicking nor scrolling lists yet.

As this has been asked for multiple times in the kotlinlang Slack channel, the purpose of this tutorial is to demonstrate how this functionality can be implemented in the current version of Jetpack Compose. The tutorial in its current revision includes clicking. Scrolling will, hopefully, be added a later date.

Feel free to suggest improvements by creating an issue or a pull request.

TL;DR

Clicking

Clicking involves invoking an action when the center key of the d-pad or the enter key is pressed. But before diving into the details of handling clicks, let's create a demo scene with items to navigate and click.

Creating a scrollable grid

In this case, we'll create a grid with colored boxes that expand beyond the screen both vertically and horizontally. Each row is individually scrollable horizontally while the whole grid can be scrolled vertically. Think Netflix and TV series.

Netflix-like grid

First, we need main Column that positions its children vertically that is scrollable:

Column(
    Modifier
        .fillMaxSize()
        .verticalScroll(verticalScrollState)
) { /* Children here */ }

Secondly, for each row we add a Row that positions its children horizontally and that's also scrollable:

val rowScrollState = remember { ScrollState(initial = 0) }
Row(
    Modifier
        .fillMaxWidth()
        .padding(bottom = 24.dp)
        .horizontalScroll(rowScrollState)
) { /* Row content here */ }

Finally, we need to map out the row items. To help us out we put our existing code inside a component that allows us to pass generic items T. These items are passed as a list of list, a list of row items, of any type T. In addition to this, we pass a function that maps any item T to a composable, i.e. what that item should look like. Using this component we can create scrollable grids of this kind with any type of items:

import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@ExperimentalComposeUiApi
@Composable
fun <T> ScrollableGrid(
    items: List<List<T>>,
    contentForItem: @Composable BoxScope.(item: T) -> Unit
) {
    val verticalScrollState = remember { ScrollState(initial = 0) }

    Column(
        Modifier
            .fillMaxSize()
            .verticalScroll(verticalScrollState)
    ) {
        items.forEach { rowItems ->
            val rowScrollState = remember { ScrollState(initial = 0) }
            Row(
                Modifier
                    .fillMaxWidth()
                    .padding(bottom = 24.dp)
                    .horizontalScroll(rowScrollState)
            ) {
                rowItems.forEach { rowItem ->
                    Row {
                        Box {
                            contentForItem(rowItem)
                        }
                        Spacer(Modifier.width(24.dp))
                    }
                }
            }
        }
    }
}

ScrollableGrid.kt

Next, we'll use our newly created component to create a grid of colored boxes. First, let's prepare list of lists containing the colors we want to display. To make this look decent I picked a palette from flatuicolors.com and copied some of the colors to a list:

val rowColors = listOf(
    Color(0xff1abc9c),
    Color(0xff2ecc71),
    Color(0xff3498db),
    Color(0xff9b59b6),
    Color(0xff34495e)
)

These are the primary colors for each row, but to further distinguish them horizontally let's darkened them progressively:

val itemsPerRow = 10
val boxColors = rowColors.map { rowColor ->
    (0..itemsPerRow).map { rowIndex ->
        val fraction = (1 - rowIndex.toFloat() / itemsPerRow)
        Color(
            red = fraction * rowColor.red,
            green = fraction * rowColor.green,
            blue = fraction * rowColor.blue
        )
    }
}

Next, let's add component that represent a grid item. In this case the item is a rectangular box with the given color as background:

@Composable
fun ColoredBox(
    modifier: Modifier = Modifier,
    color: Color
) {
    Box(
        modifier
            .size(128.dp)
            .background(color)
    )
}

Finally, let's pass the colors to the scrollable grid and the colored box component:

MaterialTheme {
  Column(
    Modifier
      .fillMaxSize()
      .background(Color(0xffecf0f1))
      .padding(start = 24.dp, top = 24.dp)
  ) {
    ScrollableGrid(
      items = boxColors,
    ) { color ->
      ColoredBox(
        color = color
      )
    }
  }
}

At this stage, you should have a Netflix-like grid that is row-wise scrollable and vertically scrollable as a whole.

Adding d-pad navigation

The next step is to make the grid items focusable using the d-pad and to invoke click actions when clicking the center key or enter. Similar to how adding out-of-the-box focus is done using the focusable() modifier, we'll create a custom modifier dpadFocusable() that we can attach to grid items. This modifier has the responsibility of showing a border if focused and appropriately responding to key events.

Dpad navigation

First, let's add a dummy modifier and add some arguments for configuring its behavior:

@ExperimentalComposeUiApi
fun Modifier.dpadFocusable(
    onClick: () -> Unit,
    borderWidth: Dp = 4.dp,
    unfocusedBorderColor: Color = Color(0x00f39c12),
    focusedBorderColor: Color = Color(0xfff39c12)
) = composed { /* Content here */ }

Next, we need a way to visualize what item is currently focused. In this case, we use a border that smoothly transitions between the focused color and the unfocused color (the default argument is in this case the focused color with alpha 0):

... = compose {
  val boxInteractionSource = remember { MutableInteractionSource() }
  val isItemFocused by boxInteractionSource.collectIsFocusedAsState()
  val animatedBorderColor by animateColorAsState(
      targetValue =
      if (isItemFocused) focusedBorderColor
      else unfocusedBorderColor
  )

  this.
    border(
      width = borderWidth,
      color = animatedBorderColor
    )
}

In the last step, we used an interaction source (boxInteractionSource) to listen to whether the item is focused or not. To receive these events, we need to make the item focusable and attach the interaction source:

... = compose {
  /* [...] */
  this.
    /* [...] */
    .focusable(interactionSource = boxInteractionSource)

Now the items are navigatable using a d-pad and the currently focused item is visualized with a border. Next, we'll add clicking.

D-pad navigation is sometimes used together with touch navigation. For this reason, we need to make the items touchable too. We'll do this by adding the .clickable() modifier and attaching the interaction source and click handler This also has the added effect of adding indications (by default ripples) whenever it is interacted with, a feature we'll use for d-pad clicks too:

... = compose {
  /* [...] */
  this.
    /* [...] */
    .clickable(
      interactionSource = boxInteractionSource,
      indication = rememberRipple()
    ) {
      onClick()
    }
}

With these elements in place, let's listen for key events. Listening to key events has two main goals: invoke the on-click handler whenever the center key is pressed and visualizing press and release events. For good user experience, we need to make it possible to cancel click events too. This is done by pressing and holding the center key (or enter) and then navigating to another item before releasing the center key. As for visualizing presses and releases, we'll use the default ripples.

We add a .keyEvent() modifier with a block that is run for key events, but we ignore any other key than the center or enter key by returning early:

/* [...] */
this.
/* [...] */
  .onKeyEvent {
    if (!listOf(Key.DirectionCenter, Key.Enter).contains(it.key)) {
      return@onKeyEvent false
    }
  /* [...] */
  }

Then we check whether the event was a key down or key up event. For key down events, we don't invoke the click handler yet, but we want to indicate to the user that the click has been registered. This is done by emiting a PressInteraction.Press event to the interaction source:

... = compose {
  val scope = rememberCoroutineScope()
  this.
  /* [...] */
    .onKeyEvent {
    /* [...] */
      when (it.type) {
        KeyEventType.KeyDown -> {
          val press =
            PressInteraction.Press(
              pressPosition = Offset(
                x = boxSize.width / 2f,
                y = boxSize.height / 2f
              )
            )
          scope.launch {
            boxInteractionSource.emit(press)
          }
          previousPress = press
          true
        }
        KeyEventType.KeyUp -> { /* [...] */ }
        else -> false
      }
    }
}

In the snippet above, there are two variables that we haven't declared yet: boxSize and previousPress.

Ripple indications grow from the point where the user pressed the item, for this reason we need to specify a position for the interaction even though a d-pad click has no inherent position. One option which I found to look good is to have the ripple grow from the center of the item. To achieve this we need to know the width and height of the element in question. This can be done by adding a onGloballyPositioned modifier that is called whenever the element's global position has changed. We keep track of the size and update it whenever the modifier's block is called:

... = compose {
  /* [...] */
  var boxSize by remember {
    mutableStateOf(IntSize(0, 0))
  }
  this.
    /* [...] */
    .onGloballyPositioned {
      boxSize = it.size
    }
}

After the ripple indication has finished the background of the pressed item remains slightly dimmed to indicate that it is still being pressed. To release the dimming we need to emit a PressInteraction.Release event with the press event as an argument (this has to be specified for supporting pressing the same item multiple times at different positions), which was the reason why we saved the press event in a variable in the previous snippet. Now let's actually declare the variable and use it to release the press on key ups. We also invoke the click handler here:

... = compose {
  /* [...] */
  var previousPress: PressInteraction.Press? by remember {
    mutableStateOf(null)
  }
  this.
    /* [...] */
    .onKeyEvent {
      /* [...] */
      when (it.type) {
        /* [...] */
        KeyEventType.KeyUp -> {
          previousPress?.let { previousPress ->
            onClick()
            scope.launch {
              boxInteractionSource.emit(
                PressInteraction.Release(
                  press = previousPress
                )
              )
            }
          }
          true
        }
      }
    }
}

As the .onKeyEvent is only called if the item is focused, clicks can be canceled by navigating to another item and hence unfocusing the item before releasing the center key. This also means, however, that the item will remain in a pressed state. To ensure the presses are released whenever the item is unfocused, we add LaunchedEffect that is run every time the isItemFocused state changes. We use this to release any potentially present press whenever the item is unfocused:

this.
  /* [...] */
  LaunchedEffect(isItemFocused) {
    previousPress?.let {
      if (!isItemFocused) {
        boxInteractionSource.emit(
          PressInteraction.Release(
            press = it
          )
        )
      }
    }
  }

With all these parts, we end up with a modifier that looks like this:

Press interactions on a box

@ExperimentalComposeUiApi
fun Modifier.dpadFocusable(
  onClick: () -> Unit,
  borderWidth: Dp = 4.dp,
  unfocusedBorderColor: Color = Color(0x00f39c12),
  focusedBorderColor: Color = Color(0xfff39c12)
) = composed {
  val boxInteractionSource = remember { MutableInteractionSource() }
  val isItemFocused by boxInteractionSource.collectIsFocusedAsState()
  val animatedBorderColor by animateColorAsState(
    targetValue =
      if (isItemFocused) focusedBorderColor
      else unfocusedBorderColor
  )
  var previousPress: PressInteraction.Press? by remember {
    mutableStateOf(null)
  }
  val scope = rememberCoroutineScope()
  var boxSize by remember {
    mutableStateOf(IntSize(0, 0))
  }

  LaunchedEffect(isItemFocused) {
    previousPress?.let {
      if (!isItemFocused) {
        boxInteractionSource.emit(
          PressInteraction.Release(
            press = it
          )
        )
      }
    }
  }

  this
    .onGloballyPositioned {
      boxSize = it.size
    }
    .clickable(
      interactionSource = boxInteractionSource,
      indication = rememberRipple()
    ) {
      onClick()
    }
    .onKeyEvent {
      if (!listOf(Key.DirectionCenter, Key.Enter).contains(it.key)) {
        return@onKeyEvent false
      }
      when (it.type) {
        KeyEventType.KeyDown -> {
          val press =
            PressInteraction.Press(
              pressPosition = Offset(
                x = boxSize.width / 2f,
                y = boxSize.height / 2f
              )
            )
          scope.launch {
            boxInteractionSource.emit(press)
          }
          previousPress = press
          true
        }
        KeyEventType.KeyUp -> {
          previousPress?.let { previousPress ->
            onClick()
            scope.launch {
              boxInteractionSource.emit(
                PressInteraction.Release(
                  press = previousPress
                )
              )
            }
          }
          true
        }
        else -> {
          false
        }
      }
    }
    .focusable(interactionSource = boxInteractionSource)
    .border(
      width = borderWidth,
      color = animatedBorderColor
    )
}

DpadFocusable.kt

Reacting to click events in the grid

With a scrollable grid and a modifier for reacting to click events, let's put the two parts together to react to clicks on items in the grid.

First, we'll create a banner component with a text and a circle indicating for visualizing what color has been clicked last:

@Composable
fun ColorClickedBanner(color: Color) {
  Row {
    Row(Modifier.height(IntrinsicSize.Min)) {
      Text(text = "Clicked color: ", style = MaterialTheme.typography.h3)
      Spacer(Modifier.width(24.dp))
      Box(
        Modifier
          .background(color, CircleShape)
          .aspectRatio(1f)
          .fillMaxSize()
      )
    }
  }
}

We then place the banner above the grid and add some state for keeping track of what color has been clicked. We update this state by adding our newly created .dpadFocusable modifier to the colored box item:

var colorClicked: Color by remember { mutableStateOf(Color.Transparent) }

MaterialTheme {
  Column(
    Modifier
      .fillMaxSize()
      .background(Color(0xffecf0f1))
      .padding(start = 24.dp, top = 24.dp)
  ) {
    ColorClickedBanner(color = colorClicked)
    Spacer(Modifier.height(24.dp))
    ScrollableGrid(
      items = boxColors,
    ) { color ->
      ColoredBox(
          Modifier.dpadFocusable(
            onClick = {
              colorClicked = color
            }
          ),
          color = color
      )
    }
  }
}

MainActivity.kt

Voilร ! We now have a grid that is scrollable, where items can be navigated using the d-pad, and where center clicks cause and indication and the banner to update based on the item clicked.

Navigating and clicking on grid items

Next steps: scrolling

As you may notice, items that are outside of the screen are currently inaccessible. The expected behavior is that the grid scrolls when reaching the edges to expose further content, but this doesn't happen at the moment. In the next part of this tutorial we will add support for this, stay tuned.

You might also like...
Android Sample Kotlin+ MVI + Jetpack compose + Coroutines + Retrofit + Hilt  + Room + Navigation component
Android Sample Kotlin+ MVI + Jetpack compose + Coroutines + Retrofit + Hilt + Room + Navigation component

MVIComposeSample Android Sample app to show user latest movies implementing MVI + Clean Architecture using kotlin & Jetpack compose following solid an

Android App made by Jetpack Compose Components with Kotlin, MVVM Pattern, Multi Module, Navigation, Hilt, Coroutines, Retrofit and cached data by Room
Android App made by Jetpack Compose Components with Kotlin, MVVM Pattern, Multi Module, Navigation, Hilt, Coroutines, Retrofit and cached data by Room

Android App made by Jetpack Compose Components with Kotlin, MVVM Pattern, Multi Module, Navigation, Hilt, Coroutines, Retrofit and cached data by Room

Android App made by Jetpack Compose Components with Kotlin, MVVM Pattern, Multi Module, Navigation, Hilt, Coroutines, Retrofit and cached data by Room
Android App made by Jetpack Compose Components with Kotlin, MVVM Pattern, Multi Module, Navigation, Hilt, Coroutines, Retrofit and cached data by Room

Mobile Banking Android App made by Jetpack Compose Components with Kotlin, MVVM Pattern, Multi Module, Navigation, Hilt, Coroutines, Retrofit and cach

Model-driven navigation for Jetpack Compose
Model-driven navigation for Jetpack Compose

Model-driven navigation for Jetpack Compose

Jetpack Compose Boids | Flocking Insect ๐Ÿœ. bird or Fish simulation using Jetpack Compose Desktop ๐Ÿš€, using Canvas API ๐ŸŽจ
Jetpack Compose Boids | Flocking Insect ๐Ÿœ. bird or Fish simulation using Jetpack Compose Desktop ๐Ÿš€, using Canvas API ๐ŸŽจ

๐Ÿœ ๐Ÿœ ๐Ÿœ Compose flocking Ants(boids) ๐Ÿœ ๐Ÿœ ๐Ÿœ Jetpack compose Boids | Flocking Insect. bird or Fish simulation using Jetpack Compose Desktop ๐Ÿš€ , usi

A collection of animations, compositions, UIs using Jetpack Compose. You can say Jetpack Compose cookbook or play-ground if you want!
A collection of animations, compositions, UIs using Jetpack Compose. You can say Jetpack Compose cookbook or play-ground if you want!

Why Not Compose! A collection of animations, compositions, UIs using Jetpack Compose. You can say Jetpack Compose cookbook or play-ground if you want!

Learn Jetpack Compose for Android by Examples. Learn how to use Jetpack Compose for Android App Development. Androidโ€™s modern toolkit for building native UI.
Learn Jetpack Compose for Android by Examples. Learn how to use Jetpack Compose for Android App Development. Androidโ€™s modern toolkit for building native UI.

Learn Jetpack Compose for Android by Examples. Learn how to use Jetpack Compose for Android App Development. Androidโ€™s modern toolkit for building native UI.

This is a sample app(For beginners - App #2) built using Jetpack Compose. It demonstrates the concept of State Hoisting in Jetpack Compose.
This is a sample app(For beginners - App #2) built using Jetpack Compose. It demonstrates the concept of State Hoisting in Jetpack Compose.

JetBMICalculator This is a sample app(For beginners - App #2) built using Jetpack Compose. It demonstrates the concept of State Hoisting in Jetpack Co

Jetpack-Compose-Demo - Instagram Profile UI using Jetpack Compose
Jetpack-Compose-Demo - Instagram Profile UI using Jetpack Compose

Jetpack-Compose-Demo Instagram Profile UI using Jetpack Compose

Comments
  • List now scrolls down, left and right on dpad interaction

    List now scrolls down, left and right on dpad interaction

    I came across BringIntoViewRequester from this article:

    https://medium.com/tech-takeaways/automatic-scrolling-to-composable-on-focus-change-with-bringintoviewrequester-in-jetpack-compose-bdeb72242bac

    I've integrated this into ScrollableGrid.

    I've also added a KeyEventListener so that if we're on the first or last item in a row, the d-pad event is not propagated

    If you have any thoughts/idea's that would be greatly appreciated

    opened by AyoTheDev 1
Owner
Walter Berggren
Walter Berggren
Code for Compose Accessibility Tutorial

Bon Appetit Sample Android app License Copyright (c) 2021 Razeware LLC Permission is hereby granted, free of charge, to any person obtaining a copy o

Victoria Gonda 0 Nov 26, 2021
AndroidComposeTutorial - Android Compose Tutorial With Kotlin

AndroidComposeTutorial LightMode DarkMode Click Event

Chaebin Park 1 Jan 4, 2022
A library that enables Safe Navigation for you Composable destinations when using Jetpack Compose Navigation

A library that enables Safe Navigation for you Composable destinations when using Jetpack Compose Navigation

Roman Levinzon 59 Oct 19, 2022
Navigation-Compose - A sample to showcase Kotlin, MVVM, Hilt, Coroutines, StateFlow, Jetpack compose

Navigation-Compose A sample to showcase Kotlin, MVVM, Hilt, Coroutines, StateFlo

Mohammadali Rezaei 6 Jul 13, 2022
A Simple Blog App using Jetpack Compose, Flow, Navigation Compose, Room and Firebase

BlogCompose A Simple Blog App using Jetpack Compose, Flow, Navigation Compose, Room and Firebase Instructions Download your Firebase configuration fil

null 4 Oct 10, 2022
This repos one of the ways hows how to use Jetpack Compose Navigation along with Dagger 2

Dagger 2 and Jetpack Compose Integration This repository is about a way how to use Dagger 2 for projects which using Jetpack Compose. Here is an artic

Alexey Glukharev 10 Nov 16, 2022
Create Bottom Navigation Bar with Jetpack Compose

BottomNavigationBarComposeExample Create Bottom Navigation Bar with Jetpack Compose https://johncodeos.com/how-to-create-bottom-navigation-bar-with-je

JohnCodeos.com 31 Dec 24, 2022
Small Android project demonstrating some navigation components for Jetpack Compose.

Small Android project demonstrating some navigation components for Jetpack Compose. Created this for presenting about this topic for a GDG meetup.

Parshav 3 Sep 15, 2021
Small code generating library for safe Jetpack Compose navigation with no boilerplate.

Compose Destinations A KSP library to use alongside compose navigation. It reduces boilerplate code and is less error-prone since passing arguments be

Rafael Costa 1.9k Jan 5, 2023
Kotlin, MVVM, Navigation Component, Hilt, Jetpack Compose, Retrofit2

What is this project? This course will replace my old java mvvm introduction: https://codingwithmitch.com/courses/rest-api-mvvm-retrofit2/. Watch the

Mitch Tabian 452 Jan 1, 2023