I'm trying to implement a custom progress indicator in Jetpack Compose that looks like the attached image.
A rounded square with a custom icon inside.
When the loading starts, there should be a progress indicator around the border of the square, showing the progression of the task. Once the task is completed, the icon color should change.
I’ve tried combining CircularProgressIndicator and Box for the icon, but I’m having trouble with the following:
How to create a rounded square progress indicator (instead of a typical circular one). How to overlay the progress animation on top of the icon.
Could someone guide me on how to achieve this with Jetpack Compose?
Any help or code snippets would be greatly appreciated!
Here’s a visual example of what I’m trying to build:
Thank you!
You can use PathMeasure
and with getPathSegments you can get a segmented path based on current progress and draw RoundedRectangle inside DrawScope with a draw Modifier.
@Preview
@Composable
fun DrawProgressTest() {
val pathMeasure by remember { mutableStateOf(PathMeasure()) }
var progress by remember {
mutableStateOf(0f)
}
val path = remember {
Path()
}
val pathWithProgress by remember {
mutableStateOf(Path())
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
Text("Progress: ${progress.toInt()}")
Slider(
value = progress,
onValueChange = {
progress = it
},
valueRange = 0f..100f
)
Icon(
modifier = Modifier.size(64.dp).drawBehind {
if (path.isEmpty) {
path.addRoundRect(
RoundRect(
Rect(offset = Offset.Zero, size),
cornerRadius = CornerRadius(16.dp.toPx(), 16.dp.toPx())
)
)
pathMeasure.setPath(path, forceClosed = false)
}
pathWithProgress.reset()
pathMeasure.setPath(path, forceClosed = false)
pathMeasure.getSegment(
startDistance = 0f,
stopDistance = pathMeasure.length * progress / 100f,
pathWithProgress,
startWithMoveTo = true
)
drawPath(
path = path,
style = Stroke(
4.dp.toPx()
),
color = Color.Black
)
drawPath(
path = pathWithProgress,
style = Stroke(
4.dp.toPx()
),
color = Color.Blue
)
},
tint = if (progress == 100f) Color.Blue else Color.Black,
imageVector = Icons.Default.CarRental,
contentDescription = null
)
}
}
If you want segment to fill from another direction you can use rotate
or transform
inside DrawScope.
And if you want it to animate between big progress changes you can use Animatable
to animate between these values instead of instant change.
@Preview
@Composable
fun DrawProgressTest() {
val pathMeasure by remember { mutableStateOf(PathMeasure()) }
val path = remember {
Path()
}
val pathWithProgress by remember {
mutableStateOf(Path())
}
val animatable = remember {
Animatable(0f)
}
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
Text("Progress: ${animatable.value.toInt()}")
Slider(
value = animatable.value,
onValueChange = {
coroutineScope.launch {
animatable.animateTo(it)
}
},
valueRange = 0f..100f
)
Icon(
modifier = Modifier.size(128.dp)
.drawBehind {
if (path.isEmpty) {
path.addRoundRect(
RoundRect(
Rect(offset = Offset.Zero, size),
cornerRadius = CornerRadius(16.dp.toPx(), 16.dp.toPx())
)
)
pathMeasure.setPath(path, forceClosed = false)
}
pathWithProgress.reset()
pathMeasure.setPath(path, forceClosed = false)
pathMeasure.getSegment(
startDistance = 0f,
stopDistance = pathMeasure.length * animatable.value / 100f,
pathWithProgress,
startWithMoveTo = true
)
drawPath(
path = path,
style = Stroke(
4.dp.toPx()
),
color = Color.Black
)
drawPath(
path = pathWithProgress,
style = Stroke(
4.dp.toPx()
),
color = Color.Blue
)
},
tint = if (animatable.value == 100f) Color.Blue else Color.Black,
imageVector = Icons.Default.CarRental,
contentDescription = null
)
}
}
Also you can refer this answer how you can animate it with different time intervals.