Will UpdateTransition animation maintain its running velocity when if being changed to new animation?

Viewed 173

I create the same animation using AnimationAsState, Animatable, and UpdateTransition and try them out.

All the animations are using both moving forward or backward.

    tween(
        durationMillis = 3000,
        easing = LinearOutSlowInEasing
    )

We can see it below

enter image description here

If the animation is performed halfway through, and I click the button again, I would assume the running animation velocity is maintained and passed on to the subsequent animation.

This seems correct for AnimateAsState and Animatable. However, to my surprise, the UpdateTransition doesn't seem to behave that way. I guess it got resumed to the default animation duration (which runs really fast).

We can see as below.

enter image description here

My question is, is it

  1. expected that UpdateTransition won't retain the running animation spec if it get cancel halfway by a subsequent animation?
  2. I miss something on my code (I share the entire code below)?
  3. This is an UpdateTransition bug, and need to be reported to Google?

The entire working code for the above animation as below

import androidx.compose.animation.core.*
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Composable
fun Combination() {
    var enabled by remember { mutableStateOf(false) }

    val dbAnimateAsState: Dp by animateDpAsState(
        targetValue = switch(enabled),
        animationSpec = animationSpec()
    )

    val dbAnimatable = remember { Animatable(0.dp) }

    val transition = updateTransition(enabled, label = "")
    val dbTransition by transition.animateDp(
        transitionSpec = { animationSpec() }, label = "") {
        switch(it)
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text("AnimateAsState")
        animateBoxHorizontal(dbAnimateAsState)
        Text("Animatable")
        animateBoxHorizontal(dbAnimatable.value)
        Text("UpdateTransition")
        animateBoxHorizontal(dbTransition)

        Button(onClick = { enabled = !enabled }) {
            Text("Click Me")
        }
    }

    LaunchedEffect(key1 = enabled) {
        dbAnimatable.animateTo(
            targetValue = switch(enabled),
            animationSpec = animationSpec()
        )
    }
}

private fun animationSpec(): TweenSpec<Dp> =
    tween(
        durationMillis = 3000,
        easing = LinearOutSlowInEasing
    )

private fun switch(enabled: Boolean) = if (enabled) 268.dp else 0.dp

fun Animatable(initialValue: Dp) = Animatable(
    initialValue,
    DpToVector,
)

private val DpToVector: TwoWayConverter<Dp, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.value) }, { it.value.dp })

@Composable
private fun animateBoxHorizontal(dbAnimateAsState: Dp) {
    Box(
        modifier = Modifier
            .height(32.dp)
            .width(300.dp)
            .background(Color.Yellow)
    ) {
        Box(
            modifier = Modifier
                .offset(dbAnimateAsState, 0.dp)
                .size(32.dp)
                .background(Color.Red)
        )
    }
    Spacer(modifier = Modifier.height(16.dp))
}

Note: Updated Info

If I change from tweenSpec to springSpec i.e.

    tween(
        durationMillis = 3000,
        easing = LinearOutSlowInEasing
    )

to

    spring(stiffness =20f, dampingRatio = 0.25f)

Then updateTransition works as usual, where the Spring velocity and animation are preserved and continuous when we interrupt the animation with a new one.

2 Answers

updateTransition does still preserve velocity - it might be more obvious if you could slow it down. It switches to a spring when interrupted, whereas animateAsState and Animatable uses the AnimationSpec that you provided. That fallback spring is likely a bit too stiff, therefore it's being perceived as a very fast change.

The intention for this design is to allow Transition to accommodate interruption when using different types of AnimationSpecs. For example, going from state A to state B, you may be using keyFrames, going from state B to state C or may be using snap() (since the values are the same in state B & C). So when you interrupt the animation from A -> B with C as the new destination, both keyframes and snap would look very strange. That's why we fallback to spring when a Transition is interrupted.

Note if you already use a spring for your animation in Transition, that spring will be used for handling interruption in that animation since it's more relevant to your animation. That's why when you supply a low-stiffness spring to the Transition in the snippet above, you see a much slower "course correction" for the animation.

We do plan to support customization for the interruption handling, where you can specify what AnimationSpec to use when interrupted. That's planned as future work. If you have any specific requirement on how interruption should be handled, or that the fallback spring should be a little slower, please feel free to file a feature request. :)

After more investigation, apparently, this is by design.

If we check the Google Transition class code of class Transition<S> @PublishedApi internal constructor

We can see

 val spec = if (isInterrupted) {
                // When interrupted, use the default spring, unless the spec is also a spring.
                if (animationSpec is SpringSpec<*>) animationSpec else interruptionSpec
            } else {
                animationSpec
            }

And

interruptionSpec = spring(visibilityThreshold = visibilityThreshold)

So the interruptionSpec is set to Spring, and if the user is providing a Spring, it get preserved. If not it will be set to spring(visibilityThreshold = visibilityThreshold).

Related