androidandroid-jetpack-composeandroid-jetpackmaterial-components-androidandroid-jetpack-compose-material3

LargeTopAppBar is collapsible even if the inner content is not scrollable in Jetpack Compose


I am trying to implement a screen that uses TopAppBar and some items with LazyColumn. However, LargeTopAppBar is collapsible even if the inner content is not scrollable.

STEPS

  1. I made a scrollBehavior variable which is TopAppBarDefaults.exitUntilCollapsedScrollBehavior().
  2. I put my TopAppBar inside Scaffold topBar argument. The TopAppBar has scrollBehavior variable inside scrollBehavior argument.
  3. Finally, I added .nestedScroll(scrollBehavior.nestedScrollConnection) in Scaffold modifier.

Here is the main part of the code:

@Composable
fun HomeScreen() {    
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() // <- STEP 1
    
    Scaffold(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(scrollBehavior.nestedScrollConnection), // <- STEP 3
        topBar = { HomeTopAppBar(scrollBehavior, actionOnClick) },
    ) { innerPadding ->
        LazyColumn(
            Modifier
                .padding(innerPadding)
                .fillMaxSize()
        ) {
            items(someIterable) {
                // Some Composables...
            }
        }
    }
}

// ...


@Composable
fun HomeTopAppBar(
    scrollBehavior: TopAppBarScrollBehavior,
    actionOnClick: () -> Unit
) {
    LargeTopAppBar(
        title = { /* Title Composable */ },
        actions = { /* Some Action Button Composable */ },
        scrollBehavior = scrollBehavior // <- STEP 2
    )
}


I tried applying this code:

val isScrollable = remember {
    derivedStateOf {
        scrollState.firstVisibleItemIndex > 0 || scrollState.firstVisibleItemScrollOffset > 0
    }
}

val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
    canScroll = { isScrollable.value }
)

This was to check if it is scrollable and disable scroll state. But It would just behave like pinnedScrollBehavior and the TopAppBar would not scroll even though it has to. I think this was not the right way to do it, but I couldn't find any other ways so I just tried.

Right now this is how the app works: https://imgur.com/a/dtGQJo0

If you see the GIF, The TopAppBar is scroll & collapsible even the inner item is not scrollable. You can still collapse the TopAppBar while you are drag/scrolling the inner item part

What I want to implement: https://imgur.com/a/vAr5sGN

There are three images in this link. As you can see in Android Settings. The TopAppBar part is not scrollable & collapsible when there is enough room. When there are enough items. It will collapse, but still you can not scroll down when the touch is starting from the TopAppBar part.


Solution

  • You can determine whether the LazyColumn can scroll by using canScrollForward and canScrollBackward. Please try to adjust your code like this:

    val scrollState = rememberLazyListState()
    
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
        canScroll = { scrollState.canScrollForward || scrollState.canScrollBackward }
    )
    

    Then assign the scrollState to your LazyColumn as follows:

    LazyColumn(
        modifier = Modifier
            .padding(innerPadding)
            .fillMaxSize(),
        state = scrollState
    ) {
        // ...
    }
    

    Currently, the exitUntilCollapsedScrollBehavior will allow the LargeTopAppBar to be expanded or collapsed at any time once you directly swipe on it. There is an issue on the Google Issue Tracker suggesting that this behavior should be customizable. You can star it to draw more attention to it.
    For now, you can create your own pinnedExitUntilCollapsedScrollBehavior and use that.

    Basically, we need to set the isPinned boolean of the ExitUntilCollapsedScrollBehavior class to true. We can't easily extend the class as it is private. So unfortunately, we will have to copy a lot of code, but at the end you will get the desired behavior:

    PinnedExitUntilCollapsedScrollBehavior.kt

    @ExperimentalMaterial3Api
    @Composable
    fun pinnedExitUntilCollapsedScrollBehavior(
        state: TopAppBarState = rememberTopAppBarState(),
        canScroll: () -> Boolean = { true },
        snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
        flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
    ): TopAppBarScrollBehavior =
        PinnedExitUntilCollapsedScrollBehavior(
            state = state,
            snapAnimationSpec = snapAnimationSpec,
            flingAnimationSpec = flingAnimationSpec,
            canScroll = canScroll
        )
    
    @OptIn(ExperimentalMaterial3Api::class)
    private class PinnedExitUntilCollapsedScrollBehavior(
        override val state: TopAppBarState,
        override val snapAnimationSpec: AnimationSpec<Float>?,
        override val flingAnimationSpec: DecayAnimationSpec<Float>?,
        val canScroll: () -> Boolean = { true }
    ) : TopAppBarScrollBehavior {
        override val isPinned: Boolean = true
        override var nestedScrollConnection =
            object : NestedScrollConnection {
                override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                    // Don't intercept if scrolling down.
                    if (!canScroll() || available.y > 0f) return Offset.Zero
    
                    val prevHeightOffset = state.heightOffset
                    state.heightOffset = state.heightOffset + available.y
                    return if (prevHeightOffset != state.heightOffset) {
                        // We're in the middle of top app bar collapse or expand.
                        // Consume only the scroll on the Y axis.
                        available.copy(x = 0f)
                    } else {
                        Offset.Zero
                    }
                }
    
                override fun onPostScroll(
                    consumed: Offset,
                    available: Offset,
                    source: NestedScrollSource
                ): Offset {
                    if (!canScroll()) return Offset.Zero
                    state.contentOffset += consumed.y
    
                    if (available.y < 0f || consumed.y < 0f) {
                        // When scrolling up, just update the state's height offset.
                        val oldHeightOffset = state.heightOffset
                        state.heightOffset = state.heightOffset + consumed.y
                        return Offset(0f, state.heightOffset - oldHeightOffset)
                    }
    
                    if (consumed.y == 0f && available.y > 0) {
                        // Reset the total content offset to zero when scrolling all the way down. This
                        // will eliminate some float precision inaccuracies.
                        state.contentOffset = 0f
                    }
    
                    if (available.y > 0f) {
                        // Adjust the height offset in case the consumed delta Y is less than what was
                        // recorded as available delta Y in the pre-scroll.
                        val oldHeightOffset = state.heightOffset
                        state.heightOffset = state.heightOffset + available.y
                        return Offset(0f, state.heightOffset - oldHeightOffset)
                    }
                    return Offset.Zero
                }
    
                override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
                    val superConsumed = super.onPostFling(consumed, available)
                    return superConsumed + settleAppBar(
                        state,
                        available.y,
                        flingAnimationSpec,
                        snapAnimationSpec
                    )
                }
            }
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    private suspend fun settleAppBar(
        state: TopAppBarState,
        velocity: Float,
        flingAnimationSpec: DecayAnimationSpec<Float>?,
        snapAnimationSpec: AnimationSpec<Float>?
    ): Velocity {
        // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
        // and just return Zero Velocity.
        // Note that we don't check for 0f due to float precision with the collapsedFraction
        // calculation.
        if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
            return Velocity.Zero
        }
        var remainingVelocity = velocity
        // In case there is an initial velocity that was left after a previous user fling, animate to
        // continue the motion to expand or collapse the app bar.
        if (flingAnimationSpec != null && abs(velocity) > 1f) {
            var lastValue = 0f
            AnimationState(
                initialValue = 0f,
                initialVelocity = velocity,
            )
                .animateDecay(flingAnimationSpec) {
                    val delta = value - lastValue
                    val initialHeightOffset = state.heightOffset
                    state.heightOffset = initialHeightOffset + delta
                    val consumed = abs(initialHeightOffset - state.heightOffset)
                    lastValue = value
                    remainingVelocity = this.velocity
                    // avoid rounding errors and stop if anything is unconsumed
                    if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
                }
        }
        // Snap if animation specs were provided.
        if (snapAnimationSpec != null) {
            if (state.heightOffset < 0 &&
                state.heightOffset > state.heightOffsetLimit
            ) {
                AnimationState(initialValue = state.heightOffset).animateTo(
                    if (state.collapsedFraction < 0.5f) {
                        0f
                    } else {
                        state.heightOffsetLimit
                    },
                    animationSpec = snapAnimationSpec
                ) { state.heightOffset = value }
            }
        }
    
        return Velocity(0f, remainingVelocity)
    }
    

    Usage

    val scrollBehavior = pinnedExitUntilCollapsedScrollBehavior(
        canScroll = { scrollState.canScrollForward || scrollState.canScrollBackward }
    )
    

    Output