A Compose Multiplatform vertical scroll container that scrolls cleanly through nested scrollable children — LazyColumn and LazyVerticalGrid are wired in directly, and any other scrollable can be plugged in through a custom dispatcher.
Stacking lazy lists inside a regular scroll container in Compose is normally broken: the outer scroll either swallows the gesture or the inner list does, and the user ends up fighting both. VerticalScroll resolves the conflict by routing scroll deltas to the currently visible nested child, falling through to the next one once that child reaches its own bounds.
- Mix non-scrollable content and lazy lists inside a single vertical scroll container
- Sequential delta consumption across multiple nested children in one fling
- Stable item keys with content reconciliation on insert / delete / resize
- Configurable spacing and out-of-bounds overdraw region
- No platform-specific code — pure Compose Multiplatform
- Reacts to runtime layout changes without losing scroll position:
- nested children resizing (e.g. an inner list gaining or losing items)
- viewport shrinking or expanding (e.g. keyboard show / hide animations)
- children being inserted into or removed from the scope
Android, iOS (arm64, simulator arm64), JVM (Desktop), JS (browser), WasmJS (browser).
Available on Maven Central:
// build.gradle.kts
dependencies {
implementation("io.github.amadeykuspakov:vertical-scroll:1.0.0-alpha")
}Requires Compose Multiplatform 1.10.3 or newer and Kotlin 2.3.20 or newer.
Wrap each nested scrollable in nestedScrollContainer and bind its state to the container through one of the define*Dispatcher helpers:
import com.uikit.verticalscroll.VerticalScroll
VerticalScroll(
modifier = Modifier.fillMaxSize(),
spacing = 8.dp,
outOfBoundsAdditionalHeight = 60.dp,
) {
nestedScrollContainer(key = "list-a") {
val scrollState = rememberLazyListState()
defineLazyListDispatcher(scrollState)
LazyColumn(
state = scrollState,
userScrollEnabled = false,
) {
items(100) { Text("Item $it") }
}
}
nestedScrollContainer(key = "list-b") {
// another LazyColumn / LazyVerticalGrid / etc.
}
}Two things to note:
- The inner lazy list disables its own gesture handling (
userScrollEnabled = false) —VerticalScrollowns the gesture and forwards deltas through the registered dispatcher. - Any delta the inner list doesn't consume flows back to
VerticalScroll, which then translates the outer container or hands the leftover to the next nested child.
For non-scrollable items, the same nestedScrollContainer block can simply omit the dispatcher call.
defineLazyListDispatcher and defineLazyGridDispatcher accept an optional OnBoundsReached callback that fires when the nested list's own scroll has reached its top or bottom — the natural place to trigger "load next page" / "load previous page" behaviour:
nestedScrollContainer(key = "list-a") {
val scrollState = rememberLazyListState()
val boundsCallback = remember {
object : VerticalScrollScope.OnBoundsReached {
override fun onBottomReached() { viewModel.loadNextPage() }
override fun onCeilingReached() { /* optional */ }
}
}
defineLazyListDispatcher(scrollState, boundsCallback)
LazyColumn(state = scrollState, userScrollEnabled = false) { /* ... */ }
}For scrollables that aren't a LazyListState or LazyGridState, use defineCustomDispatcher(...) and supply a Dispatcher of your own — the same dispatchRawDelta / snapToCeiling / snapToBottom / bounds callbacks, implemented against whatever state your component exposes.
When a nested LazyColumn lives inside a rounded Card, a positive outOfBoundsAdditionalHeight lets the card's top and bottom corners scroll past the container edges before being clipped, giving a continuous nested-scroll feel instead of a hard cutoff at the rounded corner. 60.dp is the value used in the sample app and works well in practice across screen sizes — push it higher only if your card radius is unusually large, since the overdraw region is measured but offscreen, and overly generous values trade away the resource savings that make this component closer to LazyColumn than to Column.
| Symbol | Purpose |
|---|---|
VerticalScroll(modifier, spacing, outOfBoundsAdditionalHeight, content) |
Root container composable |
VerticalScrollScope.nestedScrollContainer(key, content) |
Declares a child slot; key is used for stable identity across recompositions |
DispatchEditor.defineLazyListDispatcher(state, onBoundsReached?) |
Wires a LazyColumn (its LazyListState) into the nested-scroll flow |
DispatchEditor.defineLazyGridDispatcher(state, onBoundsReached?) |
Same, for LazyVerticalGrid (LazyGridState) |
DispatchEditor.defineCustomDispatcher(dispatcher) |
Plug in any custom scrollable by implementing Dispatcher |
OnBoundsReached |
Callback interface with onCeilingReached / onBottomReached, used for pagination |
The composeApp module contains a runnable demo with multiple LazyColumns of varying sizes, dynamic insertion / removal of children, and live remeasuring.
VerticalScroll-720.mp4
Apache License 2.0 — see LICENSE.