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 inanimateTois 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