androidandroid-jetpack-composeandroid-progressbarcompose-recompositionjetpack-compose-animation

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


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!


Solution

  • 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