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
scrollBehavior
variable which is TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
.scrollBehavior
variable inside scrollBehavior argument..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.
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