androidx.compose.ui.input.nestedscroll

Interfaces

NestedScrollConnection

Interface to connect to the nested scroll system.

Cmn

Classes

NestedScrollDispatcher

Nested scroll events dispatcher to notify the nested scroll system about the scrolling events that are happening on the element.

Cmn
NestedScrollSource

Possible sources of scroll events in the NestedScrollConnection

Cmn

Top-level functions summary

DelegatableNode
nestedScrollModifierNode(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher?
)

This creates a Nested Scroll Modifier node that can be delegated to.

Cmn

Extension functions summary

Modifier
Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher?
)

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

Cmn

Top-level functions

nestedScrollModifierNode

fun nestedScrollModifierNode(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher?
): DelegatableNode

This creates a Nested Scroll Modifier node that can be delegated to. In most case you should use Modifier.nestedScroll since that implementation also uses this. Use this factory to create nodes that can be delegated to.

Extension functions

nestedScroll

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:

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.IntOffset

// 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}") }
    )
}

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:

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.nestedScroll

// 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.Drag
                )
                // 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.Drag
                )
                // 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)
    )
}

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
connection: NestedScrollConnection

connection to the nested scroll system to participate in the event chaining, receiving events when scrollable descendant is being scrolled.

dispatcher: NestedScrollDispatcher? = null

object 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