5

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?

iknow
  • 8,358
  • 12
  • 41
  • 68

4 Answers4

4

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.

iknow
  • 8,358
  • 12
  • 41
  • 68
3

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

Richard Onslow Roper
  • 5,477
  • 2
  • 11
  • 42
  • Maybe I wasn't clear in the question. `rotation` is an integer between `0` to `359`. It is a phone rotation relative to the North. So when I have this value I have to rotate the Compass image in the opposite direction, that's why I use `-rotation`. I tried Your code but it behaves weirdly. Where `estAngle` should be used? – iknow Jul 04 '21 at 14:51
  • If the rotation value, 358 in your case of 1 to 359, is greater than the other way round, i.e. 360 - value (or here, 360 - 358 = 2), then set the target value of the animation to the latter. The negative sign is because of the assumption that positive rotation spins clockwise while negative rotation spins counterclockwise. So whatever is shorter, we go that way with appropriate signs. I was thinking some other way earlier. I think estAngle is useless now – Richard Onslow Roper Jul 05 '21 at 11:53
  • Thanks for Your help :D I have to do it in another way but still, You helped me – iknow Jul 05 '21 at 16:09
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.

cvb941
  • 51
  • 1
  • 4
  • This is really nice. Well done. I've noticed this solution doesn't animate when supplied with 0 then 180. I'll update here when I figure it out. – Rab Ross Sep 10 '21 at 19:11
  • Actually it's that the animation is much faster between lower numbers and 180. – Rab Ross Sep 10 '21 at 19:18
2
@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
  )
)