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 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.