Search code examples
androidkotlinandroid-jetpack-composeandroid-nestedscrollview

Nested Scrolling with collapsible header not working


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


Solution

  • There are a few issues in your code:

    • The nestedScroll Modifier must be applied to the parent Composable that contains both Composables that should be affected by the scrolling
    • You are using coerceIn 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.
    • The onPreScroll function must return the Offset which was consumed. Your current implementation returns the Offset that was not consumed.
    • Using an 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.
    • Be careful not to mix dp and px units. The height Modifier takes dp, the offset Modifier takes px.
    • Instead of using a 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:

    • Initially, when there is no 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.
    • Then, once maxHeaderHeightPx is initialized, we set the height of the MyHeader to currentHeaderHeightPx.
    • Whenever we scroll, and the currentHeaderHeightPx is somewhere between minHeaderHeightPx and maxHeaderHeightPx, we update the currentHeaderHeightPx in the onPreScroll function and return the amount of scroll that we consumed.
    • If we don't consume any scroll because MyHeader is already fully collapsed or expanded, all scroll will be passed on and consumed by the LazyColumn.
    • When we apply the 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:

    Screen Recording