perf(feed): defer animation transitions until there is something to animate

`updateTransition` and `AnimatedContent` allocate a Transition, its animation
list and its seeking state on *first* composition — but first composition has
nothing to animate, because target and initial state are the same value. In a
feed that is waste: every card scrolled in built six of them, and during a scroll
essentially none ever ran, since reaction counts and icons do not change in the
second a card is on screen.

`DeferredCrossfade` and `DeferredAnimatedContent` render the plain content until
the target actually moves, then build the transition seeded at the *original*
value via `MutableTransitionState` and immediately re-target it — so the first
real change still animates exactly as before, and later changes animate through
the now-live transition normally. The existing `isPerformanceMode()` branch,
which genuinely drops the animation, is untouched and still takes precedence.

Measured on an SM-T220 against a frozen corpus served by a local relay (a real
capture: 105 notes, 68 profiles, 501 reactions, 75 boosts, 22 zaps), interleaved
with the unmodified build, two runs per arm:

  frame duration P90    27.53 -> 26.90   -2.3%   (baseline spread 0.1%)
  frame overrun  P90    21.65 -> 17.44  -19.4%   (baseline spread 6.3%)
  frame duration P50                     -1.1%   (inside a 1.7% spread)

Modest at the frame level by nature: on this device the main thread sits blocked
in `postAndWait` on the RenderThread for roughly two-thirds of every frame, so
composition savings largely do not surface. Removing 24 flow subscriptions per
card, every clickable, or every counter each moved `postAndWait` by only ~2%.

`DeferredAnimationTest` drives the clock manually and asserts the outgoing and
incoming content coexist mid-transition, which only a running animation does; a
regression turning the deferral into a snap fails it.
This commit is contained in:
Vitor Pamplona
2026-09-01 15:41:05 -04:00
parent 826fc826db
commit d18b7f770f
3 changed files with 185 additions and 11 deletions
@@ -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
}
}
@@ -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 <T> 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 <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
animationSpec: FiniteAnimationSpec<Float>,
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)
}
}
@@ -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<Int>::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 <T> 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<String>::transitionSpec,
label = "SlidingAnimationAmount",
) { count ->
DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count ->
Text(
text = count,
fontSize = Font14SP,