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