Jetpack Compose Arc/Circular Progress Bar Animation (How to restart animation)

1.7k Views Asked by At

How do I create a Arc Progress bar animation like this

animated progress

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.

[My current implementation]

My current implementation

// 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!

2

There are 2 best solutions below

1
On BEST ANSWER

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
            }
        )
    }
}

enter image description here enter image description here

3
On

I made some changes in your code to utilize Animatable so we always snap to the beginning before animating to our target value. We also eliminated the computation here since we just want to fill the entire progress every time the score updates, in our case to 300 (limitAngle) and used the newScore state as a key in the LaunchedEffect to trigger the animation every time it increments. Don't mind the +30 increments, its just an arbitrary value that you can change without affecting the animation.

@Composable
fun ArcProgressbar(
    modifier: Modifier = Modifier,
    newScore: Float,
    level: String,
    startAngle : Float = 120f,
    limitAngle: Float = 300f,
    thickness: Dp = 8.dp
) {

    val animateValue = remember { Animatable(0f) }

    LaunchedEffect(newScore) {
        if (newScore > 0f) {
            animateValue.snapTo(0f)
            delay(10)
            animateValue.animateTo(
                targetValue = limitAngle,
                animationSpec = tween(
                    durationMillis = 1000
                )
            )
        }
    }

    Box(modifier = modifier.fillMaxWidth()) {

        Canvas(
            modifier = Modifier
                .fillMaxWidth(0.45f)
                .padding(10.dp)
                .aspectRatio(1f)
                .align(Alignment.Center),
            onDraw = {
                // Background Arc
                drawArc(
                    color = Color.Gray,
                    startAngle = startAngle,
                    sweepAngle = limitAngle,
                    useCenter = false,
                    style = Stroke(thickness.toPx(), cap = StrokeCap.Square),
                    size = Size(size.width, size.height)
                )

                // Foreground Arc
                drawArc(
                    color = Color.Green,
                    startAngle = startAngle,
                    sweepAngle = animateValue.value,
                    useCenter = false,
                    style = Stroke(thickness.toPx(), cap = StrokeCap.Square),
                    size = Size(size.width, size.height)
                )
            }
        )

        Column {
            Text(
                text = level,
                modifier = Modifier
                    .fillMaxWidth(0.125f)
                    .offset(y = (-10).dp),
                color = Color.Gray,
                fontSize = 82.sp
            )

            Text(
                text = "LEVEL",
                modifier = Modifier
                    .padding(bottom = 8.dp),
                color = Color.Gray,
                fontSize = 20.sp
            )

            Text(
                text = "Score ( $newScore ) ",
                modifier = Modifier
                    .padding(bottom = 8.dp),
                color = Color.Gray,
                fontSize = 20.sp
            )
        }
    }
}

Sample usage:

@Composable
fun ScoreGenerator() {

    var newScore by remember {
        mutableStateOf(0f)
    }

    Column {
        Button(onClick = {
            newScore += 30f
        }) {
            Text("Add Score + 30")
        }

        ArcProgressbar(
            newScore = newScore,
            level = ""
        )
    }
}

enter image description here