diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt new file mode 100644 index 0000000000..12b6892a0f --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.animation.core.tween +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The feed's animated elements defer building their `Transition` until a value actually changes, + * because first composition has nothing to animate and building one per card per scroll is pure + * waste (measured: roughly half the composition cost of every reaction-row button). + * + * The whole point of deferring rather than removing is that the animation must still play. These + * tests pin that: they drive the clock manually and assert that the **first** change — the one that + * happens right after the transition is lazily created — still shows outgoing and incoming content + * simultaneously, which only a running animation does. A regression that turned the deferral into a + * plain snap would show exactly one of them and fail here. + */ +@RunWith(AndroidJUnit4::class) +class DeferredAnimationTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun deferredCrossfadeStillAnimatesTheFirstChange() { + val state = mutableStateOf("A") + rule.mainClock.autoAdvance = false + + rule.setContent { + DeferredCrossfade( + targetState = state.value, + modifier = Modifier, + contentAlignment = Alignment.TopStart, + animationSpec = tween(DURATION_MS), + label = "test", + ) { value -> + Text(value, modifier = Modifier.testTag("text_$value")) + } + } + + // Before any change the transition has not been built, and only the current value renders. + rule.onNodeWithTag("text_A").assertIsDisplayed() + rule.onNodeWithTag("text_B").assertDoesNotExist() + + state.value = "B" + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeBy(DURATION_MS / 3L) + + // Mid-crossfade both are in the tree. This is the assertion that a snap would fail. + rule.onNodeWithTag("text_A").assertExists() + rule.onNodeWithTag("text_B").assertExists() + + rule.mainClock.advanceTimeBy(DURATION_MS * 3L) + rule.onNodeWithTag("text_B").assertIsDisplayed() + rule.onNodeWithTag("text_A").assertDoesNotExist() + } + + companion object { + const val DURATION_MS = 300 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt index fc6be9550a..4c5014141f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.collection.mutableScatterMapOf import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.Box @@ -54,7 +56,53 @@ fun CrossfadeIfEnabled( content(targetState) } } else { - MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) + DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) + } +} + +/** Latches the first time a crossfade's target moves off the value it was composed with. */ +private class ChangeLatch { + var changed = false +} + +/** + * A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there + * is something to animate. + * + * `updateTransition` allocates a transition, its animation list and its seeking state on *first + * composition*, even though first composition has nothing to cross-fade — target and initial state + * are the same value. In a feed that is waste: every card scrolled in builds a transition per + * animated element, and during a scroll essentially none of them run, because the underlying counts + * and icons do not change in the second a card is on screen. + * + * So the plain content renders until the target actually moves. At that point the transition is + * built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at + * the new one, so the first real change still animates exactly as before; every later change + * animates through the now-live transition normally. + */ +@OptIn(ExperimentalAnimationApi::class) +@Composable +internal fun DeferredCrossfade( + targetState: T, + modifier: Modifier, + contentAlignment: Alignment, + animationSpec: FiniteAnimationSpec, + label: String, + content: @Composable (T) -> Unit, +) { + val initial = remember { targetState } + val latch = remember { ChangeLatch() } + if (targetState != initial) latch.changed = true + + if (!latch.changed) { + Box(modifier, contentAlignment) { + content(targetState) + } + } else { + val transitionState = remember { MutableTransitionState(initial) } + transitionState.targetState = targetState + val transition = rememberTransition(transitionState, label) + transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 8c31cfff8d..6ba63fe27f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -31,6 +31,7 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn @@ -857,11 +858,7 @@ private fun SlidingAnimationCount( if (accountViewModel.settings.isPerformanceMode()) { TextCount(baseCount, textColor) } else { - AnimatedContent( - targetState = baseCount, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationCount", - ) { count -> + DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count -> TextCount(count, textColor) } } @@ -884,6 +881,48 @@ val slideAnimation: ContentTransform = ), ) +/** Latches the first time an animated counter's value moves off the one it was composed with. */ +private class CountChangeLatch { + var changed = false +} + +/** + * An [AnimatedContent] that does not build its transition until the value actually changes. + * + * Same reasoning as `DeferredCrossfade`: `AnimatedContent` builds a transition plus its content map + * and size animation on first composition, but first composition has nothing to animate. A reaction + * counter only slides when the count moves, which practically never happens in the second a card + * spends on screen during a scroll — so the apparatus was built and thrown away, once per counter + * per card. + * + * Rendering the bare content until the first change, then seeding a [MutableTransitionState] at the + * original value, keeps that first change animated exactly as before. + */ +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun DeferredAnimatedContent( + targetState: T, + label: String, + content: @Composable (T) -> Unit, +) { + val initial = remember { targetState } + val latch = remember { CountChangeLatch() } + if (targetState != initial) latch.changed = true + + if (!latch.changed) { + content(targetState) + } else { + val transitionState = remember { MutableTransitionState(initial) } + transitionState.targetState = targetState + val transition = rememberTransition(transitionState, label) + transition.AnimatedContent( + transitionSpec = { transitionSpec() }, + ) { value -> + content(value) + } + } +} + @Composable fun TextCount( count: Int, @@ -911,11 +950,7 @@ fun SlidingAnimationAmount( maxLines = 1, ) } else { - AnimatedContent( - targetState = amount, - transitionSpec = AnimatedContentTransitionScope::transitionSpec, - label = "SlidingAnimationAmount", - ) { count -> + DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count -> Text( text = count, fontSize = Font14SP,