Creating a Grouped Bar Graph using Jetpack Compose

Written by andreivancea | Published 2022/07/13
Tech Story Tags: debugging | android | jetpack-compose | androiddev | how-to-build-a-bar-chart | bar-chart | android-app-development | android-development

TLDRA client requested a grouped bar chart that will represent financial data for a company. There was an easy way to do it, and a hard way. I’ve started implementing using the MPAndroidChart library that is widely used in a lot of projects, but matching the design 100% was just a dream that wasn’t gonna come true in the timeline given by the client. After a few days of trying to implement it using MPAndroid chart, I gave up. It was taking too much time so I moved on to the easy way.via the TL;DR App

Story

Recently on my latest project (Quartr - Investor Relations) I received a lot of interesting requests from the client, and one of them was to create a grouped bar chart that will represent financial data for a company. There was an easy way to do it, and a hard way…

Figma design:

Hard way

I’ve started implementing using the MPAndroidChart library that is widely used in a lot of projects, but matching the design 100% was just a dream that wasn’t gonna come true in the timeline given by the client.

After a few days of trying to implement it using MPAndroidChart and after rewriting most of the classes needed for drawing the grouped bar chart, I gave up. It was taking too much time so I moved on to the easy way.

Easy way

After going first through the hard way I’ve took the decision to use Jetpack Compose to build the bar chart.

Solution

Let’s take each component one by one and build it:

ChartBar

Requirements:

  • Apply a fading effect on bars with values lower than -30%
  • Bars need to have rounded corners
  • Bars need to have highlighted state

@Composable
fun ChartBar(
    modifier: Modifier = Modifier,
    percentage: Int,
    brush: Brush,
    isHighlighted: Boolean = false
) {
    Box(
        modifier = modifier
            .clip(RoundedCornerShape(barCornerSize))
            .height(abs(percentage).dp)
            .width(barWidth)
            .background(brush)
            .background(color = if (!isHighlighted) Color.Black.copy(alpha = 0.5f) else Color.Transparent)
    )
}

Group Label

Requirements:

  • Labels need to be centred above each group of bars
  • Labels need to have highlight state

@Composable
private fun GroupLabel(
    text: String,
    color: Color = white40,
    highlightColor: Color = white90,
    textStyle: TextStyle = MaterialTheme.typography.labelMedium,
    isHighlighted: Boolean = false
) {
    Text(
        modifier = Modifier.padding(bottom = 8.dp),
        text = text,
        color = if (isHighlighted) highlightColor else color,
        style = textStyle
    )
}

ChartBarGroup

Requirements:

  • Tap and hold for details

From here it gets interesting since we’ll have to construct the groups and also handle the tap and hold functionality. But first lets prepare the container for the bars and add the tap and hold functionality on the container

@Composable
fun ChartBarGroup(
    modifier: Modifier = Modifier,
    label: String,
    values: List<Pair<Int, Color>>,
    onGroupSelected: () -> Unit = {},
    onRemoveSelection: () -> Unit = {},
    isSelected: Boolean,
    isNothingSelected: Boolean
) {
    Column(
        modifier = modifier
            .height(groupBarAndLabelContainerHeight)
            .pointerInteropFilter { event ->
                when (event.action) {
                    MotionEvent.ACTION_DOWN -> {
                        onGroupSelected()
                    }
                    MotionEvent.ACTION_UP -> {
                        onRemoveSelection()
                    }
                    MotionEvent.ACTION_CANCEL -> {
                        onRemoveSelection()
                    }
                }
                true
            },
        horizontalAlignment = Alignment.CenterHorizontally
    ) {}
}

Now that we have the container ready we can start going through the values list and draw the bars, but before that we need to add the group label, so inside the Column from above we’ll add the Label and a Spacer that will fill the space between the bars and the label.

GroupLabel(
  text = label,
  isHighlighted = isSelected
)
Spacer(modifier = Modifier.weight(1f))

The fun part now begins. We have to build the group of bars. We’ll calculate the height of the container with this formula: height = abs(barVisualMinThreshold) + barVisualMaxThreshold.

The positive values will have a Spacer that will always have a height of abs(barVisualMinThreshold) that will represent the bottom offset.

The negative values will have a Spacer with a dynamic height based on the percentage of the negative value. The formula is: height = abs(barVisualMinThreshold) + percentage. We add the percentage here instead if subtracting it because the percentage will always be a negative value in this case.

For values lower than the barVisualMinThreshold we’ll have to apply a fading effect since we won’t show the whole height of that bar.

Last thing to add here is the highlight functionality and we’re done with the group of bars.

We end up with this:

Row(
            modifier = Modifier.height(groupBarContainerHeight), verticalAlignment = Alignment.Bottom
        ) {
            values.forEachIndexed { index, item ->
                val (realPercentage, color) = item
                val yOffset: Int
                val applyFadingEffect = realPercentage < barVisualMinThreshold
                val percentage = realPercentage.coerceIn(barVisualMinThreshold + 1, barVisualMaxThreshold - 1)

                yOffset = if (percentage >= 0) {
                    abs(barVisualMinThreshold)
                } else if (percentage in barVisualMinThreshold..-1) {
                    abs(barVisualMinThreshold) + percentage
                } else {
                    0
                }
                Column(
                    modifier = Modifier.fillMaxHeight(),
                    verticalArrangement = Arrangement.Bottom
                ) {
                    ChartBar(
                        percentage = percentage,
                        brush = if (applyFadingEffect) {
                            Brush.verticalGradient(listOf(color, color.copy(alpha = 0f)))
                        } else {
                            Brush.verticalGradient(listOf(color, color))
                        },
                        isHighlighted = isSelected || isNothingSelected
                    )
                    Spacer(modifier = Modifier.height(yOffset.dp))
                }
                if (index in 0 until values.size - 1) {
                    Spacer(modifier = Modifier.width(barSpacing))
                }
            }
        }

Bar Graph

The last component to complete the puzzle. In this composable we receive the list of groups that we have to draw on the screen.

Requirements:

  • Draw the bar groups
  • Spread the groups to fill the entire container width
  • Handle group selection functionality

First we’ll add the rounded corners container with some padding and a nice gradient for the background. Inside the container we’ll go through each BarGroup from the list that we received as a parameter and add the ChartBarGroup.

@Composable
fun BarGraph(
    barGroups: List<BarGroup>,
    onGroupSelectionChanged: (index: Int) -> Unit = {}
) {
    val backgroundBrush = Brush.verticalGradient(
        listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
    )

    val selectedGroupIndex = remember {
        mutableStateOf(-1)
    }

    Row(
        modifier = Modifier
            .clip(RoundedCornerShape(5.dp))
            .fillMaxWidth()
            .background(backgroundBrush)
            .padding(8.dp)
    ) {
        barGroups.forEachIndexed { index, item ->
            if (index == 0) {
                Spacer(modifier = Modifier.weight(1f))
            }
            ChartBarGroup(
                label = item.label,
                values = item.values,
                onGroupSelected = {
                    selectedGroupIndex.value = index
                    onGroupSelectionChanged(selectedGroupIndex.value)
                },
                onRemoveSelection = {
                    selectedGroupIndex.value = -1
                    onGroupSelectionChanged(selectedGroupIndex.value)
                },
                isSelected = selectedGroupIndex.value == index,
                isNothingSelected = selectedGroupIndex.value == -1
            )
            Spacer(modifier = Modifier.weight(1f))
        }
    }
}

Conclusions

Having to build this feature as fast as possible means that the first version of it will never be the optimal and the best one. But later on it can be improved. Some next steps for it would be:

  1. Making the chart scrollable - if we have multiple groups that exceed the width of the container we should be able to scroll horizontally to see the other bar groups.
  2. Making the graph fully customisable - this version of the grouped bar graph contains some hardcoded values for barWidth, barSpacing, barCornerSize and others that can be given to the BarGraph composable as parameters and make it fully customisable.

If you want to check the full code you can find it in the GroupedBarGraph repository on github.


Written by andreivancea | I am a Senior Android developer with 6+ years of experience and a proven history in the IT&C industry.
Published by HackerNoon on 2022/07/13