Canvas Made Easy In Jetpack Compose

Jeffrey Rajan
8 min readMay 5, 2021

--

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 Path 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 canvas 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 300dp which was the width of our View. To move we will be using the path.moveTo() method with the position of x = 300dp and y = 0dp. Once it is moved, we need to draw a line 90° to the bottom at the position of x = 300dp and y = 150dp, where we will take the width and height of our Canvas view. Again, draw a line of 180° to the position of x = 0dp and y = 150dp.

Once the path is done, pass it to the drawPath 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 drawRect 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 clipPath method by passing the right angled triangle path. Next, we need to specify the type of clipOp that we intent to use (ClipOp.Intersect or ClipOp.Difference). If we try using the ClipOp as ClipOp.Difference, this will make the given path as transparent. But we wanted the opposite of it, so let’s use ClipOp.Intersect instead. This will allow us to draw only on the given path. Not to mention, remaining path will remain transparent and restrict us from drawing further. The above code will produce the below Canvas.

The above progress can be made dynamic by modifying the value 0.5f from the drawRect() method, but you will want to make sure to pass the value in a state and the value must be between 0f to 1.0f.

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 state 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 125, where the radius comes from the Canvas width of 250dp. In drawCircle method, we have used three parameters: Brush, radius and style. Brush will be used to provide a different kind of shaders to the paint like SolidColor, RadialGradient, LinearGradient etc. In style, we have two types of DrawStyle: Fill and Stroke. In our case we will be using the Stroke with a width of 35f.

Now, we will create a progress bar using drawArc(). Before getting into the drawArc, 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 mutableState.

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 startAngle from the drawArc() method, I have used the value as -90f instead of 90f. 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 sweepAngle we will be multiplying float value(0f to 1.0f) 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 drawArc() 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 startAngle as 120f and sweepAngle as 300f.

As we saw above, the startAngle will draw in a clockwise and sweepAngle 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 300f and 120f respectively. Those values are marked in the above image to show you a clear picture of sweep and start angle values. To provide a style we have used Stroke, in Stroke we have introduced a new parameter cap. If there is no cap 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 StrokeCap.Round.

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 sliderValue with 300. 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 state with 300 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 Compose library. For some options we still need to depend on the android.graphics Canvas, which is a native canvas. Henceforth, we have used the native drawText to create a text in Canvas.

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

Github

--

--

Responses (2)