Canvas Made Easy In Jetpack Compose

Canvas in Android, which is something I always hesitant to try because it requires several boilerplate codes to be setup and that made it very difficult to understand. However, when I started using the Jetpack Compose, I realised designing a screen has become a lot easier than ever. This motivated me further to revisit Canvas again. This time it changed my idea about Canvas. If you are also like me who hesitated to work on Canvas before, then let me convince you to try it again with three simple examples.

What we will be creating from this article?

  • Right angled triangle progress bar.
  • Circular progress bar.
  • Gauge progress bar with its current progress.

Right Angled Triangle

Right angled triangle can be created in three steps; First, we will be creating the triangle, then we will be having the overlay over the triangle and then the overlay will be changed dynamically. Before starting to create, we need to understand, how these canvas object will be placed on the screen.

All our screens are filled with pixels. In simple words, when zoomed in the screen, we will be able to see millions of boxes. Each box is considered as a pixel. Basic idea of Canvas is, when those pixels are filled with colors, which will in return shows an image on the screen.

By default, Canvas starts drawing from first pixel (x = 0, y = 0). Take for example, we will use to draw the right angled triangle.

Canvas(modifier = Modifier
.width(300.dp)
.height(150.dp)
.padding(16.dp)
) {
val path = Path()
path.moveTo(size.width, 0f)
path.lineTo(size.width, size.height)
path.lineTo(0f, size.height)

drawPath(
path = path,
brush = SolidColor(Color.LightGray)
)
}

Before we look into the above code, let’s see how this get rendered in the screen.

As we saw earlier, all our will start from (0, 0), meaning our curser will start at the position of x=0 and y=0, which is the top-left of our screen. First, we need to move our cursor to the position of which was the width of our . To move we will be using the method with the position of and . Once it is moved, we need to draw a line 90° to the bottom at the position of and , where we will take the width and height of our Canvas view. Again, draw a line of 180° to the position of and .

Once the is done, pass it to the method. This will fill out the remaining line and make it as a right angled triangle.

We have drawn a right angled triangle as shown in the above image. Now we need to fill the triangle to half with green color, to make it lookalike a progress of 50%. To replicate the progress of 50%, we will be using method.

Canvas(modifier = Modifier
.width(300.dp)
.height(150.dp)
.padding(16.dp)
) {
val path = Path()
path.moveTo(size.width, 0f)
path.lineTo(size.width, size.height)
path.lineTo(0f, size.height)
drawPath(
path = path,
brush = SolidColor(Color.LightGray)
)

drawRect(
SolidColor(Color.Green),
size = Size(
0.5f * size.width,
size.height
)
)
}

Hmm… That’s interesting! But the reality is different from what we expect. Let’s fix this by clipping the unwanted space.

Canvas(modifier = Modifier
.width(300.dp)
.height(150.dp)
.padding(16.dp)
) {
val path = Path()
path.moveTo(size.width, 0f)
path.lineTo(size.width, size.height)
path.lineTo(0f, size.height)
clipPath(
path = path,
clipOp = ClipOp.Intersect
) {
drawPath(
path = path,
brush = SolidColor(Color.LightGray)
)

drawRect(
SolidColor(Color.Green),
size = Size(
0.5f * size.width,
size.height
)
)
}
}

We can use method by passing the right angled triangle path. Next, we need to specify the type of that we intent to use ( or ). If we try using the as , this will make the given as transparent. But we wanted the opposite of it, so let’s use instead. This will allow us to draw only on the given . Not to mention, remaining will remain transparent and restrict us from drawing further. The above code will produce the below .

The above progress can be made dynamic by modifying the value from the method, but you will want to make sure to pass the value in a and the value must be between to .

val sliderValue by remember { mutableStateOf(0.5f) }

drawRect(
SolidColor(Color.Green),
size = Size(
sliderValue * size.width,
size.height
)
)

Circular Progress Bar

Creating a circular progress bar will be so much simpler than creating a right angled triangle. To create a circular progress, we will be following three steps; creating a circle, creating a progress using an arc and having a to make the progress as dynamic. First, let’s create a circle

Canvas(
modifier = Modifier
.size(250.dp)
.padding(16.dp)
) {
drawCircle(
SolidColor(Color.LightGray),
size.width / 2,
style = Stroke(35f)
)
}

The above code will create a circle with a radius of , where the radius comes from the Canvas width of . In method, we have used three parameters: , and . will be used to provide a different kind of shaders to the like , , etc. In , we have two types of : and . In our case we will be using the with a width of .

Now, we will create a progress bar using . Before getting into the , we need to understand, how a sweep and a start angle works? Start Angle starts from 3 in our clock and the series of angle will be created in a clockwise manner. Whereas in sweep angle, all the angles are created in an anti-clockwise manner.

Along with the arc we will complete our example with a dynamic progress value using a .

val sliderValue by remember { mutableStateOf(0.5f) }

Canvas(
modifier = Modifier
.size(250.dp)
.padding(16.dp)
) {
drawCircle(
SolidColor(Color.LightGray),
size.width / 2,
style = Stroke(35f)
)
val convertedValue = sliderValue * 360
drawArc(
brush = SolidColor(Color.Black),
startAngle = -90f,
sweepAngle = convertedValue,
useCenter = false,
style = Stroke(35f)
)
}

The above code will create a UI like below. If you notice the from the method, I have used the value as instead of . The reason is, I need my progress to start from the 90° and should progress in a clockwise manner. If I provided a positive 90°, the progress will be moving in an anti-clockwise way. In we will be multiplying float value( to ) with 360° to replicate progress.

Gauge Circle

So far, we have created a right angled triangle and a circular progress bar. Now we will create a Gauge progress bar with a current progress in the centre of the View. To create a gauge, we will be using method.

Canvas(
modifier = Modifier
.width(250.dp)
.height(250.dp)
.padding(16.dp)
) {
drawArc(
brush = SolidColor(Color.LightGray),
startAngle = 120f,
sweepAngle = 300f,
useCenter = false,
style = Stroke(35f, cap = StrokeCap.Round)
)
}

We need to revisit the sweep and start angle image again to understand why we have given the as and as .

As we saw above, the will draw in a clockwise and will draw the arc in an anti-clockwise manner. To create a perfect Gauge View, we need to provide the sweep and the start angle value as and respectively. Those values are marked in the above image to show you a clear picture of sweep and start angle values. To provide a we have used , in we have introduced a new parameter . If there is no was provided, then we will get the boxed corner at each edge of Gauge View. To make it as a rounded corner we have used .

Next, let’s create another arc to make it look like a progress.

val sliderValue by remember { mutableStateOf(0.5f) }

Canvas(
modifier = Modifier
.width(250.dp)
.height(250.dp)
.padding(16.dp)
) {
drawArc(
brush = SolidColor(Color.LightGray),
startAngle = 120f,
sweepAngle = 300f,
useCenter = false,
style = Stroke(35f, cap = StrokeCap.Round)
)

val convertedValue = sliderValue * 300

drawArc(
brush = SolidColor(Color.Cyan),
startAngle = 120f,
sweepAngle = convertedValue,
useCenter = false,
style = Stroke(35f, cap = StrokeCap.Round)
)
}

In the above code snippet we have multiplied the with . The reason for using this value is, if we look into our first arc the sweep angle was 300f which is the max value of the Gauge View. So, we need to multiply the float value from the with to achieve the percentage of the progress. The same approach was followed in right angled triangle and the circular progress bar.

Now, let’s add the progress in percentage to the centre of the View.

Canvas(…) {
drawArc(…)

val convertedValue = sliderValue * 300

drawArc(…)

drawIntoCanvas {
val paint = Paint().asFrameworkPaint()
paint.apply {
isAntiAlias = true
textSize = 55f
textAlign = android.graphics.Paint.Align.CENTER
}
it.nativeCanvas.drawText(
"${round(sliderValue * 100).toInt()}%",
size.width / 2,
size.height / 2,
paint
)
}
}

While writing this article, not all options are available in the library. For some options we still need to depend on the Canvas, which is a native canvas. Henceforth, we have used the native to create a text in Canvas.

In all our examples we never used , the reason is, recomposition will happen only when the state changes. Thus it is not necessary to use the in Jetpack Compose.

Github