Blog Infos
Author
Published
Topics
, ,
Published

I need to admit, I totally fall in love with Jetpack Compose. Compose gives me a lot of joy, I never felt so confident with creating views. I always liked to make custom views and even more custom views with custom behaviors like gestures or/and animations. I was really happy when the designer came with a new mockup for bar charts. I already planned how to do that in Compose. Here is the design:

 

Interactive bar chart. Tap selects bar, long click pre-selects

 

Composable Lazy Row

Firstly I thought that it will work nicely as a LazyRow with simple Boxes. Box for gradient background, Box for actual Bar, and Text Composable for value above each bar. Unfortunately, there were some performance issues. Too many of those were on one screen and I was losing frames. The swipe gesture did not work smoothly.

Composable Canvas

This time, there was no performance issue at all, but I didn’t know how to do it interactively, let alone control states for the selected option and the temporary select. I will not write down how to create a bar chart using canvas, it is not a part of this article. I will focus on the interactive part and state management.

Make Canvas react to gestures

Initially, I needed a way to detect gestures on my canvas. Modifier.pointerInput appeared with a helping hand. This function creates a modifier for processing user input within the region of the modified element. Underneath it runs LauncherEffect with a coroutine scope for the chosen key. We just need to wait for the appropriate event to show up. Let’s see how we can use it in our own Composable.

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc,

@Composable
fun Modifier.startGesture(
onStart: (offsetX: Float) -> Unit
): Modifier {
val interactionSource = remember { MutableInteractionSource() }
return this.pointerInput(interactionSource) {
forEachGesture {
coroutineScope {
awaitPointerEventScope {
val touch = awaitFirstDown().also { it.consumeDownChange() }
onStart(touch.position.x)
}
}
}
}
}
  • MutableInteractionSource allows listening to interaction changes inside components.
  • AwaitPointerEventScope suspends and installs a pointer that can await events and react to them immediately.

At this point (line 11) we can call our onStart function. The event has started. Now, it would be nice to know how and when it will end. Below you can find the extended version of startGesture.

@Composable
fun Modifier.tapOrPress(
onStart: (offsetX: Float) -> Unit,
onCancel: (offsetX: Float) -> Unit,
onCompleted: (offsetX: Float) -> Unit
): Modifier {
val interactionSource = remember { MutableInteractionSource() }
return this.pointerInput(interactionSource) {
forEachGesture {
coroutineScope {
awaitPointerEventScope {
val tap = awaitFirstDown().also { it.consumeDownChange() }
onStart(tap.position.x)
val up = waitForUpOrCancellation()
if (up == null) {
onCancel(tap.position.x)
} else {
up.consumeDownChange()
onCompleted(tap.position.x)
}
}
}
}
}
}

This modifier can track the start, the end, and cancellation of the gesture. I added xPosition to each of the high order functions, to keep track of the pointer position and cancel the selection only if it exceeds the bounds.

Check which bar has been selected

To manage which bar has been touched, I’ve created a helper data class with fields: xStart, xEnd, index. For the purpose of this article, let’s assume that the distance between bars is 20px. I mapped all the elements of the list into my helper class and calculate the start and end positions for each of them.

val barAreas = list.mapIndexed { index, item ->
BarArea(
index = index,
data = item,
xStart = horizontalPadding + distance.times(index) - distance.div(2),
xEnd = horizontalPadding + distance.times(index) + distance.div(2)
)
}
view raw barareas.kt hosted with ❤ by GitHub

Job Offers

Job Offers


    Senior Android Developer (Remote)

    Komoot
    Europe
    • Full Time
    apply now

    Android Build Engineer

    Pinterest
    San Francisco, CA | Seattle, WA
    • Full Time
    apply now

    Android Developer

    Small and Modern GmbH
    Hamburg, Remote (Germany)
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

Jobs

With that prepared list, we can easily determine with which element the user interacts.

Simple select

For this scenario, we just need to know at what position the user ended(completed) the touch event. At this point, we should check if the position is in the range of one of the bars.

var selectedPosition by remember { mutableStateOf(0) }
val selectedBar by remember(selectedPosition, barAreas) {
derivedStateOf {
barAreas.find { it.xStart < selectedPosition && selectedPosition < it.xEnd }
}
}
Modifier.tapOrPress(
onStart = { },
onCancel = { },
onCompleted = { selectedPosition = it }
)

Every change of selectedPosition or barAreas will trigger the change for the selected bar. The selected position is changed in onCompleted.

I was struggling a bit about choosing the right animation API, but I found this flowchart in the documentation.

Since the animation is my source of truth, I choose the Animatable. Animatable creates a float value holder that automatically changes its value when we call .animateTo. Here is the implementation.

val animatable = remember { Animatable(0f) }
...
//Modifier.tapOrPress
onCompleted = {
scope.launch {
selectedPosition = it
animatable.snapTo(1f)
}
}
...
// @Composable Canvas
drawRoundRect(
brush = brush,
topLeft = Offset(
x = horizontalPadding + distance.times(selectedBar!!.index) - selectionWidth.div(2),
y = chartAreaHeight - chartAreaHeight.times(animatable.value)), // use of animatable value
size = Size(selectionWidth, chartAreaHeight),
cornerRadius = CornerRadius(cornerRadius)
)
view raw animatable.kt hosted with ❤ by GitHub
Manage two selections

The example presented at the beginning of this blog post shows two available states for each bar: pressed and focused. For better user experience, we decided to hold two states for the chart. Each bar can be focused, which does not guarantee it will be selected. In order to manage those states, we need to clone the following variables: selectedPosition, selectedBar, and animatable. I added the temp prefix for those clones. Let’s go for pseudo code to have some basic idea of how this will work.

gestureStarted()
// at this point we do not know how this gesture will end, so we can start animation of tempSelection
tempSelection = xPosition
tempAnimatable.animateTo(1)
---
gestureCanceled()
// user decided to cancel the gesture, we need to reset the selection and animation back to 0
tempSelection = -1
tempAnimatable.animateTo(0)
---
gestureCompleted()
// user finishes the click event, we need to assign values from temporary to main. If the animation was not finished at that point, it should take the value from temp and continue the animation till 1 as a final result.
animatable = tempAnimatable + animateTo(1)
tempAnimatable = 0
selection = tempSelection
tempSelection = -1

Here is the implementation. Most happens in onCompleted function. These two asynchronous functions are important to note:

  1. (line 31)The duration of finishing animation is calculated from temporary animation progress (1f — tempAnim.value)
  2. (line 37) In order to animate deselection, we need to firstly select it and then animate the value towards 0f.
Modifier.tapOrPress(
onStart = { position ->
scope.launch {
selectedBar?.let { selected ->
if (position in selected.xStart..selected.xEnd) {
// click in selected area - do nothing
} else {
tempPosition = position
scope.launch {
tempAnimatable.snapTo(0f)
tempAnimatable.animateTo(1f)
}
}
}
}
},
onCancel = { position ->
tempPosition = -Int.MAX_VALUE.toFloat()
scope.launch {
tempAnimatable.animateTo(0f)
}
},
onCompleted = {
val currentSelected = selectedBar
scope.launch {
selectedPosition = it
animatable.snapTo(tempAnimatable.value)
selectedBar?.data?.let { barData ->
measurementSelected(barData)
}
async {
animatable.animateTo(
1f,
animationSpec = tween(300.times(1f - tempAnimatable.value).roundToInt())
)
}
async {
tempAnimatable.snapTo(0f)
currentSelected?.let {
tempPosition = currentSelected.xStart.plus(1f)
tempAnimatable.snapTo(1f)
tempAnimatable.animateTo(0f)
}
}
}
}
)
view raw TapOrPress.kt hosted with ❤ by GitHub
Testing

Compose testing library provides an easy-to-read API to find elements, interact with them and verify. UI tests in compose use Semantics to interact with UI hierarchy. If you want to learn more, here is a good article.

There are two important things that we will add to make testing simpler, more readable. Firstly, I have moved bar selection outside of composable to have fine control from the parent and make testing easier. This pattern is called state hoisting, read more about it here.

@Composable
fun BarChartCanvas(list: List<Int>, barSelected: (Int) -> Unit)

Secondly, to make our search in UI hierarchy easier, we will add testTag to the canvas modifier.

Row(
    Modifier
        .fillMaxWidth()
        .padding(12.dp)
        .height(150.dp)
        .testTag("BarChart")
        .horizontalScroll(rememberScrollState())

Let’s create the first test, just to check the canvas existence.

@get:Rule
val composeTestRule = createComposeRule()
@Test
fun checkCanvasExistence() {
val list = mutableStateOf(listOf(1, 2, 3, 4, 5, 6, 7, 8))
composeTestRule.setContent {
BarChartCanvas(list = list.value, barSelected = { })
}
composeTestRule.onNodeWithTag(testTag = "BarChart").assertExists()
}

 

The test passes, the BarChartCanvas exists on the screen, now we can create a more demanding test. We will check if clicking at a given position will select the correct bar. Before I will show you the code, here are some important information:

  1. Selection width is 20.dp
  2. Horizontal start/end padding is 12.dp
  3. Initially, the first element of the chart is selected
@Test
fun clickOnThirdElementOfList() {
val list = mutableStateOf(listOf(1, 2, 3, 4, 5, 6, 7, 8))
val selectedItem = mutableStateOf(list.value.first())
val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() }
composeTestRule.setContent {
BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it })
}
composeTestRule.onNodeWithTag(testTag = "BarChart")
.performGesture { click(position = Offset(distance, 1f)) }
assertEquals(3, selectedItem.value)
}

The distance (x position) is calculated as a sum of start padding and 3 times selection width. To convert the dp values to px , we can use composeTestRule.density. It works the same like ComposableLocalDensity.current. Explaining the 9 and 10 lines of the snipped: onNodeWithTag is looking for our BarChart then we perform the click gesture at the calculated x position.

This action should result in changing the selectedItem to the third element, which has the value of 3.

The last test that I want to perform is canceling the selection. Think about the following scenario: the user clicks on Bar, but instead of releasing the pointer, swipes horizontally. Such a gesture should cancel the selection.

@Test
fun cancelSelectionOfThirdElement() {
val list = mutableStateOf(listOf(1, 2, 3, 4, 5, 6, 7, 8))
val selectedItem = mutableStateOf(list.value.first())
val distance = with(composeTestRule.density) { (12.dp + 20.dp.times(3)).toPx() }
composeTestRule.setContent {
BarChartCanvas(list = list.value, barSelected = { selectedItem.value = it })
}
composeTestRule.onNodeWithTag(testTag = "BarChart")
.performGesture { down(Offset(distance, 1f)) }
.performGesture { moveBy(Offset(distance, 0f)) }
.performGesture { up() }
assertEquals(1, selectedItem.value)
}

This test looks similar to the previous, but instead of a simple click gesture, we are performing 3 gestures: pointer down, move, and up. This gesture should not result in invoking onComplete from our tapOrPress modifier, so the third element shouldn’t be selected. Since the initial selection is element 1, we are checking this value in assertEquals.

 

 

Final thoughts

 

Interactive bar chart. Tap selects bar, long click pre-selects

 

In my opinion, creating custom views in android has never been easier. Jetpack Compose provides a really refreshing experience to android development. Managing the state of selection and animation is straightforward and very readable. If I were to create a similar interactive graph using XML, I would probably reach for a ready-made library.

Thanks, Damian Petla for the review.

 

If you have any questions or comments, please use reply or reach me on Twitter.

Happy composing! 💻

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
Yes! You heard it right. We’ll try to understand the complete OTP (one time…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu