Question

Creating compose smooth stopping rotation animation

I am trying to create a smooth stopping animation. When the user clicks on the icon it will rotate. Once its fetched from the Endpoint it will stop.

However, I am controlling this by resetting the angle to 0f. So when it stops it will jump to that 0f. This makes starting smooth as it will also start from 0f.

I am thinking the angle should be remembered so whatever angle its stops at that should be the angle it starts from again. Or is there a better way to handle starting and stopping?

@Composable
fun RatesStatus(
    ratesStatus: RateStatus,
    onRateRefreshClicked: () -> Unit,
    isFetchingNewRates: Boolean = false
) {

    val infiniteTransition = rememberInfiniteTransition()
    val angle by infiniteTransition.animateFloat(
        initialValue = 0F,
        targetValue = -360F,
        animationSpec = infiniteRepeatable(
            animation = tween(2000, easing = LinearEasing)
        )
    )

    Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Row {
            Image(
                modifier = Modifier.size(50.dp),
                painter = painterResource(Res.drawable.exchange_illustration),
                contentDescription = "Exchange rate illustration"
            )

            Spacer(modifier = Modifier.width(12.dp))

            Column {
                Text(
                    text = displayCurrentDateTime(),
                    color = Color.White
                )
                Text(
                    text = stringResource(ratesStatus.title),
                    color = ratesStatus.color,
                    fontSize = MaterialTheme.typography.bodySmall.fontSize
                )
            }
        }

        IconButton(
            onClick = onRateRefreshClicked
        ) {
            Icon(
                modifier = Modifier
                    .size(24.dp)
                    .graphicsLayer {
                        this.rotationZ = if(isFetchingNewRates) angle else 0f
                    },
                painter = painterResource(Res.drawable.refresh_ic),
                contentDescription = stringResource(Res.string.start_refresh),
                tint = staleColor
            )
        }
    }
}

enter image description here

 2  122  2
1 Jan 1970

Solution

 2

One way to do this is using Animatable and keeping animation in loop and when data is fetched or you need to stop call another animation. And duration should be 360f-current.angle to have uncut motion.

If animation stops at 180 degrees stop animation should run for 1000ms instead of initial duration for instance.

Result

enter image description here

Class that keeps Animatable and hold logic

class RotateAnimationState(
    val coroutineScope: CoroutineScope,
    val duration: Int
) {
    val angle: Float
        get() = animatable.value

    private val animatable = Animatable(0f)
    private val durationPerAngle = duration / 360f

    var rotationStatus: RotationStatus = RotationStatus.Idle

    fun start() {
        if(rotationStatus == RotationStatus.Idle){
            coroutineScope.launch {
                rotationStatus = RotationStatus.Rotating

                while (isActive && rotationStatus == RotationStatus.Rotating) {
                    animatable.animateTo(
                        targetValue = 360f,
                        animationSpec = tween(
                            durationMillis = duration,
                            easing = LinearEasing
                        )
                    )

                    yield()

                    if (rotationStatus == RotationStatus.Rotating) {
                        animatable.snapTo(0f)
                    }
                }
            }
        }
    }

    fun stop() {
        if (rotationStatus == RotationStatus.Rotating){
            coroutineScope.launch {
                rotationStatus = RotationStatus.Stopping
                val currentValue = animatable.value
                // Duration depends on how far current angle is to 360f
                // total duration is duration per angle multiplied with total angles to rotate
                val durationToZero = (durationPerAngle * (360 - currentValue)).toInt()
                animatable.snapTo(currentValue)
                animatable.animateTo(
                    targetValue = 360f,
                    tween(
                        durationMillis = durationToZero,
                        easing = LinearEasing
                    )
                )
                animatable.snapTo(0f)
                rotationStatus = RotationStatus.Idle
            }
        }
    }
}

enum class RotationStatus {
    Idle, Rotating, Stopping
}

Demo

@Preview
@Composable
fun RotationAnimationTest() {
    val coroutineScope = rememberCoroutineScope()

    val rotateAnimationState = remember {
        RotateAnimationState(
            coroutineScope = coroutineScope,
            duration = 2000
        )
    }

    Column(
        modifier = Modifier.fillMaxSize()
    ) {

        Row(
            modifier = Modifier.fillMaxWidth().padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text("Text1")
            Column {
                Canvas(
                    modifier = Modifier.size(100.dp).rotate(rotateAnimationState.angle)
                        .border(2.dp, Color.Green)
                ) {

                    drawLine(
                        start = center,
                        end = Offset(center.x, 0f),
                        color = Color.Red,
                        strokeWidth = 4.dp.toPx()
                    )
                }

                Spacer(Modifier.height(16.dp))
                Icon(
                    modifier = Modifier
                        .size(60.dp)
                        .graphicsLayer {
                            rotationZ = rotateAnimationState.angle
                        },
                    imageVector = Icons.Default.ScreenRotation,
                    contentDescription = null
                )
            }
            Text("Text2")
        }

        Spacer(Modifier.height(16.dp))
        Text("Angle: ${rotateAnimationState.angle.toInt()}, status: ${rotateAnimationState.rotationStatus}")

        Button(
            onClick = {
                rotateAnimationState.start()
            }
        ) {
            Text("Start")
        }

        Button(
            onClick = {
                rotateAnimationState.stop()
            }
        ) {
            Text("Stop")
        }
    }
}
2024-07-19
Thracian

Solution

 1

I think you can remember the last angle and continue from there. You can use a MutableState to hold the current angle and update it as the animation progresses. First create a MutableState to hold the current angle. Then update this state during the animation. Finally use this state to set the initial value of the animation when it restarts.

@Composable
fun RatesStatus(
    ratesStatus: RateStatus,
    onRateRefreshClicked: () -> Unit,
    isFetchingNewRates: Boolean = false
) {
    // State to hold the current angle
    var currentAngle by remember { mutableStateOf(0f) }

    // Infinite transition to animate the rotation
    val infiniteTransition = rememberInfiniteTransition()
    val angle by infiniteTransition.animateFloat(
        initialValue = currentAngle,
        targetValue = currentAngle - 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(2000, easing = LinearEasing)
        )
    )

    // Update the current angle if fetching new rates
    if (isFetchingNewRates) {
        currentAngle = angle
    }

    Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Row {
            Image(
                modifier = Modifier.size(50.dp),
                painter = painterResource(Res.drawable.exchange_illustration),
                contentDescription = "Exchange rate illustration"
            )

            Spacer(modifier = Modifier.width(12.dp))

            Column {
                Text(
                    text = displayCurrentDateTime(),
                    color = Color.White
                )
                Text(
                    text = stringResource(ratesStatus.title),
                    color = ratesStatus.color,
                    fontSize = MaterialTheme.typography.bodySmall.fontSize
                )
            }
        }

        IconButton(
            onClick = onRateRefreshClicked
        ) {
            Icon(
                modifier = Modifier
                    .size(24.dp)
                    .graphicsLayer {
                        // Apply the angle only if fetching new rates, else use the last remembered angle
                        this.rotationZ = if (isFetchingNewRates) angle else currentAngle
                    },
                painter = painterResource(Res.drawable.refresh_ic),
                contentDescription = stringResource(Res.string.start_refresh),
                tint = staleColor
            )
        }
    }
}
2024-07-19
Archulan R