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.
Installation
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
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).