Use this skill to push frequently-changing Jetpack Compose state reads (scroll position, animation values, drag offsets) out of the Composition phase and down into Layout or Draw using lambda-based modifiers like Modifier.offset { }, Modifier.layout { }, Modifier.graphicsLayer { }, Modifier.drawBehind { }, and Modifier.drawWithCache { }. Covers the three-phase model (Composition, Layout, Draw), why a state read at phase N invalidates phase N and every phase below, the modifier-phase cheat sheet, and lambda providers (() -> T) for hoisting hot values across composables. Use when the developer mentions every-frame work, scroll jank, animation jank, dropped frames, animated alpha or offset, "the whole subtree recomposes on scroll", Modifier.alpha(state.value), Modifier.offset(x.dp), or graphicsLayer.
Instalação
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Use this skill to push frequently-changing Jetpack Compose state reads (scroll position, animation values, drag offsets) out of the Composition phase and down into Layout or Draw using lambda-based modifiers like Modifier.offset { }, Modifier.layout { }, Modifier.graphicsLayer { }, Modifier.drawBehind { }, and Modifier.drawWithCache { }. Covers the three-phase model (Composition, Layout, Draw), why a state read at phase N invalidates phase N and every phase below, the modifier-phase cheat sheet, and lambda providers (() -> T) for hoisting hot values across composables. Use when the developer mentions every-frame work, scroll jank, animation jank, dropped frames, animated alpha or offset, "the whole subtree recomposes on scroll", Modifier.alpha(state.value), Modifier.offset(x.dp), or graphicsLayer.
license
Apache-2.0. See LICENSE for complete terms.
metadata
{"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","performance","recomposition","phases","lambda-modifiers","graphicslayer","scroll-perf","animation-jank"]}
Deferring State Reads — Move Hot Reads from Composition to Draw
Compose runs three phases per frame — Composition → Layout → Draw. A state read at phase N
invalidates phase N and every phase below it. The single biggest perf win in any animation- or
scroll-driven UI is moving a state read from Composition down to Layout or Draw via a lambda-based
modifier. This skill teaches Claude how to spot the wrong-phase read and migrate it.
When to use this skill
An animation triggers full subtree recomposition on every frame (Modifier.alpha(progress.value),
Modifier.offset(x.dp), Modifier.padding(state.dp) — padding has no lambda overload, so use
Modifier.layout { ... } or Modifier.offset { IntOffset(...) } instead when the inset is
animated).
A scroll position is read directly in a composable body (val y = scrollState.value) and the
parent recomposes on every pixel of scroll.
The developer says "every frame", "scroll jank", "animation jank", "dropped frames", or reports
the whole screen recomposing on drag.
A @TraceRecomposition log shows the parent's recomposition counter incrementing once per
animation tick.
The developer is passing a hot value (animation progress, scroll offset, drag delta) as a Float
parameter across composables.
When NOT to use this skill
The state changes once per user interaction (button click, dialog open) — the lambda-modifier
rewrite buys nothing and adds noise.
The state read genuinely needs to drive Composition (show/hide a different composable, swap a
different component tree). Lambda modifiers cannot decide which composable to emit.
The non-skippable parent is non-skippable for a different reason (unstable parameter). Diagnose
with ../../stability/diagnosing-compose-stability/SKILL.md first.
The developer wants to filter many high-frequency inputs into one rare boolean — that is
../choosing-derivedstateof/SKILL.md.
Prerequisites
Familiarity with the three-phase model. If unsure which phase a read happens in, read
references/three-phases.md before doing the migration.
Compose UI 1.7+ for rememberGraphicsLayer() and the modern Modifier.animateItem() shape.
Lambda forms of Modifier.offset { }, Modifier.graphicsLayer { }, Modifier.drawBehind { },
and Modifier.drawWithCache { } exist on every supported Compose version.
Ability to confirm recomposition counts in Layout Inspector or via @TraceRecomposition (see
../debugging-recompositions/SKILL.md if it exists in this repo, otherwise the skydoves
compose-stability-analyzer runtime).
A release build for any final measurement — debug builds run interpreted and lie about cost.
Workflow
1. Identify the state read site and the modifier consuming it. Look for state.value,
by state, animatedFloat.value, scrollState.value, dragOffset.value in a composable body.
Note which modifier consumes it.
2. Classify the modifier — value form or lambda form. Most positioning/sizing/drawing
modifiers ship in two flavors:
Value form (read in Composition)
Lambda form (read in Layout or Draw)
Modifier.offset(x = dp, y = dp)
Modifier.offset { IntOffset(x, y) }
Modifier.padding(start = dp, …)
Modifier.layout { measurable, constraints -> … } (no lambda overload of padding; use a custom layout to inset with hot state)
Modifier.size(dp)
Modifier.layout { … } (custom)
Modifier.alpha(float)
Modifier.graphicsLayer { alpha = … }
Modifier.rotate(float)
Modifier.graphicsLayer { rotationZ = … }
Modifier.scale(float)
Modifier.graphicsLayer { scaleX = …; scaleY = … }
Modifier.background(color)
Modifier.drawBehind { drawRect(color) }
If a value form is being fed a hot state, swap to the lambda form. The lambda is invoked on every
Layout (or Draw) pass without re-running Composition.
3. Migrate value-form modifiers to lambda form. The lambda body re-executes when the read
state changes, but it does so during Layout or Draw — Composition is never invalidated.
4. For multiple per-frame transforms, hoist into a single Modifier.graphicsLayer { … }
block. It covers translationX/translationY, scaleX/scaleY, rotationZ, alpha,
cameraDistance, clip, and shape in one Draw-phase node, and is preferred over chaining
alpha + offset + rotate.
5. Hoist hot values across composables as lambda providers. Pass () -> Float instead of
Float so the receiving composable can defer the read into its own lambda modifier without
invalidating its caller. This is the single most important cross-composable perf trick after
lambda modifiers themselves.
6. Verify in Layout Inspector / @TraceRecomposition. The parent composable's
recomposition count MUST stop incrementing per animation frame. If it still climbs, the
wrong-phase read survived elsewhere — re-grep the file for .value and by state.
7. For per-frame Draw work that is more than a transform, use Modifier.drawBehind { }
or Modifier.drawWithCache { }.drawBehind re-runs every Draw pass; drawWithCache caches
the build step (e.g. paths, brushes) and only re-runs the onDraw block on state change.
Patterns
Pattern: Animated offset
// WRONGval animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset(x = animatedX.value.dp))
// WRONG because: reading .value in Composition phase invalidates the whole subtree on every animation frame.
The lambda runs in Layout, so Composition is never invalidated. The animation still drives the
visual position via Layout-only invalidation.
Pattern: Animated alpha (and other transforms)
// WRONGval animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.alpha(animatedAlpha))
// WRONG because: Modifier.alpha(Float) reads the value in Composition; the entire Box subtree recomposes every frame.
// WRONGval offset = scrollState.value
Header(Modifier.offset(y = offset.dp))
// WRONG because: scrollState.value updates every pixel of scroll, and reading it in the parent body recomposes Header (and every sibling) per scroll tick.
// RIGHT
Header(Modifier.offset { IntOffset(0, scrollState.value) })
The lambda captures scrollState (a stable holder), reads .value only when Layout runs, and
isolates invalidation to the Layout phase of Header.
Pattern: Lambda providers across composables
// WRONG@ComposablefunParent(scrollOffset: Float) {
Child(scrollOffset)
}
@ComposablefunChild(scrollOffset: Float) {
Box(Modifier.offset { IntOffset(0, scrollOffset.toInt()) })
}
// WRONG because: scrollOffset is read in Parent's signature each frame; Parent and every sibling of Child recompose per frame even though only Child cares.
The lambda parameter is a stable function reference. Calling it inside Modifier.offset { } defers
the read to Layout. Parent never re-reads the hot state, so Parent never recomposes.
Pattern: Per-frame Draw work — drawBehind / drawWithCache
// WRONGval color = animatedColor.value
Box(Modifier.background(color))
// WRONG because: Modifier.background reads color in Composition; per-frame color animation invalidates the subtree.
// RIGHT — Draw phase only, no caching needed for a solid color
Box(Modifier.drawBehind { drawRect(animatedColor.value) })
// RIGHT — heavier work; cache the Path/Brush across frames
Box(
Modifier.drawWithCache {
val brush = Brush.linearGradient(
colors = listOf(start, end),
start = Offset.Zero,
end = Offset(size.width, size.height),
)
onDrawBehind { drawRect(brush, alpha = animatedAlpha.value) }
}
)
drawWithCache re-builds the cache only when the remember-keys captured by the lambda change; the
onDrawBehind block re-runs on every Draw with the latest state.
Three phases reference
Compose runs every frame in this order. A read at phase N invalidates phase N and everything below
it. Push reads as low as possible.
Phase
What it does
Cost when invalidated
Composition
Run @Composable functions, build/diff the UI tree
Highest — all skippability gates re-run, child composables potentially recompose
Layout
measure() and placeRelative() for each node
Medium — re-measure and re-place affected subtree
Draw
Record draw commands into the canvas
Lowest — single render pass
Modifier phase cheat sheet (most common cases)
Modifier
Phase the state is read in
Modifier.offset(Dp)
Composition
Modifier.offset { IntOffset }
Layout
Modifier.padding(Dp) / padding(PaddingValues)
Composition (no lambda overload exists)
Modifier.size(Dp)
Composition
Modifier.layout { measurable, constraints -> … }
Layout (escape hatch when a hot state must drive measurement, e.g. an animated inset — padding ships no lambda form)
Modifier.alpha(Float)
Composition
Modifier.rotate(Float)
Composition
Modifier.scale(Float)
Composition
Modifier.graphicsLayer { … }
Draw
Modifier.background(Color)
Composition
Modifier.drawBehind { … }
Draw
Modifier.drawWithCache { … }
Draw (cache rebuilds when its captured state changes)
For the deeper mechanics — invalidation propagation, the backwards-write rule, and a comprehensive
per-modifier breakdown — see references/three-phases.md.
Mandatory rules
MUST prefer lambda-based modifiers for any state that changes more often than once per user
interaction (animation values, scroll position, drag offsets, gesture deltas).
MUST use Modifier.graphicsLayer { … } for animated alpha, scale, rotate,
translation. Never Modifier.alpha(state.value) / Modifier.rotate(state.value) /
Modifier.scale(state.value) for hot state.
MUST NOT read frequently-changing state in a composable body when a lambda modifier could read
it later. Every avoided .value read in a composable body is one fewer Composition invalidation
per frame.
MUST NOT write to a MutableState already read in the same composition pass — that is a
backwards write (see references/three-phases.md); the runtime aborts the recomposition with a
cost.
PREFERRED: lambda providers (() -> T) over plain values when the value is hot and crosses
composable boundaries. Pair with lambda modifiers at the receiving end.
PREFERRED:Modifier.drawWithCache { } over Modifier.drawBehind { } when the per-frame work
involves rebuildable resources (paths, brushes, gradients) that depend on size or theme.
Verification
Layout Inspector shows the parent composable's recomposition count is 0 (or near 0) per
animation frame. Only Layout/Draw counters increment.
@TraceRecomposition(traceStates = true) (skydoves/compose-stability-analyzer) confirms in *
release + R8 + real device* that the wrapping composable does not recompose per frame; only the
sub-node attached to the lambda modifier re-runs.
A grep over the migrated file finds no Modifier.alpha(/Modifier.rotate(/Modifier.scale(/
Modifier.offset(<dp expr>) patterns fed by hot state.
Cross-composable hot values are passed as () -> T lambda providers, not as T values.
The release build still renders correctly — value-form to lambda-form is a behavioral no-op
only when the lambda body matches the original arithmetic (watch for dp vs px confusion:
Modifier.offset { IntOffset(x, y) } takes pixels).