Skip to content

AmadeyKuspakov/VerticalScroll

Repository files navigation

VerticalScroll

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.

Features

  • 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

Supported targets

Android, iOS (arm64, simulator arm64), JVM (Desktop), JS (browser), WasmJS (browser).

Installation

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.

Usage

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:

  1. The inner lazy list disables its own gesture handling (userScrollEnabled = false) — VerticalScroll owns the gesture and forwards deltas through the registered dispatcher.
  2. 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.

Pagination hooks

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.

Tip: Card corners and overdraw

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.

API

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

Sample

The composeApp module contains a runnable demo with multiple LazyColumns of varying sizes, dynamic insertion / removal of children, and live remeasuring.

VerticalScroll-720.mp4

License

Apache License 2.0 — see LICENSE.

About

VerticalScroll to use in Jetpack Compose UI

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages