In Jetpack Compose can I configure an animation to run with a constant velocity rather than a constant time?

369 Views Asked by At

In my example I am moving some game pieces around on a board. Sometimes the pieces are moving long distances and sometimes the pieces are moving short distances.

I am achieving the animation using animateOffsetAsState() and pass in my new offset like so:

val offset by animateOffsetAsState(
        targetValue = position.toOffset(squareSize),
        animationSpec = tween(200, easing = LinearEasing)
)

Each animation runs for 200 ms. So for short distances the piece moves really slowly and for long distances the piece moves really quick to cover the distance.

Is there some built in animation spec that I can use to define a velocity that I want to use rather than a time so that, regardless of distance, the animation is always running at the same velocity?

1

There are 1 best solutions below

5
On BEST ANSWER

Since velocity is t = x/v you can calculate which duration to pass to tween with LinearEasing as

private fun calculateDuration(
    targetValue: Offset,
    velocity: Float
): Int {
    val xPos = targetValue.x
    val yPos = targetValue.y

    val distance = sqrt(xPos * xPos + yPos + yPos)
    return (distance / velocity * 1000).toInt()
}

and create a state with constant speed

@Composable
fun animateConstantSpeedOffsetAsState(
    initialOffset: Offset = Offset.Zero,
    targetValue: Offset,
    velocity: Float,
    label: String = "OffsetAnimation",
    finishedListener: ((Offset) -> Unit)? = null
): State<Offset> {

    require(velocity > 0f)

    var previousOffset by remember {
        mutableStateOf(initialOffset)
    }

    val durationMillis by remember {
        mutableIntStateOf(calculateDuration(targetValue.minus(previousOffset), velocity))
    }.apply {
        val duration = calculateDuration(targetValue.minus(previousOffset), velocity)
        if (duration > 0) {
            this.intValue = duration
        }
    }

    previousOffset = targetValue

    val animationSpec = tween<Offset>(
        durationMillis = durationMillis,
        easing = LinearEasing
    )
    return animateValueAsState(
        targetValue,
        Offset.VectorConverter,
        animationSpec,
        label = label,
        finishedListener = {
            previousOffset = targetValue
            finishedListener?.invoke(it)
        }
    )
}

Red circles use animateAsSate while green circle uses constant speed

enter image description here

Demo

@Preview
@Composable
private fun AnimationVelocityTest() {

    var isClicked by remember {
        mutableStateOf(false)
    }

    val target1 = if (isClicked.not()) Offset.Zero
    else Offset(500f, 500f)

    val target2 = if (isClicked.not()) Offset.Zero
    else Offset(1000f, 1000f)


    val offset1 by animateOffsetAsState(
        targetValue = target1,
        animationSpec = tween(4000, easing = LinearEasing),
        label = ""
    )

    val offset2 by animateOffsetAsState(
        targetValue = target2,
        animationSpec = tween(4000, easing = LinearEasing),
        label = ""
    )

    val offset3 by animateConstantSpeedOffsetAsState(
        targetValue = target1,
        velocity = 250f,
    )

    val offset4 by animateConstantSpeedOffsetAsState(
        targetValue = target2,
        velocity = 250f,
    )

    Canvas(
        modifier = Modifier.fillMaxSize()
            .padding(20.dp)
            .clickable {
                isClicked = isClicked.not()
            }
    ) {
        drawCircle(
            color = Color.Red,
            radius = 50f,
            center = offset1
        )

        translate(top = 100f) {
            drawCircle(
                color = Color.Red,
                radius = 50f,
                style = Stroke(4.dp.toPx()),
                center = offset2
            )
        }

        translate(top = 200f) {
            drawCircle(
                color = Color.Green,
                radius = 50f,
                center = offset3
            )
        }

        translate(top = 300f) {
            drawCircle(
                color = Color.Green,
                radius = 50f,
                style = Stroke(4.dp.toPx()),
                center = offset4
            )
        }
    }
}

Another Demo

@Preview
@Composable
private fun AnimationVelocityTest2() {
    var isClicked by remember {
        mutableStateOf(false)
    }

    var dynamicTarget by remember {
        mutableStateOf(Offset.Zero)
    }

    LaunchedEffect(isClicked) {
        if (isClicked) {
            dynamicTarget = dynamicTarget.plus(Offset(100f, 100f))
        }
    }

    val offset by animateConstantSpeedOffsetAsState(
        targetValue = dynamicTarget,
        velocity = 100f,
    )

    Canvas(
        modifier = Modifier.fillMaxSize()
            .padding(20.dp)
            .clickable {
                isClicked = isClicked.not()
            }
    ) {

        drawRect(
            color = Color.Magenta,
            size = Size(100f, 100f),
            style = Stroke(4.dp.toPx()),
            topLeft = offset
        )
    }
}