nestedScroll

Compose Modifier

Common
fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null,
): Modifier

Modify element to make it participate in the nested scrolling hierarchy.

There are two ways to participate in the nested scroll: as a scrolling child by dispatching scrolling events via NestedScrollDispatcher to the nested scroll chain; and as a member of nested scroll chain by providing NestedScrollConnection, which will be called when another nested scrolling child below dispatches scrolling events.

It's mandatory to participate as a NestedScrollConnection in the chain, but dispatching scrolling events is optional since there are cases where an element wants to participate in nested scrolling without being directly scrollable.

Here's the collapsing toolbar example that participates in a chain, but doesn't dispatch:

On the other side, dispatch via NestedScrollDispatcher is optional. It's needed if a component is able to receive and react to the drag/fling events and you want this components to be able to notify parents when scroll occurs, resulting in better overall coordination.

Here's the example of the component that is draggable and dispatches nested scroll to participate in the nested scroll chain:

Note: It is recommended to reuse NestedScrollConnection and NestedScrollDispatcher objects between recompositions since different object will cause nested scroll graph to be recalculated unnecessary.

There are 4 main phases in nested scrolling system:

  1. Pre-scroll. This callback is triggered when the descendant is about to perform a scroll operation and gives parent an opportunity to consume part of child's delta beforehand. This pass should happen every time scrollable components receives delta and dispatches it via NestedScrollDispatcher. Dispatching child should take into account how much all ancestors above the hierarchy consumed and adjust the consumption accordingly.
  2. Post-scroll. This callback is triggered when the descendant consumed the delta already (after taking into account what parents pre-consumed in 1.) and wants to notify the ancestors with the amount of delta unconsumed. This pass should happen every time scrollable components receives delta and dispatches it via NestedScrollDispatcher. Any parent that receives NestedScrollConnection.onPostScroll should consume no more than left and return the amount consumed.
  3. Pre-fling. Pass that happens when the scrolling descendant stopped dragging and about to fling with the some velocity. This callback allows ancestors to consume part of the velocity. This pass should happen before the fling itself happens. Similar to pre-scroll, parent can consume part of the velocity and nodes below (including the dispatching child) should adjust their logic to accommodate only the velocity left.
  4. Post-fling. Pass that happens after the scrolling descendant stopped flinging and wants to notify ancestors about that fact, providing velocity left to consume as a part of this. This pass should happen after the fling itself happens on the scrolling child. Ancestors of the dispatching node will have opportunity to fling themselves with the velocityLeft provided. Parent must call notifySelfFinish callback in order to continue the propagation of the velocity that is left to ancestors above.

androidx.compose.foundation.lazy.LazyColumn, androidx.compose.foundation.verticalScroll and androidx.compose.foundation.gestures.scrollable have build in support for nested scrolling, however, it's desirable to be able to react and influence their scroll via nested scroll system.

Note: The nested scroll system is orientation independent. This mean it is based off the screen direction (x and y coordinates) rather than being locked to a specific orientation.

Parameters

connectionconnection to the nested scroll system to participate in the event chaining, receiving events when scrollable descendant is being scrolled.
dispatcherobject to be attached to the nested scroll system on which dispatch* methods can be called to notify ancestors within nested scroll system about scrolling happening

Code Examples

NestedScrollConnectionSample

@Composable
fun NestedScrollConnectionSample() {
    // here we use LazyColumn that has build-in nested scroll, but we want to act like a
    // parent for this LazyColumn and participate in its nested scroll.
    // Let's make a collapsing toolbar for LazyColumn
    val toolbarHeight = 48.dp
    val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
    // our offset to collapse toolbar
    val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
    // now, let's create connection to the nested scroll system and listen to the scroll
    // happening inside child LazyColumn
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // try to consume before LazyColumn to collapse toolbar if needed, hence pre-scroll
                val delta = available.y
                val newOffset = toolbarOffsetHeightPx.value + delta
                toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
                // here's the catch: let's pretend we consumed 0 in any case, since we want
                // LazyColumn to scroll anyway for good UX
                // We're basically watching scroll without taking it
                return Offset.Zero
            }
        }
    }
    Box(
        Modifier.fillMaxSize()
            // attach as a parent to the nested scroll system
            .nestedScroll(nestedScrollConnection)
    ) {
        // our list with build in nested scroll support that will notify us about its scroll
        LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) {
            items(100) { index ->
                Text("I'm item $index", modifier = Modifier.fillMaxWidth().padding(16.dp))
            }
        }
        TopAppBar(
            modifier =
                Modifier.height(toolbarHeight).offset {
                    IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt())
                },
            title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") },
        )
    }
}

NestedScrollDispatcherSample

@Composable
fun NestedScrollDispatcherSample() {
    // Let's take Modifier.draggable (which doesn't have nested scroll build in, unlike Modifier
    // .scrollable) and add nested scroll support our component that contains draggable
    // this will be a generic components that will work inside other nested scroll components.
    // put it inside LazyColumn or / Modifier.verticalScroll to see how they will interact
    // first, state and it's bounds
    val basicState = remember { mutableStateOf(0f) }
    val minBound = -100f
    val maxBound = 100f
    // lambda to update state and return amount consumed
    val onNewDelta: (Float) -> Float = { delta ->
        val oldState = basicState.value
        val newState = (basicState.value + delta).coerceIn(minBound, maxBound)
        basicState.value = newState
        newState - oldState
    }
    // create a dispatcher to dispatch nested scroll events (participate like a nested scroll child)
    val nestedScrollDispatcher = remember { NestedScrollDispatcher() }
    // create nested scroll connection to react to nested scroll events (participate like a parent)
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource,
            ): Offset {
                // we have no fling, so we're interested in the regular post scroll cycle
                // let's try to consume what's left if we need and return the amount consumed
                val vertical = available.y
                val weConsumed = onNewDelta(vertical)
                return Offset(x = 0f, y = weConsumed)
            }
        }
    }
    Box(
        Modifier.size(100.dp)
            .background(Color.LightGray)
            // attach ourselves to nested scroll system
            .nestedScroll(connection = nestedScrollConnection, dispatcher = nestedScrollDispatcher)
            .draggable(
                orientation = Orientation.Vertical,
                state =
                    rememberDraggableState { delta ->
                        // here's regular drag. Let's be good citizens and ask parents first if they
                        // want to pre consume (it's a nested scroll contract)
                        val parentsConsumed =
                            nestedScrollDispatcher.dispatchPreScroll(
                                available = Offset(x = 0f, y = delta),
                                source = NestedScrollSource.UserInput,
                            )
                        // adjust what's available to us since might have consumed smth
                        val adjustedAvailable = delta - parentsConsumed.y
                        // we consume
                        val weConsumed = onNewDelta(adjustedAvailable)
                        // dispatch as a post scroll what's left after pre-scroll and our
                        // consumption
                        val totalConsumed = Offset(x = 0f, y = weConsumed) + parentsConsumed
                        val left = adjustedAvailable - weConsumed
                        nestedScrollDispatcher.dispatchPostScroll(
                            consumed = totalConsumed,
                            available = Offset(x = 0f, y = left),
                            source = NestedScrollSource.UserInput,
                        )
                        // we won't dispatch pre/post fling events as we have no flinging here, but
                        // the
                        // idea is very similar:
                        // 1. dispatch pre fling, asking parents to pre consume
                        // 2. fling (while dispatching scroll events like above for any fling tick)
                        // 3. dispatch post fling, allowing parent to react to velocity left
                    },
            )
    ) {
        Text("State: ${basicState.value.roundToInt()}", modifier = Modifier.align(Alignment.Center))
    }
}