Nested scroll is a negotiation, and there are four moments to intervene
NestedScrollConnection gives parents four chances to consume scroll deltas around a child. Choosing the wrong one is why collapsing headers collapse late, and why same-axis nesting misbehaves.

Day 75 of 100. Two scrollables on the same axis have to agree about who moves, and Compose gives you exactly four places to decide.
The symptom
A collapsing header that collapses at the wrong time:
var headerHeight by remember { mutableFloatStateOf(maxHeader) }
Column {
Header(Modifier.height { headerHeight.toDp() })
LazyColumn(
modifier = Modifier.pointerInput(Unit) {
detectVerticalDragGestures { _, delta -> headerHeight += delta }
}
) { items(rows) { Row(it) } }
}
Scrolling down collapses the header and scrolls the list at the same time, so the content jumps twice as fast as the finger. Scrolling back up expands the header only after the list has reached the top — or sometimes not at all, because the list consumed the gesture first.
The two are competing for the same drag rather than sharing it.
Why the obvious fix fails
The obvious fix is a flag: collapse the header first, then let the list scroll.
if (headerHeight > minHeader) { headerHeight += delta } else { /* let the list have it */ }
The list never sees the "let it have it" case, because the parent's gesture detector already consumed the pointer event — Day 71's consumption model. Removing the consumption means both respond, which is the original bug.
What you need is a way to take part of a scroll and pass the remainder on. That's a different mechanism from gesture consumption.

The actual mechanism
NestedScrollConnection sits between a scrollable child and its ancestors, and it's
consulted at four moments:
val connection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// BEFORE the child scrolls — take what you want
val delta = available.y
if (delta < 0) { // scrolling down: collapse first
val consumed = collapseHeader(delta)
return Offset(0f, consumed)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
// AFTER the child scrolled — take what it didn't use
if (available.y > 0) { // list at top, still pulling: expand
return Offset(0f, expandHeader(available.y))
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity =
Velocity.Zero
}
}
Column(Modifier.nestedScroll(connection)) {
Header(…)
LazyColumn { … }
}
The contract is simple and worth stating precisely: you return how much you consumed,
and the remainder continues on. Return Offset.Zero to pass everything through; return
all of available to swallow it.
That's what the flag version couldn't express — partial consumption.
Which of the four, and when
The choice is the whole skill, and it comes down to who should move first.
onPreScroll — the parent moves before the child. Use it for collapse-on-scroll-down:
the header must shrink before the list starts moving, or the content jumps.
onPostScroll — the parent moves with what the child didn't use. Use it for
expand-on-scroll-up: the list scrolls to its top, and the leftover upward drag expands the
header. This is also how pull-to-refresh gets its overscroll.
onPreFling — intercept the fling velocity before the child flings. Use it to snap a
partially-collapsed header to a resting position.
onPostFling — take the leftover velocity after the child's fling ends. Use it for
overscroll effects that continue past the list's end.
The asymmetry is the important part: collapsing uses pre, expanding uses post. Getting that backwards produces exactly the "collapses late, expands never" behaviour from the top of this post.
Use the built-in first
Day 35 already covered this, and it's worth repeating in context: a collapsing app bar
is a TopAppBarScrollBehavior, not a hand-written connection.
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { LargeTopAppBar(…, scrollBehavior = scrollBehavior) },
) { … }
That nestedScrollConnection is a NestedScrollConnection implementing everything above,
with the snapping and the fling handling already correct. Writing your own is for headers
Material doesn't model — a parallax image, a collapsing chart, a custom search surface.
Pull-to-refresh (PullToRefreshBox) and the pager are built the same way.
Same-axis nesting
The case Day 73 deferred: a horizontal carousel inside a horizontal pager, or a vertical list inside a vertical scroll.
The default resolution is that the innermost scrollable wins, because onPreScroll
runs outside-in and the built-in containers don't claim anything there. So the inner list
scrolls until it reaches its edge, and then — via onPostScroll — the outer one takes
over. That's usually the behaviour you want, and it's why a LazyRow inside a
LazyColumn works without configuration.
Where it needs help is when the outer one should win in some condition — a pager that
should page even when the inner carousel could still scroll. That's an onPreScroll that
claims the delta under that condition, and it's the legitimate reason to write a
connection by hand.
Nesting two scrollables on the same axis where both are unbounded still throws, as Day 23 said. Nested scroll coordinates scrolling; it doesn't fix an unbounded constraint.
The source parameter
NestedScrollSource tells you what caused the scroll:
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput) { … } // a finger
// NestedScrollSource.SideEffect — a fling or programmatic scroll
}
A header that collapses on drag but shouldn't collapse when the list is scrolled programmatically — after a "scroll to top" button, say — checks this. It's a small parameter that prevents a class of surprising behaviour.
How to prove it
The one-to-one test is the fastest signal: drag slowly and watch whether the content moves at the same rate as your finger. Double-speed movement means both the parent and child are consuming the same delta.
For the asymmetry, test the two directions separately. Scroll down from the top — the header should collapse before the list moves. Scroll up from the middle — the list should reach its top before the header expands. If either happens simultaneously, the delta is being consumed in the wrong callback.
Logging both callbacks makes it concrete:
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
Log.d("NS", "pre available=${available.y} source=$source")
…
}
The sequence per drag event should read: pre (parent's chance), then the child's own scroll, then post (the remainder). Seeing that once makes the four callbacks obvious.
What this generalizes to
The idea is cooperative consumption over exclusive claiming. A gesture system where one handler wins the whole event forces the flag-and-fight pattern; one where each participant reports how much it used lets several respond to one drag in a defined order.
It's the same shape as middleware, event bubbling with partial handling, and backpressure in streams — a chain where each stage takes what it needs and passes the rest along. The useful question when two things fight over an input is rarely "who should win" but "how do they share it, and in what order".
Tomorrow, Day 76: focus — the other input model, and the one that matters most on a device with a keyboard.
Day 75 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Nested scrolling.