'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