I have been trying to collapse/expand a header when lazy column list scroll up or down using nested scrolling. I have been using following code
@Composable
fun ScrollableScreenWithCollapsibleHeader() {
val headerHeight = 150.dp // Initial header height
var headerOffset by remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = headerOffset + delta
headerOffset = newOffset.coerceIn(0f, headerHeight.value)
return Offset(x = 0f, y = newOffset - headerOffset)
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
.padding(top = headerHeight) // Add padding for the header
) {
items(items) { item ->
Text(item, modifier = Modifier.padding(16.dp))
}
}
MyHeader(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.offset { IntOffset(x = 0, y = headerOffset.toInt()) }
)
}
@Composable
fun MyHeader(modifier: Modifier = Modifier) {
// ... Your header content here ...
Box(modifier = modifier.background(Color.LightGray)) {
Text("Header", modifier = Modifier.padding(16.dp))
}
}
But list is not even scrolling, other than collapsing or expanding header. What i am doing wrong
There are a few issues in your code:
nestedScroll
Modifier must be applied to the parent Composable that contains both Composables that should be affected by the scrollingcoerceIn
with 0
as lower bound. But if you want to vertically offset the MyHeader
Composable, then the headerOffset
needs to become negative. Your code prevents that.onPreScroll
function must return the Offset
which was consumed. Your current implementation returns the Offset
that was not consumed.offset
Modifier is an appropriate way to shift a Composable into a direction, but the problem is that the space where the Composable originally was placed remains reserved for that Composable. So your LazyColumn
will not be able to use the space that is reserved for the MyHeader
, even when you offset it. So I would suggest to modify the height
instead of using offset
.dp
and px
units. The height
Modifier takes dp
, the offset
Modifier takes px
.Box
and setting a padding
on the LazyColumn
, you should use a Column
and a weight
.I would suggest the following refactored code:
@Composable
fun ScrollableScreenWithCollapsibleHeader() {
val density = LocalDensity.current
var minHeaderHeightPx by remember { mutableFloatStateOf(-1f) }
var maxHeaderHeightPx by remember { mutableFloatStateOf(-1f) }
var currentHeaderHeightPx by remember { mutableFloatStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newHeaderHeightPx = currentHeaderHeightPx + delta
currentHeaderHeightPx = newHeaderHeightPx.coerceIn(minHeaderHeightPx, maxHeaderHeightPx)
val unconsumedPx = newHeaderHeightPx - currentHeaderHeightPx
return Offset(x = 0f, y = delta - unconsumedPx)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
MyHeader(
modifier = if (maxHeaderHeightPx == -1f) {
Modifier.onGloballyPositioned { coordinates ->
currentHeaderHeightPx = coordinates.size.height.toFloat()
maxHeaderHeightPx = coordinates.size.height.toFloat()
minHeaderHeightPx = maxHeaderHeightPx / 2 // set min height to 50% of full height
}
} else {
Modifier
.height(currentHeaderHeightPx.toInt().pxToDp(density))
.clipToBounds()
}
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
items(50) { item ->
Text("Item $item", modifier = Modifier.padding(16.dp))
}
}
}
}
@Composable
fun MyHeader(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(Color.LightGray)
.fillMaxWidth()
.wrapContentHeight(unbounded = true, align = Alignment.Bottom),
) {
Text("Header A", fontSize = 35.sp)
Spacer(Modifier.height(8.dp))
Text("Header B", fontSize = 35.sp)
}
}
fun Int.pxToDp(density: Density) = with(density) { this@pxToDp.toDp() }
This code does the following things:
maxHeaderHeightPx
set, we use the onGloballyPositioned
Modifier to find out which height the expanded MyHeader
has after it was composed initially. Then we set currentHeaderHeightPx
and maxHeaderHeightPx
to that height of MyHeader
, and define that the minHeaderHeight
should be half of it.maxHeaderHeightPx
is initialized, we set the height
of the MyHeader
to currentHeaderHeightPx
.currentHeaderHeightPx
is somewhere between minHeaderHeightPx
and maxHeaderHeightPx
, we update the currentHeaderHeightPx
in the onPreScroll
function and return the amount of scroll that we consumed.MyHeader
is already fully collapsed or expanded, all scroll will be passed on and consumed by the LazyColumn
.height
Modifier on MyHeader
, the MyHeader
would be clipped starting from the bottom. This would give a wrong visual indication. Instead, we want MyHeader to be clipped beginning from the top, so that it looks like it is shifting out of the screen. To achieve this, we use wrapContentHeight
with unbounded = true
and Alignment.Bottom
. Then, when the MyHeader
content height exceeds its container, it is allowed to draw out of bounds and does align the content to the bottom while doing so.Output: