Hello Folks,
- Jetpack Compose is seriously taking over, and it’s only getting bigger! Today, we’re about to create something awesome — our own custom gauge speedometer using canvas in Compose. Cool, right?
- Let’s jump in and design this whole thing from the ground up! 🔥How are we going to do that?
Alright, let’s get into it! To create a custom needle in Jetpack Compose, there are two ways you can roll:
- Canvas Magic: Draw the needle directly using
Canvas
—completely custom! - Image Spin: Use an image as the needle, find its center, and rotate it based on the percentage.
In this blog, we’re sticking with the custom needle using the canvas approach, but don’t worry — I’ll also share the code for the image method, where you can lock in the needle’s center point to keep it steady during rotation.
So, how do we build the needle using canvas? We’ll create a Path for the needle right inside the canvas.
Let’s jump into the code.
Canvas(modifier = Modifier.fillMaxSize()) {
val sweepAngle = 240f
val height = size.height
val width = size.width
val startAngle = 150f
val centerOffset = Offset(width / 2f, height / 2.09f)
drawCircle(Color.White, 24f, centerOffset)
// Calculate needle angle based on inputValue
val needleAngle = (meterValue / 100f) * sweepAngle + startAngle
val needleLength = 160f // Adjust this value to control needle length
val needleBaseWidth = 10f // Adjust this value to control the base width
val needlePath = Path().apply {
// Calculate the top point of the needle
val topX = centerOffset.x + needleLength * cos(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
val topY = centerOffset.y + needleLength * sin(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
// Calculate the base points of the needle
val baseLeftX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseLeftY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseRightX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
val baseRightY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
moveTo(topX, topY)
lineTo(baseLeftX, baseLeftY)
lineTo(baseRightX, baseRightY)
close()
}
drawPath(
color = Color.White,
path = needlePath
)
}
- If you copy and paste this code, this is how it’s going to look like
Now let’s give this needle some flair with a background and throw in a light gradient to make it pop!
Canvas(modifier = Modifier.fillMaxSize()) {
val sweepAngle = 240f
val height = size.height
val width = size.width
val startAngle = 150f
val centerOffset = Offset(width / 2f, height / 2.09f)
drawCircle(
Brush.radialGradient(
listOf(
innerGradient.copy(alpha = 0.2f),
Color.Transparent
)
), width / 2f
)
drawCircle(Color.White, 24f, centerOffset)
// Calculate needle angle based on inputValue
val needleAngle = (meterValue / 100f) * sweepAngle + startAngle
val needleLength = 160f // Adjust this value to control needle length
val needleBaseWidth = 10f // Adjust this value to control the base width
val needlePath = Path().apply {
// Calculate the top point of the needle
val topX = centerOffset.x + needleLength * cos(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
val topY = centerOffset.y + needleLength * sin(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
// Calculate the base points of the needle
val baseLeftX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseLeftY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseRightX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
val baseRightY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
moveTo(topX, topY)
lineTo(baseLeftX, baseLeftY)
lineTo(baseRightX, baseRightY)
close()
}
drawPath(
color = Color.White,
path = needlePath
)
}
- After adding a background with
drawCircle
, it’s going to look something like the image below.
Job Offers
Now let’s give this needle some flair with a background and throw in a light gradient to make it pop!
Canvas(modifier = Modifier.fillMaxSize()) {
val sweepAngle = 240f
val height = size.height
val width = size.width
val startAngle = 150f
val centerOffset = Offset(width / 2f, height / 2.09f)
drawCircle(
Brush.radialGradient(
listOf(
innerGradient.copy(alpha = 0.2f),
Color.Transparent
)
), width / 2f
)
drawCircle(Color.White, 24f, centerOffset)
// Calculate needle angle based on inputValue
val needleAngle = (meterValue / 100f) * sweepAngle + startAngle
val needleLength = 160f // Adjust this value to control needle length
val needleBaseWidth = 10f // Adjust this value to control the base width
val needlePath = Path().apply {
// Calculate the top point of the needle
val topX = centerOffset.x + needleLength * cos(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
val topY = centerOffset.y + needleLength * sin(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
// Calculate the base points of the needle
val baseLeftX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseLeftY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseRightX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
val baseRightY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
moveTo(topX, topY)
lineTo(baseLeftX, baseLeftY)
lineTo(baseRightX, baseRightY)
close()
}
drawPath(
color = Color.White,
path = needlePath
)
}
- After adding a background with
drawCircle
, it’s going to look something like the image below.
- We have added our two arcs, 1 arc with a full swipe, and the other based on our percentage value.
This is what our final code will look like.
@Composable
fun ProtectionMeter(
modifier: Modifier = Modifier,
inputValue: Int,
trackColor: Color = Color(0xFFE0E0E0),
progressColors: List<Color>,
innerGradient: Color,
percentageColor: Color = Color.White
) {
val meterValue = getMeterValue(inputValue)
Box(modifier = modifier.size(196.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
val sweepAngle = 240f
val fillSwipeAngle = (meterValue / 100f) * sweepAngle
val height = size.height
val width = size.width
val startAngle = 150f
val arcHeight = height - 20.dp.toPx()
drawArc(
color = trackColor,
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset((width - height + 60f) / 2f, (height - arcHeight) / 2f),
size = Size(arcHeight, arcHeight),
style = Stroke(width = 50f, cap = StrokeCap.Round)
)
drawArc(
brush = Brush.horizontalGradient(progressColors),
startAngle = startAngle,
sweepAngle = fillSwipeAngle,
useCenter = false,
topLeft = Offset((width - height + 60f) / 2f, (height - arcHeight) / 2),
size = Size(arcHeight, arcHeight),
style = Stroke(width = 50f, cap = StrokeCap.Round)
)
val centerOffset = Offset(width / 2f, height / 2.09f)
drawCircle(
Brush.radialGradient(
listOf(
innerGradient.copy(alpha = 0.2f),
Color.Transparent
)
), width / 2f
)
drawCircle(Color.White, 24f, centerOffset)
// Calculate needle angle based on inputValue
val needleAngle = (meterValue / 100f) * sweepAngle + startAngle
val needleLength = 160f // Adjust this value to control needle length
val needleBaseWidth = 10f // Adjust this value to control the base width
val needlePath = Path().apply {
// Calculate the top point of the needle
val topX = centerOffset.x + needleLength * cos(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
val topY = centerOffset.y + needleLength * sin(
Math.toRadians(needleAngle.toDouble()).toFloat()
)
// Calculate the base points of the needle
val baseLeftX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseLeftY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle - 90).toDouble()).toFloat()
)
val baseRightX = centerOffset.x + needleBaseWidth * cos(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
val baseRightY = centerOffset.y + needleBaseWidth * sin(
Math.toRadians((needleAngle + 90).toDouble()).toFloat()
)
moveTo(topX, topY)
lineTo(baseLeftX, baseLeftY)
lineTo(baseRightX, baseRightY)
close()
}
drawPath(
color = Color.White,
path = needlePath
)
}
Column(
modifier = Modifier
.padding(bottom = 5.dp)
.align(Alignment.BottomCenter), horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "$inputValue %", fontSize = 20.sp, lineHeight = 28.sp, color = percentageColor)
Text(text = "Percentage", fontSize = 16.sp, lineHeight = 24.sp, color = Color(0xFFB0B4CD))
}
}
}
private fun getMeterValue(inputPercentage: Int): Int {
return if (inputPercentage < 0) {
0
} else if (inputPercentage > 100) {
100
} else {
inputPercentage
}
}
- This is what our final product will look like.
- We’ve wrapped up the logic in the
getMeterValue
function, ensuring that the swipe stays within the limits. You can easily customize the arc’s height, width, and color—everything is set up for you.
If you have any questions, just drop a comment, and I’ll get back to you ASAP. We’ll dive deeper into Jetpack Compose soon. Until then, happy coding!
This article is previously published on proandroiddev.com