Mervin Hemaraju
Mervin Hemaraju

Reputation: 2187

Jetpack compose animateFloat not updating animation speed

I am trying to increment an animation speed on every page refresh, but it's not working as expected. Below is my code:

@Composable
fun ScreenReport(
) {

var isRefreshing by remember { mutableStateOf(false) }
val ptrState = rememberPullToRefreshState()
var animationSpeed by remember { mutableIntStateOf(1000) }
val infiniteTransition = rememberInfiniteTransition(label = "")
val angle by infiniteTransition.animateFloat(
    initialValue = 0F,
    targetValue = 360F,
    animationSpec = infiniteRepeatable(
        animation = tween(animationSpeed, easing = LinearEasing)
    ), label = ""
)

Box(
    modifier = Modifier
        .fillMaxSize()
        .padding(bottom = 8.dp, top = 16.dp)
        .background(MaterialTheme.colorScheme.background)
) {
    PullToRefreshBox(
        modifier = Modifier.padding(8.dp),
        state = ptrState,
        isRefreshing = isRefreshing,
        onRefresh = {
        isRefreshing = true
        coroutineScope.launch {
            animationSpeed += 1000
            isRefreshing = false
        }
    },
    ) {
        Column(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .verticalScroll(scrollState)
        ) {

                Log.i("TESTING", animationSpeed.toString())

                MesIcon(
                    painterResource = R.drawable.ic_cyclone,
                    tint = MaterialTheme.colorScheme.secondary,
                    modifier = Modifier
                        .align(Alignment.CenterHorizontally)
                        .graphicsLayer {
                            rotationZ = angle
                        }
                )

// More code here that's not relevant


}

The Log that i've added shows that the animation speed is updating each time but the rotation speed on the image is not changing at all.

Can someone help me understand why ?

Upvotes: 0

Views: 55

Answers (1)

Thracian
Thracian

Reputation: 67248

The reason it happens because TransitionAnimationState is created once inside remember and does not change based on any other param in source code. If you debug the line 248 in source code you can see that it's not re-instantiated after changing animationSpec because remember has no keys to reset with.

@Composable
public fun <T, V : AnimationVector> InfiniteTransition.animateValue(
    initialValue: T,
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: InfiniteRepeatableSpec<T>,
    label: String = "ValueAnimation"
): State<T> {
    val transitionAnimation = remember {
        TransitionAnimationState(initialValue, targetValue, typeConverter, animationSpec, label)
    }
   // Rest of the code
}

Instead of this you can use Animatable that you can use to animate with different animationSpec each time you change animation duration in LaunchedEffect in a loop to create infinite transition.

What you pass to tween is not animation speed it's animation duration. If you wish to make it spin faster you need to make that value smaller.

But important thing with Animatable is when an animation is interrupted it starts from current value to target with same easing which means if it was supposed to take 1 second from 0 degrees to 360 degrees and when you interrupt at 300 degrees it would again take 1 second to reach 360 which makes animation after interruption slower. Because of that you can adjust first animation duration based on how close to 360 degrees.

@Preview
@Composable
fun ScreenReport() {

    var isRefreshing by remember { mutableStateOf(false) }
    val ptrState = rememberPullToRefreshState()
    var animationDuration by remember { mutableIntStateOf(2000) }

    val animatable = remember {
        Animatable(0f)
    }

    LaunchedEffect(animationDuration) {
        while (isActive) {

            val currentValue = animatable.value

            animatable.animateTo(
                targetValue = 360f,
                animationSpec = tween((animationDuration * (360f - currentValue) / 360f).toInt(), easing = LinearEasing)
            )
            if (animatable.value >= 360f) {
                animatable.snapTo(targetValue = 0f)
            }
        }
    }


    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(bottom = 8.dp, top = 16.dp)
            .background(MaterialTheme.colorScheme.background),
        contentAlignment = Alignment.Center
    ) {
        PullToRefreshBox(
            modifier = Modifier
                .fillMaxSize()
                .padding(8.dp),
            state = ptrState,
            isRefreshing = isRefreshing,
            onRefresh = {
                isRefreshing = true
                animationDuration += 2000
                isRefreshing = false
            },
        ) {
            Column(
                modifier = Modifier
                    .align(Alignment.TopCenter)
                    .verticalScroll(rememberScrollState())
            ) {

                Icon(
                    imageVector = Icons.Filled.Star,
                    modifier = Modifier
                        .align(Alignment.CenterHorizontally)
                        .size(100.dp)
                        .graphicsLayer {
                            rotationZ = animatable.value
                        },
                    contentDescription = null
                )


                Text(
                    "angle: ${animatable.value.toInt()}\n" +
                            "animationDuration: $animationDuration"
                )

            }
        }
    }
}

Another sample to show difference between default behavior which animates with same duration from the value to target value and adjusting duration based on current value's distance to target value when animation is interrupted.

enter image description here

@Preview
@Composable
fun InfiniteRotationInterruptionSample() {

    var animationDuration by remember { mutableIntStateOf(2000) }

    val animatable = remember {
        Animatable(0f)
    }

    val animatable2 = remember {
        Animatable(0f)
    }

    LaunchedEffect(animationDuration) {
        while (isActive) {
            try {
                animatable.animateTo(
                    targetValue = 360f,
                    animationSpec = tween(animationDuration, easing = LinearEasing)
                )
            } catch (e: CancellationException) {
                println("Animation canceled with: $e")
            }

            if (animatable.value >= 360f) {
                animatable.snapTo(targetValue = 0f)
            }
        }
    }

    LaunchedEffect(animationDuration) {
        while (isActive) {
            val currentValue = animatable2.value
            try {
                animatable2.animateTo(
                    targetValue = 360f,
                    animationSpec = tween((animationDuration * (360f - currentValue) / 360f).toInt(), easing = LinearEasing)
                )

            } catch (e: CancellationException) {
                println("Animation2 canceled with: $e")
            }

            if (animatable2.value >= 360f) {
                animatable2.snapTo(targetValue = 0f)
            }
        }
    }

    Column(
        verticalArrangement = Arrangement.spacedBy(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text("Default Animatable Behaviour", fontSize = 24.sp)
        Canvas(
            modifier = Modifier.size(100.dp).rotate(animatable.value)
                .border(2.dp, Color.Green, CircleShape)
        ) {

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

        Text(
            "animatable2: ${animatable.value.toInt()}\n" +
                    "animationDuration: $animationDuration"
        )

        Text("Adjust duration after Interruption", fontSize = 24.sp)

        Canvas(
            modifier = Modifier.size(100.dp).rotate(animatable2.value)
                .border(2.dp, Color.Green, CircleShape)
        ) {

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

        Text(
            "animatable: ${animatable2.value.toInt()}\n" +
                    "animationDuration: $animationDuration"
        )

        Button(
            modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
            onClick = {
                animationDuration += 4000
            }
        ) {
            Text("Change duration")
        }
    }
}

Upvotes: 1

Related Questions