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:
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 Float
s. 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!