'Jetpack Compose "shortest" rotate animation
I was trying to do a compass in jetpack compose. But I faced a problem with animating it.
I have a @Composable that takes user phone rotation and rotate compass image in opposite direction. I use animateFloatAsState like this:
val angle: Float by animateFloatAsState(
targetValue = -rotation, \\ rotation is retrieved as argument
animationSpec = tween(
durationMillis = UPDATE_FREQUENCY, \\ rotation is retrieved with this frequency
easing = LinearEasing
)
)
Image(
modifier = Modifier.rotate(angle),
// rest of the code for image
)
Everything looks fine but the problem occurs when rotation is changed from 1 to 359 or in the opposite way. Animation doesn't rotate 2 degrees to the left but goes 358 degrees to the right which looks bad. Is there any way to make rotate animation that would use the shortest way?
Solution 1:[1]
I ended up doing this:
val (lastRotation, setLastRotation) = remember { mutableStateOf(0) } // this keeps last rotation
var newRotation = lastRotation // newRotation will be updated in proper way
val modLast = if (lastRotation > 0) lastRotation % 360 else 360 - (-lastRotation % 360) // last rotation converted to range [-359; 359]
if (modLast != rotation) // if modLast isn't equal rotation retrieved as function argument it means that newRotation has to be updated
{
val backward = if (rotation > modLast) modLast + 360 - rotation else modLast - rotation // distance in degrees between modLast and rotation going backward
val forward = if (rotation > modLast) rotation - modLast else 360 - modLast + rotation // distance in degrees between modLast and rotation going forward
// update newRotation so it will change rotation in the shortest way
newRotation = if (backward < forward)
{
// backward rotation is shorter
lastRotation - backward
}
else
{
// forward rotation is shorter (or they are equal)
lastRotation + forward
}
setLastRotation(newRotation)
}
val angle: Float by animateFloatAsState(
targetValue = -newRotation.toFloat(),
animationSpec = tween(
durationMillis = UPDATE_FREQUENCY,
easing = LinearEasing
)
)
So basically I remembered the last rotation and based on this when a new rotation comes in I check which way (forward or backward) is shorter and then use it to update the target value.
Solution 2:[2]
I assume you have (or can gain) access to the current value of the rotation (i.e., the current angle), store it.
Then,
val angle: Float by animateFloatAsState(
targetValue = if(rotation > 360 - rotation) {-(360 - rotation)} else rotation
animationSpec = tween(
durationMillis = UPDATE_FREQUENCY, \\ rotation is retrieved with this frequency
easing = LinearEasing
)
)
Image(
modifier = Modifier.rotateBy(currentAngle, angle), //Custom Modifier
// rest of the code for image
)
rotateBy is a custom modifier which should not be difficult to implement. Use the inbuilt rotate modifier to construct it. The logic will remain the same
Solution 3:[3]
I managed to solve this problem by converting the heading to its sine and cosine, and interpolating those. This will interpolate correctly using the shortest rotation.
To achieve this, I created an implementation of the TwoWayConverter that Compose uses to transform values to an AnimationVector. As I already mentioned, I transform the degree value to a 2D vector composed of the sine and cosine. From them, I return back to degrees using the inverse tangent function.
val Float.Companion.DegreeConverter
get() = TwoWayConverter<Float, AnimationVector2D>({
val rad = (it * Math.PI / 180f).toFloat()
AnimationVector2D(sin(rad), cos(rad))
}, {
((atan2(it.v1, it.v2) * 180f / Math.PI).toFloat() + 360) % 360
})
After that, you can animate the rotation value as:
val animatedHeading by animateValueAsState(heading, Float.DegreeConverter)
The only thing is, that since the sine and cosine of the angle are animated, the transition is I think not linear by default, and any animationSpec defined in the animate function may not behave exactly as it should.
Solution 4:[4]
@Composable
private fun smoothRotation(rotation: Float): MutableState<Float> {
val storedRotation = remember { mutableStateOf(rotation) }
// Sample data
// current angle 340 -> new angle 10 -> diff -330 -> +30
// current angle 20 -> new angle 350 -> diff 330 -> -30
// current angle 60 -> new angle 270 -> diff 210 -> -150
// current angle 260 -> new angle 10 -> diff -250 -> +110
LaunchedEffect(rotation){
snapshotFlow { rotation }
.collectLatest { newRotation ->
val diff = newRotation - storedRotation.value
val shortestDiff = when{
diff > 180 -> diff - 360
diff < -180 -> diff + 360
else -> diff
}
storedRotation.value = storedRotation.value + shortestDiff
}
}
return storedRotation
}
This is my code
val rotation = smoothRotation(-state.azimuth)
val animatedRotation by animateFloatAsState(
targetValue = rotation.value,
animationSpec = tween(
durationMillis = 400,
easing = LinearOutSlowInEasing
)
)
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | iknow |
| Solution 2 | |
| Solution 3 | cvb941 |
| Solution 4 |
