Julia Samól
11/09/2023, 9:45 AMval 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.Rafs
11/09/2023, 11:03 AMisRunning
from?Julia Samól
11/09/2023, 11:07 AMtrue
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.Albert Chang
11/09/2023, 3:04 PManimateTo
in else branch?Julia Samól
11/09/2023, 3:23 PManimateTo
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.Albert Chang
11/09/2023, 3:32 PMLaunchedEffect
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.Oleksandr Balan
11/09/2023, 5:43 PManimateTo
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.
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.
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:
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
:
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):
LaunchedEffect(isRunningState.value) {
if (isRunningState.value && !rotationState.isRunning) {
scope.launch {
...
}
}
}
Putting it all together:
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.
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
)
}
}
ascii
11/10/2023, 12:06 AMrememberAnimatedVectorPainter
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.Julia Samól
11/13/2023, 2:22 PMTo be clear, the previous call was canceled by theinstead of by a new callLaunchedEffect
As Albert mentioned the main issue is thatAh I see, that makes sense, thanks for clarifying that!call was canceled when the key inanimateTo
is changed.LaunchedEffect
IMHO: While this will work as intended I would recommend to revisit your requirements, as you can switch between rotating arrow and static arrow usingThis is what I eventually went with, looks neat. Thanks!with some pleasant transition.AnimatedContent