11package com.aquib.androidperflab.ui
22
3- import androidx.compose.animation.animateContentSize
3+ import androidx.compose.animation.core.DeferredTargetAnimation
4+ import androidx.compose.animation.core.ExperimentalAnimatableApi
45import androidx.compose.animation.core.LinearEasing
56import androidx.compose.animation.core.RepeatMode
7+ import androidx.compose.animation.core.Spring
8+ import androidx.compose.animation.core.VectorConverter
69import androidx.compose.animation.core.animateFloat
710import androidx.compose.animation.core.infiniteRepeatable
811import androidx.compose.animation.core.rememberInfiniteTransition
12+ import androidx.compose.animation.core.spring
913import androidx.compose.animation.core.tween
1014import androidx.compose.foundation.background
1115import androidx.compose.foundation.clickable
@@ -24,19 +28,27 @@ import androidx.compose.material3.MaterialTheme
2428import androidx.compose.material3.Text
2529import androidx.compose.material3.TextButton
2630import androidx.compose.runtime.Composable
31+ import androidx.compose.runtime.Immutable
2732import androidx.compose.runtime.getValue
2833import androidx.compose.runtime.mutableStateOf
2934import androidx.compose.runtime.remember
35+ import androidx.compose.runtime.rememberCoroutineScope
3036import androidx.compose.runtime.setValue
3137import androidx.compose.ui.Alignment
3238import androidx.compose.ui.Modifier
33- import androidx.compose.ui.draw.alpha
39+ import androidx.compose.ui.draw.clipToBounds
3440import androidx.compose.ui.graphics.Color
41+ import androidx.compose.ui.graphics.graphicsLayer
42+ import androidx.compose.ui.layout.layout
3543import androidx.compose.ui.platform.testTag
3644import androidx.compose.ui.semantics.contentDescription
3745import androidx.compose.ui.semantics.semantics
3846import androidx.compose.ui.unit.dp
47+ import kotlin.math.roundToInt
3948
49+ // @Immutable: all fields are val and all types are immutable, so the Compose compiler
50+ // can skip any composable whose AnimatedItem argument has not changed reference.
51+ @Immutable
4052private data class AnimatedItem (
4153 val id : Int ,
4254 val title : String ,
@@ -49,9 +61,9 @@ private fun generateAnimatedItems(count: Int = 80): List<AnimatedItem> = List(co
4961 id = i,
5062 title = " Animation Demo Item #$i " ,
5163 subtitle = " Tap to expand · item ${i + 1 } of $count " ,
52- body = " This card has animateContentSize applied, an alpha that reads State<Float> " +
53- " in composition scope (not inside a graphicsLayer lambda), and a Color " +
54- " constructed inline on every recomposition. All intentionally unoptimized ." ,
64+ body = " Alpha pulse runs via graphicsLayer (draw phase — zero recomposition). " +
65+ " Expand/collapse uses DeferredTargetAnimation inside Modifier.layout — " +
66+ " height changes are layout-phase only. No recomposition during animation frames ." ,
5567 )
5668}
5769
@@ -69,103 +81,150 @@ fun AnimatedListScreen(
6981 modifier = Modifier
7082 .fillMaxSize()
7183 .testTag(" animated_list" )
72- .semantics { contentDescription = " animated_list" }
84+ .semantics { contentDescription = " animated_list" },
7385 ) {
74- // BAD: no key lambda — Compose cannot track item identity across recompositions,
75- // so any structural change causes it to diff by position rather than by id.
76- items(items) { item ->
86+ // FIX — stable key: pins each slot to a specific AnimatedItem by id so
87+ // Compose can reuse existing nodes on structural changes instead of
88+ // destroying and recreating items by position.
89+ items(items, key = { it.id }) { item ->
7790 AnimatedListCard (item = item)
7891 HorizontalDivider ()
7992 }
8093 }
8194 }
8295}
8396
97+ @OptIn(ExperimentalAnimatableApi ::class )
8498@Composable
8599private fun AnimatedListCard (item : AnimatedItem ) {
86100 var expanded by remember { mutableStateOf(false ) }
87101
88- // BAD: animatedAlpha is a State<Float> whose value changes every ~16 ms while the
89- // animation runs. Reading it here (via the `by` delegate) subscribes this composable
90- // to that state, so the ENTIRE composable — and every child inside it — recomposes
91- // on every single animation frame.
102+ // rememberCoroutineScope() gives a scope tied to this composable's lifecycle.
103+ // DeferredTargetAnimation uses it to launch the height-animation coroutine from
104+ // inside the layout lambda — without ever touching the composition phase.
105+ val scope = rememberCoroutineScope()
106+
107+ // FIX — remember(item.id): Color object allocated once per item.
108+ // Previously a new Color was constructed on every recomposition.
109+ val accentColor = remember(item.id) {
110+ Color (
111+ red = (item.id * 37 % 200 + 55 ) / 255f ,
112+ green = (item.id * 71 % 180 + 50 ) / 255f ,
113+ blue = (item.id * 13 % 220 + 35 ) / 255f ,
114+ )
115+ }
116+
117+ // FIX — infinite alpha pulse via graphicsLayer:
118+ // animateFloat() returns State<Float>. Assigning to a plain val (no `by`) means
119+ // the value is NOT read here in the composition scope. The `by` delegate would call
120+ // State.getValue() during composition, subscribing this entire composable to the
121+ // animation and causing a full recompose on every 16 ms frame.
92122 //
93- // Correct fix: don't read the state here at all. Instead, pass it into a graphicsLayer
94- // lambda where only the draw phase is invalidated:
95- // Modifier.graphicsLayer { alpha = animatedAlpha }
123+ // Instead, alphaState.value is read only inside the graphicsLayer lambda below,
124+ // which runs in the draw phase. Only the GPU layer is invalidated on each tick —
125+ // composition and layout are never touched.
96126 val infiniteTransition = rememberInfiniteTransition(label = " pulse_${item.id} " )
97- val animatedAlpha by infiniteTransition.animateFloat(
98- initialValue = 0.50f ,
99- targetValue = 1.00f ,
127+ val alphaState = infiniteTransition.animateFloat(
128+ initialValue = 0.5f ,
129+ targetValue = 1.0f ,
100130 animationSpec = infiniteRepeatable(
101- // Stagger durations slightly so items don't all flash in sync.
102- animation = tween(durationMillis = 600 + (item.id % 10 ) * 80 , easing = LinearEasing ),
131+ animation = tween(durationMillis = 600 + (item.id % 10 ) * 80 , easing = LinearEasing ),
103132 repeatMode = RepeatMode .Reverse ,
104133 ),
105134 label = " alpha_${item.id} " ,
106135 )
107136
108- // BAD: new Color object allocated on every recomposition — should be a top-level
109- // constant or wrapped in remember { Color(...) }.
110- val accentColor = Color (
111- red = (item.id * 37 % 200 + 55 ) / 255f ,
112- green = (item.id * 71 % 180 + 50 ) / 255f ,
113- blue = (item.id * 13 % 220 + 35 ) / 255f ,
114- )
137+ // FIX — DeferredTargetAnimation for expand/collapse:
138+ // Drives the body's height from 0→full (expand) or full→0 (collapse) entirely
139+ // from inside Modifier.layout. State reads in a layout lambda trigger a layout-phase
140+ // re-run, not a recomposition — so the 80 frames of a spring animation produce
141+ // 80 layout passes and zero recompositions.
142+ val expandAnim = remember { DeferredTargetAnimation (Float .VectorConverter ) }
115143
116144 Column (
117145 modifier = Modifier
118146 .fillMaxWidth()
119- // BAD 1: animateContentSize on every item in the list.
120- // When any card expands, the LayoutModifier runs its size interpolation on
121- // every animation frame for every card that has this modifier — not just the
122- // one the user tapped.
123- .animateContentSize()
124- // BAD 2: Modifier.alpha() evaluates its argument during composition, so the
125- // state read above makes this whole subtree recompose every frame.
126- // Modifier.graphicsLayer { alpha = animatedAlpha } would confine the read to
127- // the draw phase and skip recomposition entirely.
128- .alpha(animatedAlpha)
147+ // FIX — graphicsLayer for alpha: alphaState.value is read inside the
148+ // lambda, scoped to the draw phase. The composable tree above this point
149+ // is never invalidated by the animation.
150+ .graphicsLayer { alpha = alphaState.value }
129151 .background(accentColor.copy(alpha = 0.07f ))
130- .clickable { expanded = ! expanded }
131- .padding(horizontal = 16 .dp, vertical = 12 .dp),
152+ .clickable { expanded = ! expanded },
132153 ) {
154+ // ── Header (always visible) ───────────────────────────────────────────
133155 Row (
134- modifier = Modifier .fillMaxWidth(),
156+ modifier = Modifier
157+ .fillMaxWidth()
158+ .padding(horizontal = 16 .dp, vertical = 12 .dp),
135159 horizontalArrangement = Arrangement .SpaceBetween ,
136- verticalAlignment = Alignment .CenterVertically ,
160+ verticalAlignment = Alignment .CenterVertically ,
137161 ) {
138162 Column (modifier = Modifier .weight(1f )) {
139163 Text (
140- text = item.title,
164+ text = item.title,
141165 style = MaterialTheme .typography.titleSmall,
142166 color = accentColor,
143167 )
144168 Text (
145- text = item.subtitle,
169+ text = item.subtitle,
146170 style = MaterialTheme .typography.labelSmall,
147171 color = MaterialTheme .colorScheme.outline,
148172 )
149173 }
150174 Text (
151- text = if (expanded) " ▲" else " ▼" ,
175+ text = if (expanded) " ▲" else " ▼" ,
152176 style = MaterialTheme .typography.labelMedium,
153177 color = accentColor,
154178 )
155179 }
156180
157- if (expanded) {
181+ // ── Body (height animated via DeferredTargetAnimation) ────────────────
182+ //
183+ // The body is always present in the composition tree — no if/else branch.
184+ // Removing the branch means tapping the card causes exactly ONE recomposition
185+ // (to flip the chevron); thereafter the spring animation runs purely in the
186+ // layout phase via DeferredTargetAnimation.
187+ //
188+ // Modifier.layout measures the full body height, then reports an animated
189+ // fraction of that height to the parent. clipToBounds() hides content that
190+ // extends beyond the currently animated bounds so nothing visually overflows.
191+ Column (
192+ modifier = Modifier
193+ .fillMaxWidth()
194+ .clipToBounds()
195+ .layout { measurable, constraints ->
196+ val placeable = measurable.measure(constraints)
197+
198+ // updateTarget() is called here (layout phase) — it starts or
199+ // redirects the animation coroutine when the target changes, and
200+ // returns the current interpolated progress value.
201+ // Reading it here causes layout-phase invalidation on each frame,
202+ // NOT a recomposition.
203+ val progress = expandAnim.updateTarget(
204+ target = if (expanded) 1f else 0f ,
205+ coroutineScope = scope,
206+ animationSpec = spring(stiffness = Spring .StiffnessMediumLow ),
207+ )
208+ val animatedHeight = (placeable.height * progress)
209+ .roundToInt()
210+ .coerceAtLeast(0 )
211+
212+ layout(placeable.width, animatedHeight) {
213+ placeable.place(0 , 0 )
214+ }
215+ },
216+ ) {
158217 Spacer (modifier = Modifier .height(8 .dp))
159218 Text (text = item.body, style = MaterialTheme .typography.bodySmall)
160219 Spacer (modifier = Modifier .height(4 .dp))
161- // Extra lines increase the layout cost when animateContentSize runs.
162220 repeat(4 ) { line ->
163221 Text (
164- text = " Detail line ${line + 1 } — item #${item.id} " ,
222+ text = " Detail line ${line + 1 } — item #${item.id} " ,
165223 style = MaterialTheme .typography.labelSmall,
166224 color = MaterialTheme .colorScheme.outline,
167225 )
168226 }
227+ Spacer (modifier = Modifier .height(8 .dp))
169228 }
170229 }
171230}
0 commit comments