Skip to content

Nested Scrolling in Jetpack Compose

Nested scrolling in Jetpack Compose enables coordinated scrolling between parent and child components by propagating unconsumed scroll deltas through the hierarchy.

By default, Compose’s scrollable and Lazy APIs handle nested scrolling automatically, handing off remaining scroll to ancestors when the child reaches its limit. For custom interactions, such as collapsing toolbars or synchronized panels, Compose offers NestedScrollConnection and NestedScrollDispatcher to intercept and dispatch scroll before and after the child composable processes it.

Understanding Nested Scrolling

Nested scrolling is the system where multiple scrollable components within each other coordinate scroll gestures by communicating consumed and unconsumed deltas.

Compose exposes nested scrolling via:

  • Modifier.nestedScroll(connection, dispatcher?): attaches a component to the nested scroll chain.
  • NestedScrollConnection: override onPreScroll/onPostScroll to consume or react to deltas before and after children process scroll.
  • NestedScrollDispatcher: dispatch scroll/fling events upstream when building custom scrollables.

To take manual control, create a NestedScrollConnection by overriding its callbacks.

val nestedConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            // intercept before child scrolls
            return Offset.Zero
        }
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            // react after child scrolls
            return Offset.Zero
        }
    }
}

This connection lets you consume or react to scroll deltas before and after the child handles them.

After that link your connection to a parent container using Modifier.nestedScroll, so it joins the nested‑scroll chain.

Box(
    Modifier
        .fillMaxSize()
        .nestedScroll(nestedConnection)
) {
    // Place child scrollable(s) here
}

Inside onPreScroll, adjust a header’s height state and return how much you consumed. For example:

val toolbarHeight = remember { mutableStateOf(maxHeightDp) }
val connection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            val deltaY = available.y
            val newHeight = (toolbarHeight.value + deltaY)
                .coerceIn(minHeightDp, maxHeightDp)
            val consumed = newHeight - toolbarHeight.value
            toolbarHeight.value = newHeight
            return Offset(0f, consumed)
        }
    }
}

This ensures the header collapses as you scroll up and expands on scroll down.

Wrap a LazyColumn and your header in a Box with Modifier.nestedScroll(connection). Tie the header’s height and offset modifiers to the state updated in onPreScroll:

@Composable
fun CollapsingToolbarScreen() {
    val minHeight = 56.dp
    val maxHeight = 200.dp
    val maxHeightPx = with(LocalDensity.current) { maxHeight.toPx() }
    val offsetY = remember { mutableStateOf(0f) }

    val connection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                val delta = available.y
                val newOffset = (offsetY.value + delta)
                    .coerceIn(-maxHeightPx, 0f)
                val consumed = newOffset - offsetY.value
                offsetY.value = newOffset
                return Offset(0f, consumed)
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .nestedScroll(connection)
    ) {
        LazyColumn(contentPadding = PaddingValues(top = maxHeight)) {
            items(100) {
                Text("Item #$it", Modifier.padding(16.dp))
            }
        }
        TopAppBar(
            title = { Text("Title") },
            modifier = Modifier
                .height(maxHeight + offsetY.value.toDp())
                .offset { IntOffset(0, offsetY.value.roundToInt()) }
        )
    }
}

This ensures both your LazyColumn and Toolbar scroll at the same time.


You can learn more about this at Nested scrolling | Jetpack Compose Tips.

I hope you enjoyed this article, feel free to contact me if you need anything. See you next time.

Photo by Ananya Mittal on Unsplash.