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:
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:
- 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. - 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 receivesNestedScrollConnection.onPostScroll
should consume no more thanleft
and return the amount consumed. - 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.
- 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 callnotifySelfFinish
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 | connection to the nested scroll system to participate in the event chaining, receiving events when scrollable descendant is being scrolled. |
dispatcher | 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 |
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))
}
}