mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #4031 from vitorpamplona/perf/feed-note-render
perf(feed): defer animation transitions until there is something to animate
This commit is contained in:
+91
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user