santimattius/performance-compose-skills/compose-modifier-system

Claude Code and agent skill by santimattius/performance-compose-skills

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Jetpack Compose modifier system: modifier order semantics, ModifierNodeElement, custom Modifier.Node, layout phase internals, and the three drawing tiers (drawBehind, drawWithContent, drawWithCache). Trigger: when creating custom modifiers, working with modifier order, custom layout measurement, or draw-phase effects (graphicsLayer, drawBehind).

Install

npx vskill@latest install santimattius/performance-compose-skills/compose-modifier-system

SKILL.md

Performance First

Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.

PhaseWhat RunsRestart CostTrigger
CompositionComposable functions, state readsHIGH (whole scope)State read in composable body
LayoutMeasure + placeMEDIUM (subtree)State read in measure lambda
DrawingCanvas commands, graphicsLayerLOW (single node)State read in draw/graphicsLayer lambda
Defer State Reads (decision tree)

Do you need the value during composition?

  • YES → read as T (Composition phase) — accept full restart cost
  • NO → can you defer to layout/draw?
  • Layout-only? → pass () -> T into Modifier.layout/offset { } lambda
  • Draw/transform? → pass () -> T into Modifier.graphicsLayer { } / drawBehind { } lambda

Rule: prefer Modifier.offset { lambda } over Modifier.offset(state.value.dp). Lambda variant defers the read to the layout phase, skipping recomposition entirely.

Lambda Overload vs Value Overload for Modifiers
kotlin
// Scroll state that changes on every frame
val scrollState = rememberScrollState()
// ❌ Value overload — reads scrollState.value during Composition
// Every scroll tick recomposes the entire composable scope
Box(
Modifier
.offset(x = 0.dp, y = scrollState.value.dp) // read in Composition
.size(100.dp)
.background(Color.Blue)
)
// ✅ Lambda overload — reads scrollState.value during Layout phase only
// Scroll ticks do NOT trigger recomposition; Layout phase runs instead
Box(
Modifier
.offset { IntOffset(x = 0, y = scrollState.value) } // read in Layout
.size(100.dp)
.background(Color.Blue)
)

The lambda form of offset { } creates its own layout-phase scope. State reads inside that lambda are invisible to the Composition phase — the composable does not recompose on scroll; only the layout subtree re-runs.


Performance Toolchain

WARNING: NEVER profile in debug builds. Debug disables R8, inlining, and Strong Skipping — numbers are meaningless. Always profile a release build with profileable enabled or a benchmark build type.
ToolUse ForWhen
Baseline ProfilesAOT-compile critical paths (startup, scroll)Ship in release; regenerate per release
Compose Compiler ReportsDetect unstable params, restartable/skippable statusEvery PR; fail CI on new unstable types
Layout Inspector (recomposition counts)See which composables recompose and whyWhen debugging excess recomposition
Composition TracingFrame-level composition timing in Android StudioWhen Layout Inspector is not enough
MacrobenchmarkMeasure startup, frame timing, jank in releasePer-release regression gate

When to Use

Use this skill when:

  • Creating a custom modifier that draws (backgrounds, badges, shimmer, overlays)
  • Creating a custom modifier that changes layout (custom padding, placement, sizing)
  • Debugging why a modifier is re-running on every recomposition
  • Deciding which drawing tier to use (drawBehind, drawWithContent, drawWithCache)
  • Working with Modifier.graphicsLayer { } for transforms or alpha
  • Implementing ModifierNodeElement + Modifier.Node to replace legacy Modifier.composed
  • Encountering a runtime crash related to measuring a child twice

CMP Applicability

Canonical CMP rules: `../_shared/cmp-platform.md`
Source setStatusNotes
commonMain✅All modifier APIs are commonMain-safe
androidMain✅Full skill content applies
iosMain✅graphicsLayer, drawBehind, Modifier.Node, ModifierNodeElement all supported
desktopMain✅Same as above
wasmJsMain✅Same as above

Status legend: ✅ fully supported · ⚠️ partial / version-gated · ❌ Android-only.

If using in CMP: graphicsLayer, drawBehind, Modifier.layout, and ModifierNodeElement are all commonMain-safe. Custom draw modifiers using Canvas (Compose) APIs work on all CMP targets. Verify graphicsLayer lambda overload availability in your CMP version (CMP 1.6+). See `../_shared/cmp-platform.md`.


Critical Patterns

1. Modifier.composed Is DEPRECATED — Use ModifierNodeElement + Modifier.Node

Modifier.composed was the old way to create stateful or composable-aware modifiers. It is DEPRECATED because it allocates a new modifier on every recomposition and cannot be skipped by the Compose compiler.

kotlin
// ❌ DEPRECATED — allocates on every recompose, cannot skip
fun Modifier.fade(enable: Boolean): Modifier = composed {
val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f)
this.graphicsLayer { this.alpha = alpha }
}
// ✅ CORRECT — ModifierNodeElement + Modifier.Node; no allocation on recompose
fun Modifier.circleBackground(color: Color) = this then CircleElement(color)
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
drawCircle(color)
drawContent()
}
}

For simple cases that only need CompositionLocal access or animation state, a @Composable factory function is the preferred lightweight alternative to Modifier.composed.

2. ModifierNodeElement MUST Be a data class

equals() / hashCode() correctness on ModifierNodeElement determines when update() is called vs. when a node is recreated. Without data class semantics, the node calls update() on every recomposition regardless of whether inputs changed.

kotlin
// ❌ Missing data class — update() fires every recomposition even with same color
private class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() { ... }
// ✅ data class — equals/hashCode are auto-generated; update() only fires when color changes
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() { ... }
3. shouldAutoInvalidate = false for Fine-Grained Invalidation

By default, Modifier.Node auto-invalidates (redraws + re-measures) when update() is called. Override shouldAutoInvalidate = false and call targeted invalidation methods to avoid unnecessary work.

kotlin
private class OffsetNode(var x: Dp, var y: Dp) : LayoutModifierNode, Modifier.Node() {
override val shouldAutoInvalidate: Boolean = false // take control
fun update(x: Dp, y: Dp) {
if (this.x != x || this.y != y) invalidatePlacement() // layout only — no redraw
this.x = x
this.y = y
}
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
placeable.placeRelative(x.roundToPx(), y.roundToPx())
}
}
}

Use invalidateDraw() for draw-only changes, invalidateMeasurement() for size changes, and invalidatePlacement() for position-only changes.

4. Three Drawing Tiers — Decision Tree
NeedUseWhy
Static background / simple shapedrawBehindLowest overhead; always draws behind content
Full control of draw order relative to contentdrawWithContentCan draw before AND after drawContent()
Expensive objects reused across frames (Brush, Shader, Path)drawWithCacheCache invalidates on size or read-state change only
kotlin
// Tier 1 — drawBehind: static gradient background
Modifier.drawBehind {
drawRect(Color.LightGray)
}
// Tier 2 — drawWithContent: overlay after content
Modifier.drawWithContent {
drawContent()
drawRect(Color.Black.copy(alpha = 0.3f)) // dimmer overlay on top
}
// Tier 3 — drawWithCache: expensive Brush created once, reused every frame
Modifier.drawWithCache {
// onBuildDrawCache block — runs only when size or observed state changes
val brush = Brush.linearGradient(
colors = listOf(Color.Red, Color.Blue),
start = Offset.Zero,
end = Offset(size.width, size.height)
)
onDrawBehind {
drawRect(brush) // brush reused from cache — no allocation per frame
}
}
5. graphicsLayer { } Lambda — ALWAYS Use the Lambda Overload

The property overload reads state during Composition. The lambda overload reads state during Drawing only.

kotlin
val animAlpha by animateFloatAsState(if (visible) 1f else 0f)
// ❌ Property overload — reads animAlpha in Composition phase; recomposes on every frame
Modifier.graphicsLayer(alpha = animAlpha)
// ✅ Lambda overload — reads animAlpha in Drawing phase only; zero recompositions
Modifier.graphicsLayer { alpha = animAlpha }
6. CompositingStrategy Choices

graphicsLayer { alpha < 1f } creates an offscreen GPU buffer by default (Auto). Avoid when content does not overlap.

StrategyBufferUse When
Auto (default)Yes, when alpha < 1fContent overlaps and must composite correctly
ModulateAlphaNoContent does NOT overlap (simpler views, icons)
OffscreenYes, alwaysBlendMode isolation is required (e.g., BlendMode.DstIn for masks)
kotlin
// ✅ Non-overlapping content — skip GPU buffer
Modifier.graphicsLayer {
alpha = 0.5f
compositingStrategy = CompositingStrategy.ModulateAlpha
}
7. Single-Pass Measurement Rule

Compose enforces that each child is measured EXACTLY ONCE per layout pass. Measuring a child twice throws a runtime exception in debug builds.

kotlin
// ❌ Runtime crash — measurable.measure() called twice
Layout(content = content) { measurables, constraints ->
val size = measurables[0].measure(constraints) // first measure
val real = measurables[0].measure(constraints) // CRASH: already measured
layout(real.width, real.height) { real.place(0, 0) }
}
// ✅ Use intrinsics for pre-measurement queries — separate pass, not re-measurement
val minWidth = measurables[0].minIntrinsicWidth(constraints.maxHeight)
val placeable = measurables[0].measure(constraints)

Use intrinsics only when strictly necessary — they add a separate measurement pass.

8. Coordinates Are Pixels, Not Dp

All draw and layout operations inside DrawScope, MeasureScope, and placement blocks use pixel units. Always convert.

kotlin
// ❌ Wrong — Dp passed where pixels are expected
drawCircle(color = Color.Red, radius = 24.dp)
// ✅ Convert using the Density receiver available in DrawScope / MeasureScope
drawCircle(color = Color.Red, radius = 24.dp.toPx())
// ✅ Or use density explicitly when outside a receiver scope
with(density) { 24.dp.toPx() }
9. Modifier Order Is Semantically Significant

Modifiers are decorators. Each modifier wraps everything below it in the chain. The chain reads left-to-right; outer modifiers wrap inner ones.

kotlin
// background BEFORE padding — background fills the outer (larger) area
Modifier
.background(Color.Red) // draws behind padding + content
.padding(16.dp) // content area shrinks inward
.background(Color.Blue) // draws behind content only
// clickable BEFORE padding — click target includes the padded area
Modifier
.clickable { }
.padding(24.dp)
// vs.
// clickable AFTER padding — click target is only the inner content, not the padding
Modifier
.padding(24.dp)
.clickable { }

Convention: always place the external modifier parameter FIRST on the UI root element, so callers can wrap the entire component.

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
5

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/santimattius/performance-compose-skills/compose-modifier-system/badge)](https://verified-skill.com/skills/santimattius/performance-compose-skills/compose-modifier-system)

Something wrong with this skill or its page? Tell us.