Drawing a custom speedometer on compose canvas in android
Recently i had to work on a implementating a speedometer with animation the needle. The speedometer deisgn looked like the below in figma.
Now, lets anyalyze what we need.
- We require a canvas where we draw the custom speedomter
- The speedometer arc should have rounded corners both the inner and outer.
- Each section should have its own color.
- Draw a needle at the center and point to a particular section. The animtaion starts from 0 to the current value.
Before we get into drawing on canvas, we need to understand the co-ordinate system. The top left corner of the screen represents 0,0. Lets say we have a co-ordinate 100,100. This means x is 100 pixels from the left of the screen and y is 100 pixels from the top of screen. A example to visualize
We need a compose function that takes 4 colors, 4 sections length and the actual progress value. Each section in the speedometer above has various length and its dynamic ( source is server).
@Composable
fun Speedometer(
modifier: Modifier = Modifier,
redProgress: Float,
greenProgress: Float,
yellowProgress: Float,
blueProgress: Float,
progress: Float,
colorDanger: Color = JKTheme.colors.WarningHigh,
colorStress: Color = JKTheme.colors.ColorSparkleLight50,
colorOptimum: Color = JKTheme.colors.Success50,
colorExcess: Color = JKTheme.colors.OptimumColor
) {
Canvas(
modifier = Modifier,
onDraw = {
drawIntoCanvas {
}
}
)
}
We need the center co-ordinate of the canvas to draw the needle and the arc’s. My inital approach was to draw arcs. Let see how that looks
The android canvas used cartesian co-ordiante system. Your 0 degree is on the right. 90 to the south and 180 towars the left. 270 at the top and back t0 360.
Now drawing we need to draw arc.
We need the center of the canvas. You can get the center by doing w/2 and h/2. w- refers to width and h- refers ot height.
To draw an arc we need the start angle and end angle. If you see the above screen shot it looks like a circle. Lets say you need to draw arc from 0 to 90, the start angle is 0 and end angle is 90. All the angles are in radians.
Lets look at the drawArc api
fun drawArc(
color: Color,
startAngle: Float,
sweepAngle: Float,
useCenter: Boolean,
topLeft: Offset = Offset.Zero,
size: Size = this.size.offsetSize(topLeft),
@FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
We need to pass a color, a start angle, sweep angle, boolean which says we use the center of the canvas, topleft offset repective to the canvas and a size offset. Style parameter can be a Stroke or Fill. Stroke will have certain stroke size and can be rounded caps.
So i used this
drawArc(
secondaryColor,
startArcAngle,
arcDegrees.toFloat(),
false,
topLeft = quarterOffset,
size = centerArcSize,
style = centerArcStroke
)
So the start and end will have rouded cap and the rest will not have the rounded cap style.
After implementing i ended up with the below. Ignore the markers and needle drawn foe now. If you see carefully we have 0 at 180 degress and because of the rounded cap style it goes beyond zero. You can also see the red slice below where i have tried customizing the drawing arc part.
Even after this we are not able to achieve what is given in the design.
What about paths. Each section having its own path. A rounded path with customized rounded edges at the start and end. This might work. Path also has arcTo where you can provide the start angle and sweep angle.
fun Path.addRoundedPolarBox(
center: Offset,
startAngleDegrees: Float,
sweepAngleDegrees: Float,
innerRadius: Float,
outerRadius: Float,
cornerRadius: Float
) {
val endAngleDegrees = startAngleDegrees + sweepAngleDegrees.toDouble()
val innerRadiusShift = innerRadius + cornerRadius.toDouble()
// the length of the arc at inner radius arc
val innerAngleShift = asin(cornerRadius / innerRadiusShift) * 180 / PI
val outerRadiusShift = outerRadius - cornerRadius.toDouble()
// the length of the arc at outer radius arc
val outerAngleShift = asin(cornerRadius / outerRadiusShift) * 180 / PI
// start inner arc no rounded it left bottom arc
arcTo(
rect = Rect(
center = Offset(
x = (center.x + innerRadiusShift * cos((startAngleDegrees + innerAngleShift) * PI / 180)).toFloat(),
y = (center.y + innerRadiusShift * sin((startAngleDegrees + innerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = startAngleDegrees - 90,
sweepAngleDegrees = (innerAngleShift - 90).toFloat(),
forceMoveTo = true
)
// arc from start to sweep minus the arc part ie start to sweep angle arc
arcTo(
rect = Rect(center = center, radius = innerRadius),
startAngleDegrees = (startAngleDegrees + innerAngleShift).toFloat(),
sweepAngleDegrees = (sweepAngleDegrees - innerAngleShift).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(center = center, radius = outerRadius),
startAngleDegrees = (endAngleDegrees).toFloat(),
sweepAngleDegrees = -(sweepAngleDegrees - 2 * outerAngleShift).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(
center = Offset(
x = (center.x + outerRadiusShift * cos((startAngleDegrees + outerAngleShift) * PI / 180)).toFloat(),
y = (center.y + outerRadiusShift * sin((startAngleDegrees + outerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = (startAngleDegrees + outerAngleShift).toFloat(),
sweepAngleDegrees = -(outerAngleShift + 90).toFloat(),
forceMoveTo = false
)
close()
}
fun Path.addRoundedPolarBoxAllSides(
center: Offset,
startAngleDegrees: Float,
sweepAngleDegrees: Float,
innerRadius: Float,
outerRadius: Float,
cornerRadius: Float
) {
val endAngleDegrees = startAngleDegrees + sweepAngleDegrees.toDouble()
val innerRadiusShift = innerRadius + cornerRadius.toDouble()
val innerAngleShift = asin(cornerRadius / innerRadiusShift) * 180 / PI
val outerRadiusShift = outerRadius - cornerRadius.toDouble()
val outerAngleShift = asin(cornerRadius / outerRadiusShift) * 180 / PI
arcTo(
rect = Rect(
center = Offset(
x = (center.x + innerRadiusShift * cos((startAngleDegrees + innerAngleShift) * PI / 180)).toFloat(),
y = (center.y + innerRadiusShift * sin((startAngleDegrees + innerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = startAngleDegrees - 90,
sweepAngleDegrees = (innerAngleShift - 90).toFloat(),
forceMoveTo = true
)
arcTo(
rect = Rect(center = center, radius = innerRadius),
startAngleDegrees = (startAngleDegrees + innerAngleShift).toFloat(),
sweepAngleDegrees = (sweepAngleDegrees - innerAngleShift).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(
center = Offset(
x = (center.x + innerRadiusShift * cos((endAngleDegrees - innerAngleShift) * PI / 180)).toFloat(),
y = (center.y + innerRadiusShift * sin((endAngleDegrees - innerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = (endAngleDegrees - innerAngleShift + 180).toFloat(),
sweepAngleDegrees = (innerAngleShift - 90).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(
center = Offset(
x = (center.x + outerRadiusShift * cos((endAngleDegrees - outerAngleShift) * PI / 180)).toFloat(),
y = (center.y + outerRadiusShift * sin((endAngleDegrees - outerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = (endAngleDegrees + 90).toFloat(),
sweepAngleDegrees = -(outerAngleShift + 90).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(center = center, radius = outerRadius),
startAngleDegrees = (endAngleDegrees).toFloat(),
sweepAngleDegrees = -(sweepAngleDegrees - 2 * outerAngleShift).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(
center = Offset(
x = (center.x + outerRadiusShift * cos((startAngleDegrees + outerAngleShift) * PI / 180)).toFloat(),
y = (center.y + outerRadiusShift * sin((startAngleDegrees + outerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = (startAngleDegrees + outerAngleShift).toFloat(),
sweepAngleDegrees = -(outerAngleShift + 90).toFloat(),
forceMoveTo = false
)
close()
}
fun Path.addRoundedEndBox(
center: Offset,
startAngleDegrees: Float,
sweepAngleDegrees: Float,
innerRadius: Float,
outerRadius: Float,
cornerRadius: Float
) {
val endAngleDegrees = startAngleDegrees + sweepAngleDegrees.toDouble()
val innerRadiusShift = innerRadius + cornerRadius.toDouble()
val innerAngleShift = asin(cornerRadius / innerRadiusShift) * 180 / PI
val outerRadiusShift = outerRadius - cornerRadius.toDouble()
val outerAngleShift = asin(cornerRadius / outerRadiusShift) * 180 / PI
arcTo(
rect = Rect(
center = Offset(
x = (center.x + innerRadiusShift * cos((startAngleDegrees) * PI / 180)).toFloat(),
y = (center.y + innerRadiusShift * sin((startAngleDegrees) * PI / 180)).toFloat()
),
radius = 0.1f
),
startAngleDegrees = startAngleDegrees,
sweepAngleDegrees = 90f,
forceMoveTo = true
)
arcTo(
rect = Rect(center = center, radius = innerRadius),
startAngleDegrees = startAngleDegrees,
sweepAngleDegrees = (sweepAngleDegrees - 2 * innerAngleShift).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(
center = Offset(
x = (center.x + innerRadiusShift * cos((endAngleDegrees - innerAngleShift) * PI / 180)).toFloat(),
y = (center.y + innerRadiusShift * sin((endAngleDegrees - innerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = (endAngleDegrees - innerAngleShift + 180).toFloat(),
sweepAngleDegrees = (innerAngleShift - 90).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(
center = Offset(
x = (center.x + outerRadiusShift * cos((endAngleDegrees - outerAngleShift) * PI / 180)).toFloat(),
y = (center.y + outerRadiusShift * sin((endAngleDegrees - outerAngleShift) * PI / 180)).toFloat()
),
radius = cornerRadius
),
startAngleDegrees = (endAngleDegrees + 90).toFloat(),
sweepAngleDegrees = -(outerAngleShift + 90).toFloat(),
forceMoveTo = false
)
arcTo(
rect = Rect(center = center, radius = outerRadius),
startAngleDegrees = (endAngleDegrees - innerAngleShift).toFloat(),
sweepAngleDegrees = -(sweepAngleDegrees - innerAngleShift).toFloat(),
forceMoveTo = false
)
close()
}
Above the screen shot of the drawn arcs using path. The difficult part to understand here in the innerAngleShift and the outerAngleShift which gives us that curve at the start as you can see is the red section and blue section.
Bit of trigonometry. It may be schools methematics but certainly i forgot and had to revist trignometry and had to do lot of writing on pen and paper with some trials changing the values to get the curves right.
Once i draw the red section i need to draw the yellow section. The end of red becomes the start of yellow. The end of yellow section becomes the start of green. Here we are referring to start and end offset.
Now our progress values are converted to radians which gives us the arc startAngle and startAngle + progress would give the actual slice of each section.
Putting it all together. The Speeodmeter code looks as below
@Composable
fun Speedometer(
modifier: Modifier = Modifier,
redProgress: Float,
greenProgress: Float,
yellowProgress: Float,
blueProgress: Float,
progress: Float,
showInfo: () -> Unit,
colorDanger: Color = JKTheme.colors.WarningHigh,
colorStress: Color = JKTheme.colors.ColorSparkleLight50,
colorOptimum: Color = JKTheme.colors.Success50,
colorExcess: Color = JKTheme.colors.OptimumColor
) {
BoxWithConstraints(
modifier = modifier
.height(130.dp)
.fillMaxWidth()
) {
val arcDegrees = 180f
val startArcAngle = 180f
val progressAnimation = remember {
Animatable(0f)
}
LaunchedEffect(progress) {
progressAnimation.animateTo(
targetValue = progress.toFloat(),
animationSpec = tween(durationMillis = 3000, easing = LinearEasing)
)
}
val innerRadius = 60.dp.dpToPx()
val outerRadius = 114.dp.dpToPx()
val cornerRadius = 12.dp.dpToPx()
var start = startArcAngle
val redPath = remember {
Path().apply {
addRoundedPolarBox(
center = Offset(constraints.maxWidth / 2f, constraints.maxHeight.toFloat()),
startAngleDegrees = start,
sweepAngleDegrees = redProgress * (arcDegrees) / 100f,
innerRadius = innerRadius,
outerRadius = outerRadius,
cornerRadius = cornerRadius
)
}
}
start += redProgress * (arcDegrees) / 100f
val yellow = remember {
Path().apply {
addRoundedPolarBoxAllSides(
center = Offset(constraints.maxWidth / 2f, constraints.maxHeight.toFloat()),
startAngleDegrees = start,
sweepAngleDegrees = yellowProgress * (arcDegrees) / 100f,
innerRadius = innerRadius,
outerRadius = outerRadius,
cornerRadius = 0.1f
)
}
}
start += yellowProgress * (arcDegrees) / 100f
val green = remember {
Path().apply {
addRoundedPolarBoxAllSides(
center = Offset(constraints.maxWidth / 2f, constraints.maxHeight.toFloat()),
startAngleDegrees = start,
sweepAngleDegrees = greenProgress * (arcDegrees) / 100f,
innerRadius = innerRadius,
outerRadius = outerRadius,
cornerRadius = 0.1f
)
}
}
start += greenProgress * (arcDegrees) / 100f
val blue = remember {
Path().apply {
addRoundedEndBox(
center = Offset(constraints.maxWidth / 2f, constraints.maxHeight.toFloat()),
startAngleDegrees = start,
sweepAngleDegrees = blueProgress * (arcDegrees) / 100f,
innerRadius = innerRadius,
outerRadius = outerRadius,
cornerRadius = cornerRadius
)
}
}
val vector = ImageVector.vectorResource(id = R.drawable.pointer_black)
val painter = rememberVectorPainter(image = vector)
Canvas(
modifier = Modifier,
onDraw = {
drawIntoCanvas {
val centerOffset =
Offset(constraints.maxWidth / 2f, constraints.maxHeight.toFloat())
drawPath(redPath, colorDanger)
drawPath(yellow, colorStress)
drawPath(green, colorOptimum)
drawPath(blue, colorExcess)
rotate(
progressAnimation.value * (arcDegrees) / 100f,
pivot = Offset(centerOffset.x, centerOffset.y)
) {
translate(
left = centerOffset.x - 111.dp.toPx(),
top = centerOffset.y - 11.dp.toPx()
) {
with(painter) {
draw(
size = Size(111.dp.toPx(), 22.dp.toPx())
)
}
}
}
}
}
)
}
}
The needle drawn is a vector drawable. The animation is impelmented using
val progressAnimation = remember {
Animatable(0f)
}
As and when the values changes from 0f to 1f it would reach 100 percentage. Intially the needly points at zero ie drawn from center to 180 degress.
Lets say progress is 50%. We use LauchEffect with the key as progress. As soon as the progess changes we need to animate the needle
LaunchedEffect(progress) {
progressAnimation.animateTo(
targetValue = progress.toFloat(),
animationSpec = tween(durationMillis = 3000, easing = LinearEasing)
)
}
I have used LinearEasing a default one with 3000 milliseconds. Adjust it to your needs.
Finally we end up creating a custom speedometer in compose by drawing everything on canvas and animating.
Conclusion : We can draw arcs, lines, squares, rectangles and triangles. For soem cases android provides convinient api’s to draw rectangles, circles and squared. For more complex ui we have canvas we have the paint you can create ui using some math formuals. We learnt about cartesian coordinate system and drawing on canvas. We accomplished the above using paths and arcs.