How do I create a Arc Progress bar animation like this
Currently I've already used Canvas to draw an arc and added animations to the progress bar using animateFloatAsState API. But second pic is not my expected.
[]
// e.g. oldScore = 100f newScore = 350f
// Suppose 250 points are into one level
@Composable
fun ArcProgressbar(
modifier: Modifier = Modifier,
oldScore: Float,
newScore: Float,
level: String,
startAngle: Float = 120f,
limitAngle: Float = 300f,
thickness: Dp = 8.dp
) {
var value by remember { mutableStateOf(oldScore) }
val sweepAngle = animateFloatAsState(
targetValue = (value / 250) * limitAngle, // convert the value to angle
animationSpec = tween(
durationMillis = 1000
)
)
LaunchedEffect(Unit) {
delay(1500)
value = newScore
}
Box(modifier = modifier.fillMaxWidth()) {
Canvas(
modifier = Modifier
.fillMaxWidth(0.45f)
.padding(10.dp)
.aspectRatio(1f)
.align(Alignment.Center),
onDraw = {
// Background Arc
drawArc(
color = Gray100,
startAngle = startAngle,
sweepAngle = limitAngle,
useCenter = false,
style = Stroke(thickness.toPx(), cap = StrokeCap.Square),
size = Size(size.width, size.height)
)
// Foreground Arc
drawArc(
color = Green500,
startAngle = startAngle,
sweepAngle = sweepAngle.value,
useCenter = false,
style = Stroke(thickness.toPx(), cap = StrokeCap.Square),
size = Size(size.width, size.height)
)
}
)
Text(
text = level,
modifier = Modifier
.fillMaxWidth(0.125f)
.align(Alignment.Center)
.offset(y = (-10).dp),
color = Color.White,
fontSize = 82.sp
)
Text(
text = "LEVEL",
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.BottomCenter),
color = Color.White,
fontSize = 20.sp
)
}
}
How can I animate from start again if progress percentage over 100%, just like the one in the gif. Does anybody got some ideas? Thanks!
My first answer doesn't feel like doing any justice since it's far from the gif you posted which shows what you want.
So here's another one that closely resembles it. However, I feel like this implementation is not very efficient in terms of calling sequences of animations, but in terms of re-composition
I incorporated some optimization strategy called deferred reading, making sure only the composables that observes the values will be the only parts that will be re-composed. I left a Log
statement in the parent progress composable to verify it, the ArcProgressbar
is not updating unnecessarily when the progress is animating.
Log.e("ArcProgressBar", "Recomposed")
Full source code that you can copy-and-paste (preferably on a separate file) without any issues.
val maxProgressPerLevel = 200 // you can change this to any max value that you want
val progressLimit = 300f
fun calculate(
score: Float,
level: Int,
) : Float {
return (abs(score - (maxProgressPerLevel * level)) / maxProgressPerLevel) * progressLimit
}
@Composable
fun ArcProgressbar(
modifier: Modifier = Modifier,
score: Float
) {
Log.e("ArcProgressBar", "Recomposed")
var level by remember {
mutableStateOf(score.toInt() / maxProgressPerLevel)
}
var targetAnimatedValue = calculate(score, level)
val progressAnimate = remember { Animatable(targetAnimatedValue) }
val scoreAnimate = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(level, score) {
if (score > 0f) {
// animate progress
coroutineScope.launch {
progressAnimate.animateTo(
targetValue = targetAnimatedValue,
animationSpec = tween(
durationMillis = 1000
)
) {
if (value >= progressLimit) {
coroutineScope.launch {
level++
progressAnimate.snapTo(0f)
}
}
}
}
// animate score
coroutineScope.launch {
if (scoreAnimate.value > score) {
scoreAnimate.snapTo(0f)
}
scoreAnimate.animateTo(
targetValue = score,
animationSpec = tween(
durationMillis = 1000
)
)
}
}
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box {
PointsProgress(
progress = {
progressAnimate.value // deferred read of progress
}
)
CollectorLevel(
modifier = Modifier.align(Alignment.Center),
level = {
level + 1 // deferred read of level
}
)
}
CollectorScore(
modifier = Modifier.padding(top = 16.dp),
score = {
scoreAnimate.value // deferred read of score
}
)
}
}
@Composable
fun CollectorScore(
modifier : Modifier = Modifier,
score: () -> Float
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Collector Score",
color = Color.White,
fontSize = 16.sp
)
Text(
text = "${score().toInt()} PTS",
color = Color.White,
fontSize = 40.sp
)
}
}
@Composable
fun CollectorLevel(
modifier : Modifier = Modifier,
level: () -> Int
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier
.padding(top = 16.dp),
text = level().toString(),
color = Color.White,
fontSize = 82.sp
)
Text(
text = "LEVEL",
color = Color.White,
fontSize = 16.sp
)
}
}
@Composable
fun BoxScope.PointsProgress(
progress: () -> Float
) {
val start = 120f
val end = 300f
val thickness = 8.dp
Canvas(
modifier = Modifier
.fillMaxWidth(0.45f)
.padding(10.dp)
.aspectRatio(1f)
.align(Alignment.Center),
onDraw = {
// Background Arc
drawArc(
color = Color.LightGray,
startAngle = start,
sweepAngle = end,
useCenter = false,
style = Stroke(thickness.toPx(), cap = StrokeCap.Square),
size = Size(size.width, size.height)
)
// Foreground Arc
drawArc(
color = Color(0xFF3db39f),
startAngle = start,
sweepAngle = progress(),
useCenter = false,
style = Stroke(thickness.toPx(), cap = StrokeCap.Square),
size = Size(size.width, size.height)
)
}
)
}
Sample usage:
@Composable
fun PrizeProgressScreen() {
var score by remember {
mutableStateOf(0f)
}
var scoreInput by remember {
mutableStateOf("0")
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF6b4cba)),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier
.padding(vertical = 16.dp),
text = "Progress for every level up: $maxProgressPerLevel",
color = Color.LightGray,
fontSize = 16.sp
)
ArcProgressbar(
score = score,
)
Button(onClick = {
score += scoreInput.toFloat()
}) {
Text("Add Score")
}
TextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = scoreInput,
onValueChange = {
scoreInput = it
}
)
}
}