androidandroid-jetpack-composeandroid-collapsingtoolbarlayoutnestedscrollview

Jetpack compose. How to create collapsing composable?


I need to implement partially collapsed layout in jetpack compose. When I scroll down the list, I want the top layout to partially collapse, and when I scroll to the top of the list, I want the expanded layout to show again. The behavior is similar to collapsing toolbar, but I don't need collapsing toolbar I need collapsing custom composable.

example of collapsing composable

I took the following solution as a basis: https://github.com/germainkevinbusiness/CollapsingTopBarCompose/tree/master

Here is my code:

@Composable
private fun CollapsingLayout(
    modifier: Modifier,
    collapsedContent: @Composable (Modifier) -> Unit,
    expandedContent: @Composable (Modifier) -> Unit,
    scrollBehavior: CollapsingTopBarScrollBehavior,
) = with(scrollBehavior) {

    Surface(
        modifier = modifier
            .fillMaxWidth()
            .height(currentTopBarHeight)
            .verticalScroll(topBarVerticalScrollState.invoke()),
        color = Color.Unspecified,
        contentColor = Color.Unspecified,
        elevation = 0.dp,
    ) {
        Box {

            // Display expanded content
            expandedContent(Modifier
                .alpha(expandedColumnAlpha.invoke().value)
                .fillMaxWidth()
                .height(currentTopBarHeight))

            // Collapsed content with AnimatedVisibility for smooth transition
            AnimatedVisibility(
                visible = collapsedTitleAlpha.invoke().value in 0F..1F,
                enter = fadeIn(initialAlpha = collapsedTitleAlpha.invoke().value),
                exit = fadeOut()
            ) {
                collapsedContent(
                    Modifier
                        .fillMaxWidth()
                        .height(scrollBehavior.collapsedTopBarHeight)
                        .align(Alignment.BottomStart),
                )
            }
        }
    }
}

The problem is that this code needs to know the height of expanded and collapsed content in advance, but I can't set this height in advance because the height of expanded block can be dynamic. So I'm confused. Please help me to solve this problem.

P.S. partially collapsed composable should stay fixed at the top and not scroll with list.

P.S2. A partially collapsed composable may contain smaller text, and components within that composable may be positioned differently than in an expanded composable.


Solution

  • I am adding another answer to address the extended requirements.

    If you need a crossfade animation to allow animating between completely different Composables, try the following code:

    @Composable
    fun CollapsingLayoutDemo() {
    
        CollapsingLayout(
            expandedContent = { modifier ->
                Text(
                    modifier = modifier
                        .fillMaxWidth()
                        .wrapContentHeight(),
                    text = "EXPANDED\nEXPANDED\nEXPANDED\nEXPANDED\nEXPANDED\nEXPANDED"
                )
            },
            collapsedContent = { modifier ->
                Text(
                    modifier = modifier
                        .fillMaxWidth()
                        .wrapContentHeight(),
                    text = "COLLAPSE\nCOLLAPSE\nCOLLAPSE")
            }
        ) { modifier ->
            LazyColumn() {
                items(50) {
                    Card(
                        modifier = modifier.padding(4.dp)
                    ) {
                        Text(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(8.dp),
                            text = "ITEM $it"
                        )
                    }
                }
            }
        }
    }
    
    @Composable
    private fun CollapsingLayout(
        modifier: Modifier = Modifier,
        expandedContent: @Composable (Modifier) -> Unit,
        collapsedContent: @Composable (Modifier) -> Unit,
        content: @Composable (Modifier) -> Unit
    ) {
    
        val localDensity = LocalDensity.current
        var currentHeight by remember { mutableFloatStateOf(0f) }
        var maxHeight by remember { mutableFloatStateOf(-1f) }
        var minHeight by remember { mutableFloatStateOf(-1f) }
        val animationProgress by remember(currentHeight) { mutableFloatStateOf((currentHeight-minHeight) / (maxHeight-minHeight)) }
        Log.d("Collapsible", "$animationProgress")
    
        LaunchedEffect(maxHeight) {
            if (currentHeight == 0f) {
                currentHeight = maxHeight  // initially expand it
            }
        }
    
        val nestedScrollConnection = object : NestedScrollConnection {
    
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                if (currentHeight != minHeight && available.y < 0) {
                    currentHeight = (currentHeight + available.y).coerceAtLeast(minHeight)
                    return available
                }
                return Offset.Zero
            }
    
            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                if (currentHeight != maxHeight && available.y > 0) {
                    currentHeight = (currentHeight + available.y).coerceAtMost(maxHeight)
                    return available
                }
                return Offset.Zero
            }
        }
    
        Column(
            modifier = modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .nestedScroll(nestedScrollConnection),
        ) {
            Card(
                modifier = Modifier.padding(4.dp),
            ) {
                Box(
                    modifier = Modifier.padding(8.dp).then(
                        if (currentHeight != 0f) {
                            Modifier
                                .height(with(localDensity) { currentHeight.toDp() } )
                                .clipToBounds()
                        } else {
                            Modifier
                        }
                    )
                ) {
                    expandedContent(
                        Modifier.onGloballyPositioned { coordinates ->
                            if (maxHeight == -1f) {
                                maxHeight = coordinates.size.height.toFloat()
                            }
                        }.alpha(animationProgress)
                    )
                    collapsedContent(
                        Modifier.onGloballyPositioned { coordinates ->
                            if (minHeight == -1f) {
                                minHeight = coordinates.size.height.toFloat()
                            }
                        }.alpha(1 - animationProgress)
                    )
                }
            }
    
            content(Modifier.weight(1f))
        }
    }
    

    Output:

    Screen Recording