
Jetpack Compose and Nested Scrolling
January 15, 2025
Jetpack Compose and Nested Scrolling
Jetpack Compose is a great way to implement your UI. It allows us to easily create screens that look great, and finally, we, Android developers, can forget about XML. That’s how Jetpack Compose enthusiasts and evangelists paint the world. But is it that simple? At Paramount, we work on streaming applications. If you’ve ever seen one, you know that its most basic concept is a carousel of posters. The user needs an easy and intuitive way to pick a movie or series to watch. A common feature in such applications is stacking multiple carousels vertically.
It really can be done in an effortless and elegant way with Jetpack Compose. The code would look something like this:
LazyColumn {
items(carousels) { carousel ->
Column {
Title(carousel.title)
Spacer(…)
LazyRow {
items(carousel.items) { item ->
Poster(item)
}
}
}
}
}
And that’s it. We have a LazyColumn
, so we’re efficient in terms of memory, rendering only the carousels visible to the user, LazyRow
takes care of rendering only the posters that are visible. We can scroll horizontally or vertically — we did it! What required extensive XML boilerplate is now handled in just a few lines of code. What to complain about?
Let’s make this example more complicated. Let’s imagine that we need to change how we display the carousel. Now, instead of presenting its items in a row, we want all of them on our screen in a grid.
So, it’s just a simple UI change — it shouldn’t be that hard, right? Instead of a LazyRow,
we need to use a LazyVerticalGrid,
and we should be fine.
LazyColumn {
items(carousels) { carousel ->
Column {
Title(carousel.title)
Spacer(…)
LazyVerticalGrid(…) {
items(carousel.items) { item ->
Poster(item)
}
}
}
}
}
The code also looks nice, except for the fact that it doesn’t work. When we open the screen, we’re getting an exception thrown at us:
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.
Due to the length of the message, even reading it is quite challenging. But at least it’s expressive and provides a solution—so that’s good, right? Well, not exactly. This isn’t surprising, though, as Google had already warned us about it.
Avoid nesting components scrollable in the same direction
In the link above, there’s more about this topic, and the solution proposed by the exception message is explained in detail. The problem is it doesn’t work with our problem. They’re saying that instead of nesting scrollable columns inside each other, we should aim at only one nesting level. So, instead of having:
Column(Modifier.verticalScroll(…)) {
Header()
LazyColumn {
items(contentItems) {
Item(it)
}
}
Footer()
}
We should have something like this:
LazyColumn {
item { Header() }
items(contentItems) {
Item(it)
}
item { Footer() }
}
It makes perfect sense for such a simple use case but in our case… We’re paging the carousels data, and based on the data inside of paged carousels, we’re displaying other paged items. So, in our case, the code would look like this:
LazyColumn {
items(carousels) { carousel ->
Column {
Title(carousel.title)
Spacer(…)
items(carousel.items) { item ->
Poster(item)
}
}
}
}
If there’s a red light in your head that is alarming you about using items nested inside of items, your intuition is correct. This code will compile. It will launch. Moreover, it may even look like it’s working until you start to scroll. That is when everything goes wrong, and your app becomes unresponsive and crashes with a beautiful Application Not Responding error.
This solution won’t work for us. Perhaps we should consider how we could have solved that issue in legacy XML. There, we had NestedScrollView
, which could host RecyclerView
, and could handle scrolling for us. Another option would be to use a single RecyclerView
with several adapters chained one after another, using ConcatAdapter
. So, a single RecyclerView
could have multiple adapters that load their items as we scroll. That sounds exactly what we want to achieve here, making it worth porting to the Compose world.
Our main Composable would be a LazyVerticalGrid
, inside which we’d display multiple grids with items. For example, we could have a grid, where a span is used to place a title, followed by carousel items. Implementation would look something like this:
LazyVerticalGrid(…) {
items(
items = carousels,
span = GridItemSpan(x),
) { carousel ->
Title()
items(carousel.items) { poster ->
Item(poster)
}
}
}
And we’re back at the place that we’ve already been before. We’re calling items inside of items, which Compose doesn’t like (but it won’t tell you until runtime). In other words - this approach also won’t work for us.
So here we are, left with a design that is simple yet seems out of our reach. But whatever you do, don’t mention that to the design team. We’d rather not endure another round of their “hilarious” Android jokes. Luckily, we still have a few tricks up our sleeve.
Let’s read the exception message that left an ugly bruise on our software developer pride: “Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed”. So, an infinity maximum height constraint is what causes the problem. We do know how to change height constraints, don’t we? There’s a Modifier.height(...)
, which can be used to set the height of our composable. Even AI assistants (regardless of the brand) suggest this solution, reinforcing its validity. That must be the right path! Let’s try it then:
LazyColumn {
items(carousels) { carousel ->
Column {
Title(carousel.title)
Spacer(…)
LazyVerticalGrid(
…,
modifier = Modifier.height(99999999.dp),
) {
items(carousel.items) { item ->
Poster(item)
}
}
}
}
}
The 99999999.dp
value is picked arbitrarily, and it is smaller than infinity, so it should be good, right? Now our inner grid will take 99999999.dp
, and we will be able to place it inside of our LazyColumn. After running this code, surprise, surprise, we’re getting another exception:
java.lang.IllegalArgumentException: Can’t represent width of 0 and height of 13312499 in Constraints
At least it’s different from before. Right now, Compose is complaining that the height is too big to render on screen. We could try with smaller values, but we won’t find a value that would work on every device, especially since those limits may differ depending on the device on which we’re running our app. Even if we find an acceptable height, say 5000.dp
, we have more problems to face.
- Our grid would always be
5000.dp
high, even if there’s just one item inside. So, the grids below would be hard for the user to reach, as they might not fit on the same screen, creating an illusion of only one carousel available.
- Even such a huge height might not be enough to fit all our items. If we have enough items to fill the entire height, they will eventually become cut off, and we’ll be back at square one.
- The grid’s laziness is broken, as even if there’s only 1 row visible, we will keep items fitting that
5000.dp
in our composition. That might be too much for a smooth user experience. - We want to support paging, so hardcoding height like that wouldn’t really work well. We would load more pages until we reached that
5000.dp
height or load all items, which is against the whole idea of paging.
Are we doomed? Not necessarily. Fortunately, there’s one more thing that we could do. Amongst many modifiers that we can use, there’s one that can save us here. Modifier.heightIn
. This modifier accepts two optional parameters - min and max – allowing us to set height limits for our grid, preventing it from extending beyond a predefined value. Let’s try to use it with some predefined max value, like 5000.dp
:
LazyColumn {
items(carousels) { carousel ->
Column {
Title(carousel.title)
Spacer(…)
LazyVerticalGrid(
…,
modifier = Modifier.heightIn(max = 5000.dp),
) {
items(carousel.items) { item ->
Poster(item)
}
}
}
}
}
Now our inner grids will take as much height as they need, but not more than 5000.dp
. This solution eliminates the problem of huge gaps between grids because if the grid needs just 10.dp
- it will take no more than that. The compiler will also be happy because we removed two scrollable containers with infinite height. But we’re still having the problem with:
- broken paging,
- possibility that this value is too big not to crush the app,
- the cut-off, when our items take so much space that our limit is too small.
Yet, there’s one safe value that can be rendered, and with some additional logic, we could use it to solve our problem. That value is a viewport height. In fact, if we think about it, that’s what the user will ever see. In case we have just one grid with many items, the user can only see as many as fit on the screen. But that requires additional logic, as we would scroll the lazy column, and our grid wouldn’t scroll.
Scrolling the LazyColumn:
Scrolling the LazyVerticalGrid:
However, if we modified the behavior of our scrollables in such a way that we’re scrolling only the one that we need to scroll and not the other one, we could implement the desired behavior. Let me explain it. Let’s say that we have a LazyColumn
with three grids. 1st grid has 1 row of items, 2nd and 3rd have 10 rows each. Let’s assume that we can fit on a single screen only 6 rows. We’re limiting each grid’s height to the maximum viewport height. So initially, we’re seeing on our screen the entire first grid and five rows of 2nd grid. Now we should intercept any scroll events to the LazyColumn
or the visible grids so that we’re scrolling the LazyColumn
, not its children. 1st grid cannot be scrolled either way, and in the case of 2nd grid, we don’t want it to scroll yet, as it would mean that we would still see 1st grid’s items, and we could scroll away from the 1st row of 2nd grid. Instead, we want to scroll LazyColumn
until the 2nd grid is the only one visible to the user. The gif below represents this phase. The red border shows the area visible to the user; everything outside of it is what is happening under the hood.
When LazyVerticalGrid
fills the entire viewport of LazyColumn
, we want to dispatch all scroll events to it. So now, for as long as the second grid can be scrolled forward, we’re scrolling it.
Finally, when we get to the point where it can’t be scrolled anymore, we’re again dispatching scroll events to the LazyColumn
, so that only the 3rd grid becomes visible. Then we’re dispatching scroll events to the 3rd grid for as long as we can scroll it. And the same logic applies to upward scrolling.
Fortunately, Jetpack Compose has a modifier, which can do that. It’s Modifier.nestedScroll
. It allows us to register a listener that takes part in nested scrolling. Google has excellent documentation about that, so I’ll send you there to get more details. What is important for us is that we can add this modifier to any composable in the hierarchy, and it will participate in the nested scrolling cycle of all its children. Whenever a child is scrolled, we will receive that scroll offset. Then we can decide how much of the offset we want to consume. The originator of the scroll event will handle the unconsumed offset. Unfortunately, we cannot identify which Composable was the one that was scrolled, but we can work without it. We want to consume the entire offset and manage which child will be scrolled with it. The usage of this modifier looks like that:
val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return super.onPreScroll(available, source)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return super.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
return super.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return super.onPostFling(consumed, available)
}
}
LazyColumn(modifier = Modifier.nestedScroll(nestedScrollConnection)) {
...
}
We have several ways of intercepting the scroll offset. What is important for our case is onPreScroll
. From there, we’ll determine which container is scrolling. But how can we tell a column/grid to scroll?
Each Lazy container has a state we can use as a handle. For LazyRow
and LazyColumn
it is LazyListState.
For LazyVerticalGrid
/LazyHorizontalGrid
it is LazyGridState
. Each state implements the ScrollableState
interface, which has a dispatchRawData
method, which allows us to scroll the container in such a way that this offset is not passed to the nested scroll connection; hence, it allows us to avoid a case where we’re handling the same offset again and again. If we use standard scrollBy API, that offset would be sent back to our nested scroll connection to be processed repeatedly. A very simplified solution could look like this:
val lazyColumnState = rememberLazyListState()
val lazyGridStates = remember { List(3) { LazyGridState() } }
val nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
when (determineWhichToScroll()) {
ContainerToScroll.Column -> lazyColumnState.dispatchRawDelta(available.y)
ContainerToScroll.Grid0 -> lazyGridStates[0].dispatchRawDelta(available.y)
ContainerToScroll.Grid1 -> lazyGridStates[1].dispatchRawDelta(available.y)
ContainerToScroll.Grid2 -> lazyGridStates[2].dispatchRawDelta(available.y)
}
return available
}
}
LazyColumn(
modifier = Modifier.nestedScroll(nestedScrollConnection),
state = lazyColumnState,
) {
item {
LazyVerticalGrid(
state = lazyGridStates[0],
columns = GridCells.Fixed(3),
) {
items(carouselItems) { Poster(it) }
}
}
item {
LazyVerticalGrid(
state = lazyGridStates[1],
columns = GridCells.Fixed(3),
) {
items(carouselItems) { Poster(it) }
}
}
item {
LazyVerticalGrid(
state = lazyGridStates[2],
columns = GridCells.Fixed(3),
) {
items(carouselItems) { Poster(it) }
}
}
}
Of course, the code above has several problems. We should remember the CarouselsColumnConnection,
not recreate it over and over. We have hardcoded 3 Grid carousels, whereas our complete solution needs to handle various amounts of Grids. The logic determining which container to scroll is also skipped in the code above. But most importantly it would be too easy to scroll it like that. We have several grids that take the height of the entire viewport, so we need to ensure that when the user scrolls quickly, we don’t pass the entire offset to the column. We need to pass an amount that is enough to make the grid displayed at the top, and the rest of the offset should be passed to the grid. There might be an edge case: after scrolling the column and grid, we will have to scroll again, as the grid is no longer scrollable in that direction.
The code below contains the complete NestedScrollConnection
code, which might be a bit overwhelming.
class NestedVerticalGridsScrollConnection(
private val lazyColumnState: LazyListState,
private val innerScrollableStates: List<ScrollableState>,
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (available.y == 0f) {
return Offset.Zero
}
val isScrollingDown = available.y < 0
var offsetToProcess = abs(available.y)
while (offsetToProcess != 0f) {
if (lazyColumnState.hasReachedScrollBoundary(isScrollingDown)) {
// LazyColumn can not scroll anymore.
// Don't consume offset, to display overscroll effect.
return Offset.Zero
}
when (val gridScrollData = buildScrollData(isScrollingDown)) {
ScrollData.NoVerticalScrollable -> {
return Offset.Zero
}
is ScrollData.VerticalScrollData -> {
offsetToProcess -= processOffset(
offset = offsetToProcess,
isScrollingDown = isScrollingDown,
scrollData = gridScrollData,
)
}
}
}
return available
}
private fun buildScrollData(isScrollingDown: Boolean): ScrollData {
val verticalScrollables = lazyColumnState.layoutInfo.visibleItemsInfo.filter {
innerScrollableStates[it.index]?.isVertical() == true
}
if (verticalScrollables.isEmpty()) return ScrollData.NoVerticalScrollable
val scrollableToCheck = if (isScrollingDown) {
verticalScrollables.last()
} else {
verticalScrollables.first()
}
val lazyState = innerScrollableStates[scrollableToCheck.index]!!
val canScroll = if (isScrollingDown) {
lazyState.canScrollForward
} else {
lazyState.canScrollBackward
}
val scrollableStartOffset = scrollableToCheck.offset
val scrollBoundaryOffset = if (isScrollingDown) {
-lazyColumnState.layoutInfo.beforeContentPadding - lazyColumnState.layoutInfo.afterContentPadding
} else {
0
}
val desiredOffset =
if (!canScroll && scrollableStartOffset == -lazyColumnState.layoutInfo.beforeContentPadding) {
scrollBoundaryOffset
} else {
-lazyColumnState.layoutInfo.beforeContentPadding
}
return ScrollData.VerticalScrollData(
visibleVerticalItemsCount = lazyColumnState.layoutInfo.visibleItemsInfo.size,
scrollableState = lazyState,
diffBetweenDesiredAndCurrentOffset = abs(desiredOffset - scrollableStartOffset).toFloat(),
isAtDesiredOffsetAndCanBeScrolled = scrollableStartOffset == desiredOffset && canScroll,
)
}
private fun processOffset(
offset: Float,
isScrollingDown: Boolean,
scrollData: ScrollData.VerticalScrollData,
): Float {
return when {
scrollData.visibleVerticalItemsCount > 1 -> {
scrollColumn(
offset = offset,
isScrollingDown = isScrollingDown,
withLimit = scrollData.diffBetweenDesiredAndCurrentOffset,
)
}
scrollData.isAtDesiredOffsetAndCanBeScrolled -> {
scrollInnerScrollable(
offset = offset,
scrollableState = scrollData.scrollableState,
isScrollingDown = isScrollingDown,
)
}
else -> {
val limit = if (scrollData.diffBetweenDesiredAndCurrentOffset == 0f) {
lazyColumnState.layoutInfo.mainAxisItemSpacing.coerceAtLeast(1).toFloat()
} else {
scrollData.diffBetweenDesiredAndCurrentOffset
}
scrollColumn(
offset = offset,
isScrollingDown = isScrollingDown,
withLimit = limit,
)
}
}
}
private fun scrollColumn(
offset: Float,
isScrollingDown: Boolean,
withLimit: Float = Float.MAX_VALUE,
): Float {
val toConsume = min(offset, withLimit)
return if (isScrollingDown) {
lazyColumnState.dispatchRawDelta(toConsume)
} else {
-lazyColumnState.dispatchRawDelta(-toConsume)
}
}
private fun scrollInnerScrollable(
offset: Float,
scrollableState: ScrollableState,
isScrollingDown: Boolean,
): Float {
return if (isScrollingDown) {
scrollableState.dispatchRawDelta(offset)
} else {
-scrollableState.dispatchRawDelta(-offset)
}
}
private fun LazyListState.hasReachedScrollBoundary(isScrollingDown: Boolean): Boolean {
return if (isScrollingDown) {
val endOffset = layoutInfo.viewportSize.height - layoutInfo.beforeContentPadding
!canScrollForward && layoutInfo.viewportEndOffset == endOffset
} else {
!canScrollBackward && layoutInfo.viewportStartOffset == -layoutInfo.beforeContentPadding
}
}
private sealed interface ScrollData {
data object NoVerticalScrollable : ScrollData
data class VerticalScrollData(
val visibleVerticalItemsCount: Int,
val scrollableState: ScrollableState,
val diffBetweenDesiredAndCurrentOffset: Float,
val isAtDesiredOffsetAndCanBeScrolled: Boolean,
) : ScrollData
}
}
There’s one more problem now - how to pass scroll states of LazyVerticalGrid
s that are created dynamically - in the end we’re paging carousels too. That part is easier than it seems to be. We can create a class that will hold and create LazyGridState
s whenever it is asked for one.
class InnerScrollablesState {
private val _gridStates: MutableList<ScrollableState> = mutableMapOf()
val gridStates: List<ScrollableState> = _gridStates
fun getOrCreateStateForGrid(carouselIndex: Int): LazyGridState {
val scrollableState = _gridStates[carouselIndex]
return if (scrollableState != null) {
scrollableState as LazyGridState
} else {
LazyGridState().also {
_gridStates.add(it)
}
}
}
fun clearState() {
_gridStates.clear()
}
}
Now, we can quite easily create new LazyGridState
s and have access to the list of all ScrollableState
s from our NestedVerticalGridsScrollConnection
.
val lazyColumnState = rememberLazyListState()
val innerScrollablesState = remember { InnerScrollablesState() }
val nestedScrollConnection = remember {
NestedVerticalGridsScrollConnection(
lazyColumnState,
innerScrollablesState.gridStates,
)
}
val viewportHeight = with(LocalDensity.current) {
lazyColumnState.layoutInfo.viewportSize.height.toDp()
}
LazyColumn(
modifier = Modifier.nestedScroll(nestedScrollConnection),
state = lazyColumnState,
) {
itemsIndexed(carousels) { index, carousel ->
LazyVerticalGrid(
state = innerScrollablesState.getOrCreateStateForGrid(index),
columns = GridCells.Fixed(3),
modifier = Modifier.heightIn(max = viewportHeight),
) {
items(carousel.items) { Poster(it) }
}
}
}
Finally, we have a solution that is working! But… Yes, there’s a “but”… It still has some limitations. For example, imagine we have a paging data source that doesn’t support placeholders. In that case, we might end up in such a situation where we’ve scrolled to the end of the inner grid, scrolled the lazy column to the middle, and then suddenly, our grid becomes larger as more items are loaded. With placeholders, such a scenario wouldn’t appear, as we would have to scroll over the placeholders - in that scenario, the worst that can happen would be to display more placeholders than items that we have in a dataset. That would mean that our grid could become smaller than it was with placeholders in place.
Another limitation is how this code behaves on Android TV. Since Google deprecated TvLazy*
containers, we should be able to reuse this code on Android TV. We can do it, but there’s another problem with scrolling when moving focus inside a grid… It’s related to LocalBringIntoViewSpec,
which gets nested, and now we need to handle it somehow. But that’s something that must be covered in a separate article :) Here, you can see how the problem is manifests on the Search screen in the Paramount+ app. The grid scrolls to the top after focusing on the last row item, even though no one requested it.
Thank you for staying with me and enjoy your journey with Compose!