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:
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.
After going first through the hard way I’ve took the decision to use Jetpack Compose to build the bar chart.
Let’s take each component one by one and build it:
Requirements:
@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)
)
}
Requirements:
@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
)
}
Requirements:
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))
}
}
}
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:
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))
}
}
}
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:
If you want to check the full code you can find it in the GroupedBarGraph repository on github.