Skip to main content
Parallax effect in Jetpack Compose

Parallax effect in Jetpack Compose

·1034 words·5 mins

If you want to add a parallax effect to some content in Jetpack Compose, your first thought might be to maybe use Modifier.graphicsLayer with a translation applied, or maybe even Modifier.offset, but what if I told you that was an even easier way?

When you break down what a parallax effect is, it’s offsetting the position of some content by some factor in the opposite direction, usually driven by a scroll change. The Sketch team wrote up a great overview of what the effect is here:

What is a parallax effect? The definitive guide · Sketch
Everything you need to know about parallax scrolling for websites and apps — including parallax effect examples
www.sketch.com

Aligning the path forward
#

When you look at the API for Compose’s Image composable, it has a little used parameter called alignment , which allows you to change how the image is displayed within the bounds. Typically you’d use the default of Alignment.Center so that your image is centered within the bounds. Alignment provides a bunch of other pre-built instances in it’s companion object:

  • Alignment.Center
  • Alignment.BottomStart
  • Alignment.TopEnd

…and so on. Alignment though is a simple interface which exposes a single function:

@Stable
fun interface Alignment {
    fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset
}

Compose UI also provides an implementation of Alignment called BiasAlignment, which accepts to two Float parameters: horizontalBias and verticalBias, which bias the position of the relevant axis. For horizontal, -1f aligns the start edge of the image with the start edge of the layout, 1f doing the opposite on the end edge.

In fact, all of the pre-built instances we listed above are instances of BiasAlignment:

  • Alignment.Center = BiasAlignment(h = 0f, v = 0f)
  • Alignment.BottomStart = BiasAlignment(h = 1f, v = -1f)
  • Alignment.TopEnd = BiasAlignment(h = -1f, v = 1f)

Hopefully at this point you can see that we can use this to implement our parallax effect! We can provide a BiasAlignment with the bias computed from the scroll offset.

// Read the LazyListState layout info
val layoutInfo = lazyListState.layoutInfo
// Find the layout info of this item
val itemInfo = layoutInfo.visibleItemsInfo.first {
    it.key == TODO("Implement this as appropriate")
}

val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset
val parallax = (adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f)

Image(
    painter = ...,
    alignment = BiasAlignment(horizontalBias = parallax),
)

And yep, works perfectly.

Optimizing the state reads
#

The keen-eyed among you will notice though that we’re reading the scroll offset in composition, and then creating a new BiasAlignment every time it changes, causing a recomposition. Not great for performance, especially for something which changes very frequently, like scroll positions.

To optimize this, we should look to the Compose Performance guide:

Defer state reads for as long as possible. By moving state reading to a child composable or a later phase, you can minimize recomposition or skip the composition phase entirely.

Unfortunately the built-in BiasAlignment doesn’t provide a way to defer the state read, forcing us to provide constant Float values. However, it’s such a simple class that it’s easy to import the class and optimize it for our needs:

@Stable
class ParallaxAlignment(
    private val horizontalBias: () -> Float = { 0f },
    private val verticalBias: () -> Float = { 0f },
) : Alignment {
    override fun align(
        size: IntSize,
        space: IntSize,
        layoutDirection: LayoutDirection,
    ): IntOffset {
        // Convert to Px first and only round at the end, to avoid rounding twice while calculating
        // the new positions
        val centerX = (space.width - size.width).toFloat() / 2f
        val centerY = (space.height - size.height).toFloat() / 2f
        val resolvedHorizontalBias = if (layoutDirection == LayoutDirection.Ltr) {
            horizontalBias()
        } else {
            -1 * horizontalBias()
        }

        val x = centerX * (1 + resolvedHorizontalBias)
        val y = centerY * (1 + verticalBias())
        return IntOffset(x.roundToInt(), y.roundToInt())
    }
}

The only changes in this class from BiasAlignment are that the parameters are now lambdas rather than constant Floats. Since the align function will be called from the image’s Modifier.paint during a draw pass, and we’re invoking the lambdas from that function, we’re automatically deferring the state read to the draw ‘phase’. 🦾

If we update our code snippet above to use our new ParallaxAlignment, it results in us using a single ParallaxAlignment per item, with scroll position changes not causing a recomposition:

Image(
    painter = ...,
    alignment = remember(lazyListState) {
        ParallaxAlignment(
            horizontalBias = {
                // Read the LazyListState layout info
                val layoutInfo = lazyListState.layoutInfo
                // Find the layout info of this item
                val itemInfo = layoutInfo.visibleItemsInfo.first {
                    it.key == TODO("Implement this as appropriate")
                }

                val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset
                (adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f)
            }
        )
    }
)

Going beyond images
#

When applying a parallax effect, you might want to apply it to some abitrary content, rather than just an image, so how can we do that with Alignment?

Since Alignment is a multi-purpose tool in Compose UI, we can use the same mechanism anywhere an alignment is used. The most commonly used API using alignment is probably Modifier.align() used in a Box composable.

Here I’ve updated the snippet from earlier, so that the show title Text() has a parallax effect applied too:

Box {
    // Image, etc

    Text(
        text = show.title,
        modifier = Modifier
            .align(
                remember(lazyListState) {
                    ParallaxAlignment(
                        horizontalBias = {
                            // same code as above
                        }
                    )
                }
            )
    )
}

Why is this better than other methods?
#

Typically when applying a parallax effect you only want the image to ‘scroll’ within its bounds. You probably do not want the image scrolling past either edge. With a BiasAlignment (or our class) we can do that very easily: we just coerce the bias between -1f and 1f.

When using Modifier.offset or a graphics layer translation, that is much more difficult, you’re translating the whole layout, not the contents inside. You need to calculate what the scaled image’s bounds actually are. All of that information is computed within the Image composable (actually Modifier.paint), so you’ll need to compute it yourself, and then interpolate between the edges. Why make life harder for yourself?

Alignment.Parallax
#

Hopefully this was useful, and you can see how this pattern can be used to apply a parallax effect across Compose UI. Let me know if this works for you!