Drawing a graph with a mix of composable components and composable Canvas

The article describes the Android Jetpack Compose implementation of a bar graph with overlayed compound lines drawn on a composable Canvas. It contains some code snippets in Kotlin and explains the basic usage of Canvas.

Maciej Rudnicki
DNA Technology
7 min readDec 8, 2023

--

The use-case

Let’s say we have an app that motivates users to drink more water.
We want to show the progress of the journey in the form of monthly graphs.
The graph should have bars stating the amount of water consumed.
When users exceed their goal they are awarded with badges displayed on top of the bar and the bar is clickable to allow checking the inspiring text connected with the badge.

On top of that, we’d like to have a line drawn showing the goal set by users themselves.

The top of the graph should match the greater of two values — the amount consumed or the goal consumed by the user.

Image depicting the desired result

Let’s make it work

I’ll make the graph with a pre-defined height and the bars will have a defined width, but will allow the graph to adjust its width to the space available by leaving the spaces undefined.
As the bars need to be clickable they will be constructed with the usage of @Composable components.
The “goal line” will be drawn on the canvas overlayed on top of the graph.

The bars

Each bar will consist of a box with a defined size acting as the background, another box with a height dependent on the consumed water amount, and a badge displayed when consumption is greater than or equal to the goal set.

This way I’ll have a component that can be made clickable.

@Composable
private fun GraphBar(
week: GraphData.GraphWeekData,
maxValue: Double,
onClick: () -> Unit
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.clickable {
onClick.invoke()
}) {
Box(
modifier = Modifier
.height(GRAPH_HEIGHT.dp)
.width(BAR_WIDTH.dp)
.background(Cheese),
contentAlignment = Alignment.BottomCenter
) {


Box(
modifier = Modifier
.height(week.getBarHeight(maxValue = maxValue, GRAPH_HEIGHT).dp)
.width(BAR_WIDTH.dp)
.background(Royal)
) {

}
}

if (week.showReward()) {
Image(
painter = painterResource(id = R.drawable.star),
contentDescription = "Reward star",
modifier = Modifier
.padding(horizontal = 4.dp),
contentScale = ContentScale.FillWidth
)
}
}
}

I’ll just mention that getGraphHeight scales the week.goal value so that maxValue (greatest of all consumption and goals) is equal to BAR_HEIGHT.

Then the graph can be easily created like this:

Box(Modifier
.weight(1f)
.padding(vertical = 32.dp)
.padding(horizontal = 16.dp)) {
Row(
modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {

for (week in data.weeks) {
GraphBar(week, maxValue) {
Toast.makeText(context, "Inspirational quote for week ${week.weekNo}", Toast.LENGTH_LONG).show()
}
}
}
}

The “goal line”

I will draw the line using Canvas .

It can be used in the view like any other Composable with the restriction that it needs to have its size defined with the usage of aModifier as there are no internal components that could define the size.
Note that fillMax... modifiers are applicable as well as size constraints.

Row {
Box(
Modifier
.weight(1f)
.padding(vertical = 32.dp)
.padding(horizontal = 16.dp)) {
// Bars
Row(
modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {...}

// Line
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(GRAPH_HEIGHT.dp)
// let's check if the canvas is placed properly by making it visible
.background(Color.Cyan.copy(alpha = 0.2f))
) {

}
}
}
Canvas overlayed over the bar graph.

Let’s draw a horizontal line.

Drawing on Canvas comes down to providing the type of object (line, rectangle, path, arc) and its coordinates on the canvas. That is, calling drawLine, drawRect, and other functions inside

The origin of the coordinate system, the (0,0) point, is the top left corner of the canvas.

All the operations are performed using pixels so it is important to take into account that if Canvas size changes then any hardcoded pixel size will not be correct.

Had my graph been completely created on Canvas I could just tell that the bar width is some fraction of the canvas but since I am drawing bars as Boxes with the width defined I can recalculate BAR_WIDTH to pixels using dp.toPx() .

drawLine(
color = Cerise,
strokeWidth = 4.dp.toPx(),
start = Offset(x = 0f, y = size.height / 2),
end = Offset(x = BAR_WIDTH.dp.toPx(), y = size.height / 2)
)

By iterating through available data and adding offset I can now draw a goal line for each day.

val spaceWidth = (size.width -  data.weeks.count()*BAR_WIDTH.dp.toPx()) / (data.weeks.count() - 1)
val scaleFactor = size.height / maxValue.toFloat()

for (week in data.weeks) {
val y = (maxValue - week.goal).toFloat() * scaleFactor
val colNum = data.weeks.indexOf(week)
val x = colNum * BAR_WIDTH.dp.toPx() + colNum * spaceWidth
drawLine(
color = Cerise,
strokeWidth = 10.dp.toPx(),
start = Offset(x = x, y = y),
end = Offset(x = x + BAR_WIDTH.dp.toPx(), y = y)
)
}

Again, space width is dependent on the canvas width to allow the graph to scale together with the components below it.

The y value is defined by the difference between max and goal values ((maxValue — week.goal).toFloat() )as the 0 is on the top and we need to provide distance between the top and the line to draw it properly.
The scaleFactor = size.height / maxValue.toFloat() factor is used to scale all y coordinates so that maxValue is equal to the height of the canvas in pixels.
The x coordinates do not need scaling as the calculations are already done in the pixels — they only use canvas width and BAR_WIDTH.dp.toPx() .

Now, the only thing missing is to connect the dots or rather the lines.
The code below draws the line that connects the end vertice of the previous line with the start of the current line.

Inside the if statement, I calculate the value of the previous lines y and, the end x coordinates.

val scaleFactor = size.height / maxValue.toFloat()
for (week in data.weeks) {
val y = (maxValue - week.goal).toFloat() * scaleFactor
val colNum = data.weeks.indexOf(week)
val x = colNum * BAR_WIDTH.dp.toPx() + colNum * spaceWidth
if (colNum > 0) {
val prevX = x - spaceWidth
val prevY = (maxValue - data.weeks[colNum-1].goal).toFloat() * scaleFactor
drawLine(
color = Cerise,
strokeWidth = 4.dp.toPx(),
start = Offset(x = prevX, y = prevY),
end = Offset(x = x, y = y)
)
}
drawLine(
color = Cerise,
strokeWidth = 4.dp.toPx(),
start = Offset(x = x, y = y),
end = Offset(x = x + BAR_WIDTH.dp.toPx(), y = y)
)
}

Nice, but there’s a catch. The ends of the segments do not align nicely because the lines are separate graphic elements.

Fortunately, there is a way to draw compound objects.

val spaceWidth = (size.width -  data.weeks.count()*BAR_WIDTH.dp.toPx()) / (data.weeks.count() - 1)

val graphPath = Path()
graphPath.moveTo(x = 0f, y = size.height / 2)

for (week in data.weeks) {
val y = (maxValue - week.goal).toFloat() * size.height / maxValue.toFloat()
val colNum = data.weeks.indexOf(week)
val x = colNum * BAR_WIDTH.dp.toPx() + colNum * spaceWidth

if (colNum == 0) {
graphPath.moveTo(x = x, y = y)
graphPath.lineTo(x = x + BAR_WIDTH.dp.toPx(), y = y)
} else {
graphPath.lineTo(x = x, y = y)
graphPath.lineTo(x = x + BAR_WIDTH.dp.toPx(), y = y)

}
}

drawPath(
path = graphPath,
color = Cerise,
style = Stroke(width = 4.dp.toPx(), join = StrokeJoin.Miter, cap = StrokeCap.Butt, pathEffect = PathEffect.cornerPathEffect(1f) )
)

I can create a Path object and collect all the elements in it. Using a path eliminates the issue of gaps between segments. Since the path stores the last offset passed to it, it also makes it much easier to draw consecutive segments as I only need to provide the next vertices' coordinates.

Setting up the canvas in other ways

In the examples above I’ve used separate Canvas composable but for this use case, I could use a modifier on the row that wraps the bars to set up canvas exactly matching the components' size.

Row(
modifier
.fillMaxWidth()
.drawWithContent {
// canvas graphic elements
drawPath(...)

drawContent()
}
,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Content drawn by callling drawContent()
for (week in data.weeks) {
GraphBar(week, maxValue) {
Toast.makeText(context, "Inspirational quote for week ${week.weekNo}", Toast.LENGTH_LONG).show()
}
}

This takes all the guesswork connected with accurately placing the canvas.

It’s worth mentioning that drawWithContent the modifier allows one to easily draw behind or in front of the content. It’s just a matter of calling drawContent() before, after, or somewhere among drawing operations.

Another modifierdrawBehind allows us to easily obtain a canvas component behind the content. A good use case for this would be a button with text on top of a gradient-filled rectangle.

Speaking about gradient, in such cases it may be useful to use drawWithCache modifier to cache objects like Brush or Shader between recompositions enhancing the performance of drawing.

Key takeouts

  1. Compose Canvas can be instantiated as a separate component or with a modifier to another composable.
  2. When canvas is a standalone component it requires well-defined size constraints.
  3. When combining dp sizes with a canvas one needs to recalculate dp to pixels that are used in the canvases coordinate system.
  4. The path should be a preferred way of drawing when complex shapes are to be drawn.
  5. Different implementations of canvas allow to place graphic elements in different arrangements relative to other components.
  6. Caching graphics is possible.

--

--

Maciej Rudnicki
DNA Technology
0 Followers

A software engineer who believes that tech stack is secondary to the business value of the product. Software Engineer at DNA Technology (dnatechnology.io)