Collapsible header in Jetpack Compose using NestedScrollConnection and SubComposeLayout
Collapsible header in Jetpack Compose using NestedScrollConnection and SubComposeLayout 관련
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",
)
}
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 (androiddevelopers
). 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💪
1.
Calculation of header offset and progress was a challenge and we have to do some Maths here to calculate headerOffset
and progress
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 (androiddevelopers
)
// ...
/**
* 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
.
// s...
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.
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!
Info
This article is previously published on proandroiddev.com (<FontIcon icon="fa-brands fa-medium"/>
)
This article is previously published on proandroiddev.com.