androidandroid-jetpack-composeandroid-jetpack-compose-animation

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


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?


Solution

  • 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
            )
        }
    }