Dan Tech Academy

graphicsLayer - The Anti-Recomposition Weapon in Jetpack Compose

Learn how graphicsLayer pushes visual effects to the Draw phase, eliminating recomposition in scroll-driven animations. Real WheelPicker case study.

Dan TechDan TechSoftware Engineer, Weight Lifter6 min readUpdated 06/23/2026

Mobile Action Pro Course

This post uses production code from the Mobile Action Pro course. Learn Compose performance, Clean Architecture, and ship a real AI quiz app.

108 Lessons9 ModulesLifetime Access
See the course

Compose runs through 3 phases: Composition → Layout → Draw. Most animation code defaults to the Composition phase - every frame change triggers recomposition. graphicsLayer lets you push visual effects down to the Draw phase, where changes only redraw without recomposing or relaying out.

This post explains when and how to use graphicsLayer for performance optimization, illustrated through a real WheelPicker case study.

Compose 3 Phases and the Performance Problem

Compose 3 Phases — animateXAsState vs graphicsLayer

When you use animateFloatAsState or animateColorAsState, the new value creates a State change - Compose must re-run Composition + Layout + Draw. With graphicsLayer, you only pay for Draw.

This distinction becomes critical in scroll-driven animations where state changes happen every single frame. A LazyColumn with 5 visible items, each holding 3 animation states, means 15+ recomposition triggers per frame - far more work than the GPU needs.

What Can graphicsLayer Do?

Inside the graphicsLayer lambda, you directly access transform properties:

PropertyDescription
scaleX, scaleYScale transform
alphaOpacity
translationX, translationYPosition offset
rotationX, rotationY, rotationZ3D rotation
shadowElevationDrop shadow
clip, shapeClipping shape

Changing any property here triggers only a redraw - no recomposition.

The key insight: the lambda version of graphicsLayer captures state reads at draw time, not composition time. This is how Compose knows it can skip the first two phases entirely.

Case Study: WheelPicker - From 25+ to 0 Recompositions per Frame

The Problem

A time picker with scroll wheel behavior: center items appear large and clear, off-center items shrink and fade. The initial implementation:

var centerIndex by remember { mutableIntStateOf(initialIndex) }

// State change every scroll frame → recompose entire LazyColumn
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .collectLatest { centerIndex = it }
}

items(items.size) { index ->
    val isCenter = centerIndex == index
    // 3 animation states per item × 5 visible items = 15 recomposition triggers/frame
    val fontSize by animateFloatAsState(if (isCenter) 32f else 15f)
    val fontWeight by animateFloatAsState(if (isCenter) 700f else 350f)
    val color by animateColorAsState(if (isCenter) Purple600 else Grey400)
}

Cost per scroll frame: ~25+ recompositions - centerIndex state change cascade + 15 Animatable triggers + offscreen compositing buffer.

The Solution: Move Everything into graphicsLayer

items(items.size, key = { it }, contentType = { 0 }) { index ->
    Box(
        Modifier.height(itemHeightDp).graphicsLayer {
            // Calculate item distance from viewport center
            val info = listState.layoutInfo
            val viewportCenter =
                info.viewportStartOffset + info.viewportSize.height / 2f
            val itemCenter = info.visibleItemsInfo
                .firstOrNull { it.index == index + halfVisible }
                ?.let { it.offset + it.size / 2f } ?: viewportCenter

            val distance =
                (abs(itemCenter - viewportCenter) / itemHeightPx).coerceIn(0f, 2f)
            val eased = (distance / 2f).let { it * it }  // quadratic easing

            // All visual effects - zero recomposition
            scaleX = lerp(1f, 0.3f, eased)
            scaleY = scaleX
            alpha = scaleX
            rotationX =
                ((itemCenter - viewportCenter) / itemHeightPx)
                    .coerceIn(-2f, 2f) * -18f
            translationY =
                ((itemCenter - viewportCenter) / itemHeightPx)
                    .coerceIn(-2f, 2f) * 2f
        }
    ) { Text(items[index], style = baseTextStyle) }
}

Result: 0 recompositions per scroll frame. Scale, alpha, rotation, translation - all computed from scroll offset inside the draw phase.

Comparison

BeforeAfter
Recompositions/frame~25+0
Animation states/item3 Animatable0
Phase running effectsCompositionDraw
Offscreen bufferYesNo

The numbers speak for themselves. On mid-range devices, this was the difference between visible jank and buttery-smooth scrolling.

Mobile Action Pro Course

This WheelPicker is part of a real AI quiz app built in the Mobile Action Pro course - where you learn Compose performance patterns by shipping production code.

108 Lessons9 ModulesLifetime Access
See the full curriculum

Supporting Techniques

1. Callback Only on Scroll Settle

// BEFORE: fires every frame
snapshotFlow { listState.firstVisibleItemIndex }
    .collectLatest { centerIndex = it }

// AFTER: fires once when settled
snapshotFlow { listState.isScrollInProgress }
    .distinctUntilChanged()
    .collectLatest {
        if (!it) onSelectedChanged(listState.firstVisibleItemIndex)
    }

This pattern works well with Kotlin Coroutines - snapshotFlow + distinctUntilChanged is far cheaper than per-frame state updates.

2. Gradient Overlay Instead of Offscreen Compositing

// BEFORE: buffer allocation + blend mode
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent { drawRect(blendMode = BlendMode.DstIn) }

// AFTER: gradient drawn on top, no buffer needed
Box(Modifier.matchParentSize().drawWithContent {
    drawContent()
    drawRect(
        Brush.verticalGradient(listOf(White, Transparent)),
        endY = fadeRatio
    )
    drawRect(
        Brush.verticalGradient(listOf(Transparent, White)),
        startY = size.height - fadeRatio,
        endY = size.height
    )
})

Offscreen compositing allocates a separate bitmap buffer - expensive on memory-constrained devices. The gradient overlay approach achieves a similar visual effect without that allocation overhead.

3. LazyColumn Recycling Hints

items(count, key = { it }, contentType = { 0 })

contentType helps LazyColumn reuse composition slots more efficiently when all items share the same type. Combined with stable key, this minimizes the composition work LazyColumn needs to do during scroll.

When to Use graphicsLayer vs animateXAsState?

Use graphicsLayerUse animateXAsState
Scroll-driven effects (LazyColumn, Pager)One-shot transitions (click, toggle)
Many items animating every frameFew items, low frequency
Only need transforms (scale, alpha, rotation)Need content changes (fontSize, color, text)
Performance-critical pathsPrototypes / low-item-count UI

graphicsLayer only supports transform properties - it cannot change actual text size or color. But for scroll animations, scale + alpha typically create an equivalent visual effect at zero cost.

The decision is straightforward: if your animation drives from continuously-changing values (scroll offset, drag position, sensor data), graphicsLayer is almost always the right choice. For discrete state transitions, animateXAsState provides better ergonomics with acceptable performance.

Takeaways

  1. graphicsLayer runs in the Draw phase - skips Composition + Layout entirely
  2. Compute visual effects from scroll offset inside the lambda, not through State
  3. 3D effects are free - rotationX, translationY run in the same phase, adding them costs nothing extra
  4. Use gradient overlays to avoid offscreen compositing overhead
  5. Settle-only callbacks - don't fire state changes every scroll frame

Course

Master Jetpack Compose Performance in Practice

The Mobile Action Pro course covers graphicsLayer, LazyColumn optimization, and production-ready Compose patterns - with a real AI quiz app project where these techniques are applied.

  • Jetpack Compose with performance best practices
  • Clean Architecture, MVVM, Dependency Injection
  • 9 modules, 108 hands-on lessons
  • Lifetime access & community support
Explore Mobile Action Pro

Reading is fun. A roadmap gets you there.

The Kotlin Android Roadmap sorts the Android Mastery, OOP and Design Patterns posts into 5 levels - from your first line of Kotlin to an app on the store.

Plus a new post every week. Unsubscribe anytime.