https://kotlinlang.org logo
#compose
Title
# compose
j

Julia Samól

11/09/2023, 9:45 AM
Hi! I’m doing a simple animation of a refresh button - if the state is refreshing, the button rotates, when the ongoing task stops, I stop the animation
Copy code
val rotationState = remember { Animatable(0f) }

LaunchedEffect(isRunning) {
  if (isRunning) {
    rotationState.animateTo(
      targetValue = 360f,
      animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 2000, easing = LinearEasing),
        repeatMode = RepeatMode.Restart,
      ),
    )
  } else {
    rotationState.stop()
  }
}

Image(
  modifier = Modifier.graphicsLayer { rotationZ = rotationState.value },
  painter = painterResource(id = R.drawable.ic_refresh),
  contentDescription = null
)
This is working fine, however, the button ends up in a random position. Ideally, I’d like the button to smoothly rotate to its original configuration. Is there a way to make the animation wait until the
targetValue
is reached before stopping? I’m aware of the
snapTo(Float)
method, unfortunately this is not the effect I’m looking for as the transition is not smooth at all and, in this case, it looks more like a glitch than an intended behaviour.
1
r

Rafs

11/09/2023, 11:03 AM
Where do you get
isRunning
from?
j

Julia Samól

11/09/2023, 11:07 AM
It’s just my variable that gets evaluated to
true
if the refreshing task is still ongoing or to
false
if it has completed. I’m not sure its definition is relevant to the question, but, essentially, it comes long way down from a
ViewModel
that manages the view’s state.
a

Albert Chang

11/09/2023, 3:04 PM
Why not just use another
animateTo
in else branch?
j

Julia Samól

11/09/2023, 3:23 PM
Yes, I tried that. The use of another
animateTo
cancels the previous one, resulting in a noticeable pause before it continues with the new animation. Or at least I suspect that’s the reason. Also the configuration of the second
animateTo
might be quite tricky as
rotationState
holds currently a certain value between 0 and 360 and I want it to transition to 360 with the same number of frames as it had left in the initial
animateTo
. I would expect that
(1 - rotationState.value / 360f) * 2000
should be enough to maintain the pace, but, tbh, I didn’t spend much time on exploring that solution after I noticed the issue I mentioned first, so I can’t confirm there are no issues with that setting.
a

Albert Chang

11/09/2023, 3:32 PM
To be clear, the previous call was canceled by the
LaunchedEffect
instead of by a new call (and the
stop()
is actually useless). You can try
snapshotFlow { isRunning }.collectLatest {}
and see if that shortens the delay.
👍 1
o

Oleksandr Balan

11/09/2023, 5:43 PM
As Albert mentioned the main issue is that
animateTo
call was canceled when the key in
LaunchedEffect
is changed. So one of the possible solutions would be to use component's
CoroutineScope
, which is not cancelled with keys.
Copy code
val scope = rememberCoroutineScope()
LaunchedEffect(...) {
   scope.launch {
       ...
   }
}
As you want to smoothly end the rotation, I would use a while cycle instead of
InfiniteRepeatableSpec
. This way the animation will play from 0 to 360 degrees while the condition matches.
Copy code
LaunchedEffect(...) {
    scope.launch {
        while (...) {
            rotationState.animateTo(
                targetValue = 360f,
                animationSpec = tween(durationMillis = 2000, easing = LinearEasing),
            )
            rotationState.snapTo(0f)
        }
    }
}
As to the condition you cannot use
isRunning
, due to recomposition specifics, so you have to wrap it in the
rememberUpdatedState
to convert it to the state, which could be read later in the job:
Copy code
val isRunningState = rememberUpdatedState(isRunning)
LaunchedEffect(...) {
    scope.launch {
        while (isRunningState.value) {
            ...
        }   
    }
}
However this will work correctly only when
isRunning
changes from true for false, but will never restart an animation when it changes from false to true. So you will indeed want to use the state of running as a key in the
LaunchedEffect
:
Copy code
LaunchedEffect(isRunningState.value) {
    scope.launch {
        while (isRunningState.value) {
            ...
        }
    }
}
This will work fine for both cases (true-to-false and false-to-true), but will introduce a side-effect that will "slow down" the animation when state changes frequently (during animation), as there will be multiple jobs started, which will all animate to the 360 degrees. To fix this you can wrap the
launch
in the condition to start the new coroutine only if needed (when animation is not running, but
isRunning
is set to true):
Copy code
LaunchedEffect(isRunningState.value) {
    if (isRunningState.value && !rotationState.isRunning) {
        scope.launch {
            ...
        }
    }
}
Putting it all together:
Copy code
val isRunningState = rememberUpdatedState(isRunning)
val rotationState = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
LaunchedEffect(isRunningState.value) {
    if (isRunningState.value && !rotationState.isRunning) {
        scope.launch {
            while (isRunningState.value) {
                rotationState.animateTo(
                    targetValue = 360f,
                    animationSpec = tween(durationMillis = 2000, easing = LinearEasing),
                )
                rotationState.snapTo(0f)
            }
        }
    }
}
IMHO: While this will work as intended I would recommend to revisit your requirements, as you can switch between rotating arrow and static arrow using
AnimatedContent
with some pleasant transition. This way your UI will be perfectly synced with UI state and user will not be confused of rotating arrow for two more seconds.
Copy code
AnimatedContent(
    targetState = isRunning,
    transitionSpec = { fadeIn() + scaleIn() with fadeOut() + scaleOut() },
    label = "Refresh icon",
) { targetIsRunning ->
    if (targetIsRunning) {
        val transition = rememberInfiniteTransition(
            label = "Infinity rotating refresh icon transition"
        )
        val infinityRotationState = transition.animateFloat(
            initialValue = 0f,
            targetValue = 360f,
            animationSpec = infiniteRepeatable(
                animation = tween(durationMillis = 2000, easing = LinearEasing),
                repeatMode = RepeatMode.Restart,
            ),
            label = "Infinity rotating refresh icon"
        )
        Image(
            modifier = Modifier.graphicsLayer { rotationZ = infinityRotationState.value },
            imageVector = Icons.Rounded.Refresh,
            contentDescription = null
        )
    } else {
        Image(
            imageVector = Icons.Rounded.Refresh,
            contentDescription = null
        )
    }
}
🙌 1
a

ascii

11/10/2023, 12:06 AM
I opted to morph my icon into a spinner via
rememberAnimatedVectorPainter
by toggling
atEnd
on click. Target's a clip path being controlled by an objectAnimator.pathData. I spent way too much time making the animated vector in the first place, so here's my opinion: don't bother. With how small icons are, it's good enough to just replace them with a fade-in spinner.
👍 1
j

Julia Samól

11/13/2023, 2:22 PM
To be clear, the previous call was canceled by the
LaunchedEffect
instead of by a new call
As Albert mentioned the main issue is that
animateTo
call was canceled when the key in
LaunchedEffect
is changed.
Ah I see, that makes sense, thanks for clarifying that!
IMHO: While this will work as intended I would recommend to revisit your requirements, as you can switch between rotating arrow and static arrow using
AnimatedContent
with some pleasant transition.
This is what I eventually went with, looks neat. Thanks!
🤗 1
2 Views