
In Jetpack Compose, building a collapsible header with a custom navigation bar can be straightforward using
NestedScrollConnection—provided the header has fixed expanded and collapsed heights. However, when the header height is dynamic and depends on its content (e.g., based on backend responses), things get tricky. Using
onGloballyPositioned to measure the header’s height alone may not suffice. To address this, I combined
NestedScrollConnection with
SubComposeLayout, as it handles dynamic header content effectively.
Our Goal: The final header states
Let’s start by looking at the two final states of the header that we’ll achieve using NestedScrollConnection
and SubComposeLayout
in Jetpack Compose.

UI Composition Overview
To better understand how this UI is structured, let’s break it down. The layout uses a Box
composable containing a Column
. Within the Column
, we have two key components: the ExpandedHeader
and the LazyColumn
. I’ll dive deeper into the nestedScroll(connection)
and scrollable
implementations in the following sections.
@Composable | |
fun CollapsibleThing(modifier: Modifier = Modifier) { | |
Surface( | |
modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.tertiary | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.nestedScroll(connection) | |
) { | |
Column(modifier = Modifier.scrollable( | |
orientation = Orientation.Vertical, | |
// state for Scrollable, describes how consume scroll amount | |
state = | |
rememberScrollableState { delta -> | |
0f | |
} | |
)) { | |
ExpandedHeader( | |
modifier = Modifier, | |
) | |
LazyColumn( | |
modifier = Modifier | |
.fillMaxSize() | |
.weight(weight = 1f) | |
.background(Color.White) | |
) { | |
items(contents) { | |
ListItem(item = it) | |
} | |
} | |
} | |
} | |
} | |
} |
Breaking Down ExpandedHeader
The ExpandedHeader
consists of two parts: the header and the navigation bar. To implement this, we use SubComposeLayout
, creating two placeables—one for the header and another for the navigation bar. The HeaderContent
represents the expanded state, while the NavBar
corresponds to the collapsed state during transitions.
If you’re new to SubComposeLayout
in Jetpack Compose, I highly recommend exploring these resources for a deeper understanding: SubComposeLayoutSample and Advanced Layouts in Compose.
@Composable | |
fun ExpandedHeader(modifier: Modifier = Modifier) { | |
//To simulate Header Content | |
SubcomposeLayout(modifier) { constraints -> | |
val headerPlaceable = subcompose("header") { | |
Column(modifier = modifier.background(Color.Cyan)) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(Color.Red) | |
.height(250.dp), | |
contentAlignment = Alignment.BottomStart | |
) { | |
Image( | |
painter = painterResource(id = R.drawable.texture_image), | |
contentDescription = "Header Image", | |
contentScale = ContentScale.Crop, | |
) | |
Box( | |
modifier = Modifier | |
.padding(16.dp) | |
.size(56.dp) | |
.background(Color.White) | |
) { | |
Image( | |
painter = painterResource(id = R.drawable.logo), | |
contentDescription = "Logo Image", | |
contentScale = ContentScale.Crop, | |
) | |
} | |
} | |
HeaderContent() | |
Divider(color = Color.LightGray, modifier = Modifier.height(16.dp)) | |
} | |
}.first().measure(constraints) | |
val navBarPlaceable = subcompose("navBar") { | |
NavBar() | |
}.first().measure(constraints) | |
connection.maxHeight = headerPlaceable.height.toFloat() | |
connection.minHeight = navBarPlaceable.height.toFloat() | |
val space = IntSize( | |
constraints.maxWidth, | |
headerPlaceable.height + connection.headerOffset.roundToInt() | |
) | |
layout(space.width, space.height) { | |
headerPlaceable.place(0, connection.headerOffset.roundToInt()) | |
navBarPlaceable.place( | |
Alignment.TopCenter.align( | |
IntSize(navBarPlaceable.width, navBarPlaceable.height), | |
space, | |
layoutDirection | |
) | |
) | |
} | |
} | |
} | |
@Composable | |
fun NavBar() { | |
var alphaValue by remember { mutableFloatStateOf(0f) } | |
alphaValue = (3 * (1f - connection.progress)).coerceIn(0f, 1f) | |
//To Simulate Navigation BAR | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(56.dp) | |
.border( | |
width = 1.dp, color = Color.Gray.copy(alpha = alphaValue) | |
) | |
.background(Color.White.copy(alpha = alphaValue)) | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(horizontal = 8.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
IconButton(onClick = { /* TODO: Handle back action */ }) { | |
Icon( | |
imageVector = Icons.Default.ArrowBack, | |
contentDescription = "Back", | |
tint = Color.Black.copy(alpha = alphaValue) | |
) | |
} | |
Text( | |
modifier = Modifier.weight(1f), | |
text = "Navigation Bar", | |
color = Color.Black.copy(alpha = alphaValue) | |
) | |
IconButton(onClick = { /* TODO: Handle search action */ }) { | |
Icon( | |
imageVector = Icons.Default.Menu, | |
contentDescription = "Search", | |
tint = Color.Black.copy(alpha = alphaValue) | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun HeaderContent() { | |
HeaderItem( | |
Modifier | |
.padding(8.dp) | |
.fillMaxWidth() | |
.border( | |
width = 1.dp, color = Color.Gray, shape = RoundedCornerShape(2.dp) | |
) | |
.padding(8.dp), | |
"Header content item 1", | |
) | |
HeaderItem( | |
Modifier | |
.padding(8.dp) | |
.fillMaxWidth() | |
.border( | |
width = 1.dp, color = Color.Gray, shape = RoundedCornerShape(2.dp) | |
) | |
.padding(8.dp), | |
"Header content item 2", | |
) | |
} |
Job Offers
The Role of NestedScrollConnection
By default, the header isn’t scrollable — only the lazy list is. However, our goal is to allow the header to move upward in sync with the scroll offset of the lazy list. This is where NestedScrollConnection
becomes essential.
For a deeper dive into NestedScrollConnection
, check out this blog post. Below, I’ll share my implementation of NestedScrollConnection
, focusing on its onPreScroll
and onPostScroll
overrides.
class CollapsingAppBarNestedScrollConnection : NestedScrollConnection { | |
var headerOffset: Float by mutableFloatStateOf(0f) | |
private set | |
var progress: Float by mutableFloatStateOf(1f) | |
private set | |
var maxHeight: Float by mutableFloatStateOf(0f) | |
var minHeight: Float by mutableFloatStateOf(0f) | |
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | |
val delta = available.y | |
/** | |
* when direction is negative, meaning scrolling downward, | |
* we are not consuming delta but passing it for Node Consumption | |
*/ | |
if (delta >= 0f) { | |
return Offset.Zero | |
} | |
val newOffset = headerOffset + delta | |
val previousOffset = headerOffset | |
val heightDelta = -(maxHeight - minHeight) | |
headerOffset = if (heightDelta > 0) 0f else newOffset.coerceIn(heightDelta, 0f) | |
progress = 1f - headerOffset / -maxHeight | |
val consumed = headerOffset - previousOffset | |
return Offset(0f, consumed) | |
} | |
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { | |
val delta = available.y | |
val newOffset = headerOffset + delta | |
val previousOffset = headerOffset | |
val heightDelta = -(maxHeight - minHeight) | |
headerOffset = if (heightDelta > 0) 0f else newOffset.coerceIn(heightDelta, 0f) | |
progress = 1f - headerOffset / -maxHeight | |
val consumedValue = headerOffset - previousOffset | |
return Offset(0f, consumedValue) | |
} | |
} |
Implementing NestedScrollConnection with the header?
Here’s how we integrate NestedScrollConnection
within our Activity and composables to enable smooth interactions between the header and the lazy list.
... | |
private val contents: List<String> = (1..50).map { "Lazy Column Item $it" } | |
val connection = CollapsingAppBarNestedScrollConnection() //initialing nestedScrollConnection here | |
class MainActivity : ComponentActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContent { | |
CollapsibleHeaderTheme { | |
CollapsibleThing() | |
} | |
} | |
} | |
} | |
@Composable | |
fun CollapsibleThing(modifier: Modifier = Modifier) { | |
Surface( | |
modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.tertiary | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.nestedScroll(connection) //using nestedScrollConnection to the common parent of lazylist view and header | |
) { | |
... |
Bring It All Together
When all the pieces of this puzzle come together, the result is seamless. As the lazy list scrolls, the header scrolls along with it. Once the header reaches a specific progress, we dynamically adjust the alpha value of the NavigationBar
background, its icons, and the title for a smooth transition effect.

Challenges! Faced and Solved
- Calculation of header offset and progress was a challenge and we have to do some Maths here to calculate
headerOffset
andprogress
which we will use to adjust the height of header and alpha of navBar when lazy list scrolls up
... | |
var headerOffset: Float by mutableFloatStateOf(0f) | |
private set | |
var progress: Float by mutableFloatStateOf(1f) | |
private set | |
... | |
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | |
... | |
val newOffset = headerOffset + delta | |
val previousOffset = headerOffset | |
val heightDelta = -(maxHeight - minHeight) | |
headerOffset = if (heightDelta > 0) 0f else newOffset.coerceIn(heightDelta, 0f) | |
progress = 1f - headerOffset / -maxHeight | |
val consumed = headerOffset - previousOffset | |
return Offset(0f, consumed) | |
... |
2. When we scrolls the list up, the header scrolls up first and then the list. On the other hand, when I scroll down the list, the header was scrolling down first and then the list was scrolling but my requirement was that we we scroll down the list, we first scroll down the list untill it reaches to first item and then we scroll down the header. To solve this case, I added this below code snippet in onPreScroll
and passing the zero offset when we scroll downward to pass it to the Node consumption phase. — More details on Node consumption phase is in this blogpost
... | |
/** | |
* when direction is negative, meaning scrolling downward, | |
* we are not consuming delta but passing it for Node Consumption | |
*/ | |
if (delta >= 0f) { | |
return Offset.Zero | |
} | |
... |
3. If I tried to scroll the header by dragging the header part(not the lazy list), It was not scrolling because its was a Column with no scrollable behavior so to solve this case and make the header scrollable even if we drag the header part without touching the lazy list. Here is how I did it.
... | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.nestedScroll(connection) | |
) { | |
Column(modifier = Modifier.scrollable( | |
orientation = Orientation.Vertical, | |
// state for Scrollable, describes how consume scroll amount | |
state = | |
rememberScrollableState { delta -> | |
0f | |
} | |
)) { | |
ExpandedHeader( | |
modifier = Modifier, | |
) | |
... |
4. Here is how we are adjusting height of header based on the header offset received through NestedScrollConnection
, and placing the placeables calculated with SubComposeLayout
.
... | |
connection.maxHeight = headerPlaceable.height.toFloat() | |
connection.minHeight = navBarPlaceable.height.toFloat() | |
val space = IntSize( | |
constraints.maxWidth, | |
headerPlaceable.height + connection.headerOffset.roundToInt() | |
) | |
layout(space.width, space.height) { | |
headerPlaceable.place(0, connection.headerOffset.roundToInt()) | |
navBarPlaceable.place( | |
Alignment.TopCenter.align( | |
IntSize(navBarPlaceable.width, navBarPlaceable.height), | |
space, | |
layoutDirection | |
) | |
) | |
} | |
... |
5. In this way, we are calculating alpha value based on the progress received from NestedScrollConnection
and changing the alpha of Navigation Bar Composable
... | |
@Composable | |
fun NavBar() { | |
var alphaValue by remember { mutableFloatStateOf(0f) } | |
alphaValue = (3 * (1f - connection.progress)).coerceIn(0f, 1f) | |
... | |
IconButton(onClick = { /* TODO: Handle action */ }) { | |
Icon( | |
imageVector = Icons.Default.ArrowBack, | |
contentDescription = "Back", | |
tint = Color.Black.copy(alpha = alphaValue) | |
) | |
} | |
Text( | |
modifier = Modifier.weight(1f), | |
text = "Navigation Bar", | |
color = Color.Black.copy(alpha = alphaValue) | |
) | |
IconButton(onClick = { /* TODO: Handle action */ }) { | |
Icon( | |
imageVector = Icons.Default.Menu, | |
contentDescription = "Search", | |
tint = Color.Black.copy(alpha = alphaValue) | |
) | |
} | |
... |
If you’d like to see the complete implementation in one place, feel free to check out this repository.
For more details, please refer to these resources.
Jetpack Compose, Nested scrolling in Jetpack Compose, SubComposeLayoutSample , Layouts in Jetpack Compose
Feel free to ask any questions you may have — I’d be happy to collaborate and discuss further.
I hope you found this helpful, and thank you for reading!
This article is previously published on proandroiddev.com.