wip: Preparing three rows app bar

Issue:
- I broke the collapsing app bar again for expanded top app bar :^)
This commit is contained in:
Ahmad Ansori Palembani 2025-01-10 19:09:37 +07:00
parent 258708b038
commit 678fba61b8
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
7 changed files with 578 additions and 113 deletions

View file

@ -2,6 +2,7 @@ package yokai.presentation
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
@ -9,11 +10,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
@ -28,6 +25,10 @@ import dev.icerock.moko.resources.compose.stringResource
import yokai.i18n.MR
import yokai.presentation.component.ToolTipButton
import yokai.presentation.core.ExpandedAppBar
import yokai.presentation.core.TopAppBar
import yokai.presentation.core.TopAppBarScrollBehavior
import yokai.presentation.core.enterAlwaysScrollBehavior
import yokai.presentation.core.rememberTopAppBarState
@Composable
fun YokaiScaffold(
@ -43,7 +44,7 @@ fun YokaiScaffold(
snackbarHost: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
val scrollBehaviorOrDefault = scrollBehavior ?: TopAppBarDefaults.enterAlwaysScrollBehavior(state = rememberTopAppBarState())
val scrollBehaviorOrDefault = scrollBehavior ?: enterAlwaysScrollBehavior(state = rememberTopAppBarState())
val view = LocalView.current
val useDarkIcons = MaterialTheme.colorScheme.surface.luminance() > .5
val (color, scrolledColor) = getTopAppBarColor(title)
@ -63,7 +64,10 @@ fun YokaiScaffold(
when (appBarType) {
AppBarType.SMALL -> TopAppBar(
title = {
Text(text = title)
Text(
modifier = Modifier.basicMarquee(),
text = title,
)
},
// modifier = Modifier.statusBarsPadding(),
colors = topAppBarColors(
@ -82,7 +86,10 @@ fun YokaiScaffold(
)
AppBarType.LARGE -> ExpandedAppBar(
title = {
Text(text = title)
Text(
modifier = Modifier.basicMarquee(),
text = title,
)
},
// modifier = Modifier.statusBarsPadding(),
colors = topAppBarColors(

View file

@ -11,8 +11,6 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -41,6 +39,8 @@ import yokai.presentation.AppBarType
import yokai.presentation.YokaiScaffold
import yokai.presentation.component.EmptyScreen
import yokai.presentation.component.ToolTipButton
import yokai.presentation.core.enterAlwaysScrollBehavior
import yokai.presentation.core.rememberTopAppBarState
import yokai.presentation.extension.repo.component.ExtensionRepoInput
import yokai.presentation.extension.repo.component.ExtensionRepoItem
import yokai.util.Screen
@ -67,7 +67,7 @@ class ExtensionRepoScreen(
onNavigationIconClicked = onBackPress,
title = title,
appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
scrollBehavior = enterAlwaysScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 },
),

View file

@ -7,8 +7,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -29,8 +27,10 @@ import yokai.presentation.component.Gap
import yokai.presentation.component.preference.Preference
import yokai.presentation.component.preference.PreferenceItem
import yokai.presentation.component.preference.widget.PreferenceGroupHeader
import yokai.presentation.core.TopAppBarScrollBehavior
import yokai.presentation.core.drawVerticalScrollbar
import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior
import yokai.presentation.core.rememberTopAppBarState
@Composable
fun SettingsScaffold(

View file

@ -6,8 +6,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
@ -23,6 +21,8 @@ import yokai.i18n.MR
import yokai.presentation.AppBarType
import yokai.presentation.YokaiScaffold
import yokai.presentation.component.ToolTipButton
import yokai.presentation.core.enterAlwaysScrollBehavior
import yokai.presentation.core.rememberTopAppBarState
import yokai.util.Screen
class AboutLibraryLicenseScreen(
@ -45,7 +45,7 @@ class AboutLibraryLicenseScreen(
},
title = name,
appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
scrollBehavior = enterAlwaysScrollBehavior(
state = rememberTopAppBarState(),
),
actions = {

View file

@ -1,7 +1,6 @@
package yokai.presentation.settings.screen.about
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.LocalNavigator
@ -13,6 +12,7 @@ import eu.kanade.tachiyomi.util.compose.currentOrThrow
import yokai.i18n.MR
import yokai.presentation.AppBarType
import yokai.presentation.YokaiScaffold
import yokai.presentation.core.pinnedScrollBehavior
import yokai.util.Screen
class AboutLicenseScreen : Screen() {
@ -25,7 +25,7 @@ class AboutLicenseScreen : Screen() {
onNavigationIconClicked = backPress,
title = stringResource(MR.strings.open_source_licenses),
appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
scrollBehavior = pinnedScrollBehavior(),
) { innerPadding ->
LibrariesContainer(
modifier = Modifier.fillMaxSize(),

View file

@ -18,7 +18,6 @@ import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -66,6 +65,7 @@ import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior
import yokai.presentation.core.icons.CustomIcons
import yokai.presentation.core.icons.Discord
import yokai.presentation.core.icons.GitHub
import yokai.presentation.core.rememberTopAppBarState
import yokai.presentation.settings.SettingsScaffold
import yokai.util.Screen
import yokai.util.lang.getString

View file

@ -1,5 +1,6 @@
package yokai.presentation.core
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.CubicBezierEasing
@ -21,19 +22,26 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.remember
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
@ -62,6 +70,35 @@ import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
fun TopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight,
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null
) =
SingleRowTopAppBar(
modifier = modifier,
title = title,
titleTextStyle = MaterialTheme.typography.titleLarge,
centeredTitle = false,
navigationIcon = navigationIcon,
actions = actions,
expandedHeight =
if (expandedHeight == Dp.Unspecified || expandedHeight == Dp.Infinity) {
TopAppBarDefaults.TopAppBarExpandedHeight
} else {
expandedHeight
},
windowInsets = windowInsets,
colors = colors,
scrollBehavior = scrollBehavior
)
/**
* Composable replacement for [eu.kanade.tachiyomi.ui.base.ExpandedAppBarLayout]
*
@ -87,14 +124,99 @@ fun ExpandedAppBar(
modifier = modifier,
navigationIcon = navigationIcon,
actions = actions,
collapsedHeight = CollapsedContainerHeight,
expandedHeight = ExpandedContainerHeight,
topHeight = CollapsedContainerHeight,
bottomHeight = ExpandedContainerHeight - CollapsedContainerHeight,
windowInsets = windowInsets,
colors = colors,
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun SingleRowTopAppBar(
modifier: Modifier = Modifier,
title: @Composable () -> Unit,
titleTextStyle: TextStyle,
centeredTitle: Boolean,
navigationIcon: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit,
expandedHeight: Dp,
windowInsets: WindowInsets,
colors: TopAppBarColors,
scrollBehavior: TopAppBarScrollBehavior?
) {
require(expandedHeight.isSpecified && expandedHeight.isFinite) {
"The expandedHeight is expected to be specified and finite"
}
val expandedHeightPx = with(LocalDensity.current) { expandedHeight.toPx().coerceAtLeast(0f) }
SideEffect {
// Sets the app bar's height offset to collapse the entire bar's height when content is
// scrolled.
if (scrollBehavior?.state?.heights != listOf(expandedHeightPx)) {
scrollBehavior?.state?.heights?.clear()
scrollBehavior?.state?.heights?.addAll(listOf(expandedHeightPx))
}
}
// Obtain the container color from the TopAppBarColors using the `overlapFraction`. This
// ensures that the colors will adjust whether the app bar behavior is pinned or scrolled.
// This may potentially animate or interpolate a transition between the container-color and the
// container's scrolled-color according to the app bar's scroll state.
val colorTransitionFraction by remember(scrollBehavior) {
// derivedStateOf to prevent redundant recompositions when the content scrolls.
derivedStateOf {
val overlappingFraction = scrollBehavior?.state?.overlappedFraction ?: 0f
if (overlappingFraction > 0.01f) 1f else 0f
}
}
val appBarContainerColor by animateColorAsState(
targetValue = lerp(
colors.containerColor,
colors.scrolledContainerColor,
FastOutLinearInEasing.transform(colorTransitionFraction)
),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
)
// Wrap the given actions in a Row.
val actionsRow =
@Composable {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
content = actions
)
}
// Compose a Surface with a TopAppBarLayout content.
// The surface's background color is animated as specified above.
// The height of the app bar is determined by subtracting the bar's height offset from the
// app bar's defined constant height value (i.e. the ContainerHeight token).
Surface(modifier = modifier, color = appBarContainerColor) {
AppBarLayout(
modifier =
Modifier.windowInsetsPadding(windowInsets)
// clip after padding so we don't show the title over the inset area
.clipToBounds()
.heightIn(max = expandedHeight),
scrolledOffset = { scrollBehavior?.state?.heightOffset ?: 0f },
navigationIconContentColor = colors.navigationIconContentColor,
titleContentColor = colors.titleContentColor,
actionIconContentColor = colors.actionIconContentColor,
title = title,
titleTextStyle = titleTextStyle,
titleAlpha = 1f,
titleVerticalArrangement = Arrangement.Center,
titleHorizontalArrangement =
if (centeredTitle) Arrangement.Center else Arrangement.Start,
titleBottomPadding = 0,
hideTitleSemantics = false,
navigationIcon = navigationIcon,
actions = actionsRow,
)
}
}
@Composable
private fun TwoRowsTopAppBar(
modifier: Modifier = Modifier,
@ -105,35 +227,33 @@ private fun TwoRowsTopAppBar(
smallTitleTextStyle: TextStyle,
navigationIcon: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit,
collapsedHeight: Dp,
expandedHeight: Dp,
topHeight: Dp,
bottomHeight: Dp,
windowInsets: WindowInsets,
colors: TopAppBarColors,
scrollBehavior: TopAppBarScrollBehavior?
) {
require(collapsedHeight.isSpecified && collapsedHeight.isFinite) {
"The collapsedHeight is expected to be specified and finite"
require(topHeight.isSpecified && topHeight.isFinite) {
"The topHeight is expected to be specified and finite"
}
require(expandedHeight.isSpecified && expandedHeight.isFinite) {
"The expandedHeight is expected to be specified and finite"
require(bottomHeight.isSpecified && bottomHeight.isFinite) {
"The bottomHeight is expected to be specified and finite"
}
require(expandedHeight >= collapsedHeight) {
"The expandedHeight is expected to be greater or equal to the collapsedHeight"
}
val expandedHeightPx: Float
val collapsedHeightPx: Float
val topHeightPx: Float
val bottomHeightPx: Float
val titleBottomPaddingPx: Int
LocalDensity.current.run {
expandedHeightPx = expandedHeight.toPx()
collapsedHeightPx = collapsedHeight.toPx()
topHeightPx = topHeight.toPx()
bottomHeightPx = bottomHeight.toPx()
titleBottomPaddingPx = titleBottomPadding.roundToPx()
}
// Sets the app bar's height offset limit to hide just the bottom title area and keep top title
// visible when collapsed.
SideEffect {
if (scrollBehavior?.state?.heightOffsetLimit != -expandedHeightPx) {
scrollBehavior?.state?.heightOffsetLimit = -expandedHeightPx
if (scrollBehavior?.state?.heights != listOf(topHeightPx, bottomHeightPx)) {
scrollBehavior?.state?.heights?.clear()
scrollBehavior?.state?.heights?.addAll(listOf(topHeightPx, bottomHeightPx))
}
}
@ -142,7 +262,7 @@ private fun TwoRowsTopAppBar(
// collapse.
// This will potentially animate or interpolate a transition between the container color and the
// container's scrolled color according to the app bar's scroll state.
val colorTransitionFraction = scrollBehavior?.state?.bottomCollapsedFraction(collapsedHeightPx, expandedHeightPx) ?: 0f
val colorTransitionFraction = scrollBehavior?.state?.collapsedFraction(1) ?: 0f
val appBarContainerColor =
lerp(
@ -175,12 +295,9 @@ private fun TwoRowsTopAppBar(
.windowInsetsPadding(windowInsets)
// clip after padding so we don't show the title over the inset area
.clipToBounds()
.heightIn(max = collapsedHeight),
.heightIn(max = topHeight),
scrolledOffset = {
scrollBehavior?.state?.topHeightOffset(
topHeightPx = collapsedHeightPx,
totalHeightPx = expandedHeightPx,
) ?: 0f
scrollBehavior?.state?.heightOffset(0) ?: 0f
},
navigationIconContentColor = colors.navigationIconContentColor,
titleContentColor = colors.titleContentColor,
@ -203,12 +320,9 @@ private fun TwoRowsTopAppBar(
// padding will always be applied by the layout above
.windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal))
.clipToBounds()
.heightIn(max = expandedHeight - collapsedHeight),
.heightIn(max = bottomHeight),
scrolledOffset = {
scrollBehavior?.state?.bottomHeightOffset(
topHeightPx = collapsedHeightPx,
totalHeightPx = expandedHeightPx,
) ?: 0f
scrollBehavior?.state?.heightOffset(1) ?: 0f
},
navigationIconContentColor = colors.navigationIconContentColor,
titleContentColor = colors.titleContentColor,
@ -227,6 +341,160 @@ private fun TwoRowsTopAppBar(
}
}
/**
* Creates a [TopAppBarState] that is remembered across compositions.
*
* @param initialHeightOffsetLimit the initial value for [TopAppBarState.heightOffsetLimit], which
* represents the pixel limit that a top app bar is allowed to collapse when the scrollable
* content is scrolled
* @param initialHeightOffset the initial value for [TopAppBarState.heightOffset]. The initial
* offset height offset should be between zero and [initialHeightOffsetLimit].
* @param initialContentOffset the initial value for [TopAppBarState.contentOffset]
*/
@Composable
fun rememberTopAppBarState(
initialHeights: List<Float> = listOf(),
initialHeightOffset: Float = 0f,
initialContentOffset: Float = 0f,
): TopAppBarState {
return rememberSaveable(saver = TopAppBarState.Saver) {
TopAppBarState(initialHeights, initialHeightOffset, initialContentOffset)
}
}
/**
* A state object that can be hoisted to control and observe the top app bar state. The state is
* read and updated by a [TopAppBarScrollBehavior] implementation.
*
* In most cases, this state will be created via [rememberTopAppBarState].
*
* @param initialHeightOffsetLimit the initial value for [TopAppBarState.heightOffsetLimit]
* @param initialHeightOffset the initial value for [TopAppBarState.heightOffset]
* @param initialContentOffset the initial value for [TopAppBarState.contentOffset]
*/
@ExperimentalMaterial3Api
@Stable
class TopAppBarState(
initialHeights: List<Float>,
initialHeightOffset: Float,
initialContentOffset: Float
) {
val heights = initialHeights.toMutableStateList()
val heightOffsetLimit get() = -heights.sum()
/**
* The top app bar's current height offset in pixels. This height offset is applied to the fixed
* height of the app bar to control the displayed height when content is being scrolled.
*
* Updates to the [heightOffset] value are coerced between zero and [heightOffsetLimit].
*/
var heightOffset: Float
get() = _heightOffset.floatValue
set(newOffset) {
_heightOffset.floatValue =
newOffset.fastCoerceIn(minimumValue = heightOffsetLimit, maximumValue = 0f)
}
fun heightOffset(index: Int = Int.MIN_VALUE, raw: Boolean = false): Float {
if (index == Int.MIN_VALUE) return _heightOffset.floatValue
if (heights.isEmpty() || heights.size < index + 1) return 0f
val selectedHeight = heights[index]
val limit = heights.sum()
val rt = _heightOffset.value + (limit - selectedHeight)
if (raw) return rt
return rt.fastCoerceIn(-selectedHeight, 0f)
}
fun setHeightOffsetAgainstIndex(index: Int = Int.MIN_VALUE, offset: Float) {
if (index == Int.MIN_VALUE) {
_heightOffset.floatValue = offset.fastCoerceIn(heightOffsetLimit, 0f)
return
}
if (heights.isEmpty() || heights.size < index + 1) return
_heightOffset.floatValue = offset.fastCoerceIn(heightOffsetLimit, -heights[index])
}
/**
* The total offset of the content scrolled under the top app bar.
*
* The content offset is used to compute the [overlappedFraction], which can later be read by an
* implementation.
*
* This value is updated by a [TopAppBarScrollBehavior] whenever a nested scroll connection
* consumes scroll events. A common implementation would update the value to be the sum of all
* [NestedScrollConnection.onPostScroll] `consumed.y` values.
*/
var contentOffset by mutableFloatStateOf(initialContentOffset)
/**
* A value that represents the collapsed height percentage of the app bar.
*
* A `0.0` represents a fully expanded bar, and `1.0` represents a fully collapsed bar (computed
* as [heightOffset] / [heightOffsetLimit]).
*/
val collapsedFraction: Float
get() =
if (heights.isNotEmpty() || heightOffsetLimit != 0f) {
heightOffset / heightOffsetLimit
} else {
0f
}
fun collapsedFraction(index: Int = Int.MIN_VALUE): Float {
if (heights.isEmpty()) return 0f
if (index == Int.MIN_VALUE) return if (heightOffsetLimit != 0f) {
_heightOffset.floatValue / heightOffsetLimit
} else {
0f
}
if (heights.size < index + 1) return 0f
return heightOffset(index) / -heights[index]
}
/**
* A value that represents the percentage of the app bar area that is overlapping with the
* content scrolled behind it.
*
* A `0.0` indicates that the app bar does not overlap any content, while `1.0` indicates that
* the entire visible app bar area overlaps the scrolled content.
*/
val overlappedFraction: Float
get() =
if (heights.isNotEmpty() || heightOffsetLimit != 0f) {
1 -
((heightOffsetLimit - contentOffset).coerceIn(
minimumValue = heightOffsetLimit,
maximumValue = 0f
) / heightOffsetLimit)
} else {
0f
}
companion object {
/** The default [Saver] implementation for [TopAppBarState]. */
val Saver: Saver<TopAppBarState, *> =
listSaver(
save = { listOf(it.heightOffset, it.contentOffset) + it.heights.toList() },
restore = {
val list = it.toMutableList()
val initialHeightOffset = list.removeAt(0)
val initialContentOffset = list.removeAt(0)
TopAppBarState(
initialHeights = list.toMutableStateList(),
initialHeightOffset = initialHeightOffset,
initialContentOffset = initialContentOffset,
)
}
)
}
private var _heightOffset = mutableFloatStateOf(initialHeightOffset)
}
@Composable
private fun AppBarLayout(
modifier: Modifier,
@ -457,16 +725,17 @@ private fun interface ScrolledOffset {
private suspend fun settleAppBar(
state: TopAppBarState,
velocity: Float,
topHeightPx: Float,
totalHeightPx: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?,
topOnly: Boolean = false,
): 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.topCollapsedFraction(topHeightPx, totalHeightPx) < 0.01f || state.topCollapsedFraction(topHeightPx, totalHeightPx) == 1f) {
if (topOnly && (state.collapsedFraction(0) < 0.01f || state.collapsedFraction(0) == 1f)) {
return Velocity.Zero
} else if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
return Velocity.Zero
}
var remainingVelocity = velocity
@ -491,53 +760,96 @@ private suspend fun settleAppBar(
}
// Snap if animation specs were provided.
if (snapAnimationSpec != null) {
if (state.topHeightOffset(topHeightPx, totalHeightPx) < 0 && state.topHeightOffset(topHeightPx, totalHeightPx) > -topHeightPx) {
AnimationState(initialValue = state.topHeightOffset(topHeightPx, totalHeightPx)).animateTo(
if (state.topCollapsedFraction(topHeightPx, totalHeightPx) < 0.5f) {
0f
} else {
-topHeightPx
},
animationSpec = snapAnimationSpec
) {
state.heightOffset = value + (topHeightPx - totalHeightPx)
}
when (topOnly) {
true ->
if (state.heightOffset(0) < 0 && state.heightOffset(0) > -state.heights[0]) {
AnimationState(initialValue = state.heightOffset(0)).animateTo(
if (state.collapsedFraction(0) < 0.5f) {
0f
} else {
-state.heights[0]
},
animationSpec = snapAnimationSpec
) {
state.heightOffset = value + (-state.heights[0])
}
}
false ->
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)
}
/**
* Default values:
* - Top app bar height: 128px
* - Total app bar height: 304px
* - Bottom app bar height: 176px
* - Top offset limit: (-(Total), (Top - Total)) = (-304px, -176px)
* - Bottom offset limit: ((Top - Total), 0) = (-176px, 0px)
*/
interface TopAppBarScrollBehavior {
private fun TopAppBarState.rawTopHeightOffset(topHeightPx: Float, totalHeightPx: Float): Float {
return heightOffset + (totalHeightPx - topHeightPx)
/**
* A [TopAppBarState] that is attached to this behavior and is read and updated when scrolling
* happens.
*/
val state: TopAppBarState
/**
* Indicates whether the top app bar is pinned.
*
* A pinned app bar will stay fixed in place when content is scrolled and will not react to any
* drag gestures.
*/
val isPinned: Boolean
/**
* An optional [AnimationSpec] that defines how the top app bar snaps to either fully collapsed
* or fully extended state when a fling or a drag scrolled it into an intermediate position.
*/
val snapAnimationSpec: AnimationSpec<Float>?
/**
* An optional [DecayAnimationSpec] that defined how to fling the top app bar when the user
* flings the app bar itself, or the content below it.
*/
val flingAnimationSpec: DecayAnimationSpec<Float>?
/**
* A [NestedScrollConnection] that should be attached to a [Modifier.nestedScroll] in order to
* keep track of the scroll events.
*/
val nestedScrollConnection: NestedScrollConnection
}
private fun TopAppBarState.topHeightOffset(topHeightPx: Float, totalHeightPx: Float): Float {
return rawTopHeightOffset(topHeightPx, totalHeightPx).fastCoerceIn(-topHeightPx, 0f)
}
@Composable
fun pinnedScrollBehavior(
state: TopAppBarState = rememberTopAppBarState(),
canScroll: () -> Boolean = { true }
): TopAppBarScrollBehavior = remember(state, canScroll) { PinnedScrollBehavior(state = state, canScroll = canScroll) }
private fun TopAppBarState.bottomHeightOffset(topHeightPx: Float, totalHeightPx: Float): Float {
return heightOffset.fastCoerceIn(topHeightPx - totalHeightPx, 0f)
}
private fun TopAppBarState.topCollapsedFraction(topHeightPx: Float, totalHeightPx: Float): Float {
val offset = topHeightOffset(topHeightPx, totalHeightPx)
return offset / -topHeightPx
}
private fun TopAppBarState.bottomCollapsedFraction(topHeightPx: Float, totalHeightPx: Float): Float {
val offset = bottomHeightOffset(topHeightPx, totalHeightPx)
return offset / (topHeightPx - totalHeightPx)
}
@Composable
fun enterAlwaysScrollBehavior(
state: TopAppBarState = rememberTopAppBarState(),
canScroll: () -> Boolean = { true },
snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
): TopAppBarScrollBehavior =
remember(state, canScroll, snapAnimationSpec, flingAnimationSpec) {
EnterAlwaysScrollBehavior(
state = state,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll
)
}
@Composable
fun enterAlwaysCollapsedScrollBehavior(
@ -546,22 +858,106 @@ fun enterAlwaysCollapsedScrollBehavior(
isAtTop: () -> Boolean = { true },
snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
): TopAppBarScrollBehavior {
val (topHeightPx, totalHeightPx) = with(LocalDensity.current) {
CollapsedContainerHeight.toPx() to ExpandedContainerHeight.toPx()
}
return remember(state, canScroll, isAtTop, snapAnimationSpec, flingAnimationSpec, topHeightPx, totalHeightPx) {
): TopAppBarScrollBehavior =
remember(state, canScroll, isAtTop, snapAnimationSpec, flingAnimationSpec) {
EnterAlwaysCollapsedScrollBehavior(
state = state,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll,
isAtTop = isAtTop,
topHeightPx = topHeightPx,
totalHeightPx = totalHeightPx,
)
}
@Composable
fun exitUntilCollapsedScrollBehavior(
state: TopAppBarState = rememberTopAppBarState(),
canScroll: () -> Boolean = { true },
snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
): TopAppBarScrollBehavior =
remember(state, canScroll, snapAnimationSpec, flingAnimationSpec) {
ExitUntilCollapsedScrollBehavior(
state = state,
snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll
)
}
private class PinnedScrollBehavior(
override val state: TopAppBarState,
val canScroll: () -> Boolean = { true }
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = true
override val snapAnimationSpec: AnimationSpec<Float>? = null
override val flingAnimationSpec: DecayAnimationSpec<Float>? = null
override var nestedScrollConnection =
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (!canScroll()) return Offset.Zero
if (consumed.y == 0f && available.y > 0f) {
// Reset the total content offset to zero when scrolling all the way down.
// This will eliminate some float precision inaccuracies.
state.contentOffset = 0f
} else {
state.contentOffset += consumed.y
}
return Offset.Zero
}
}
}
private class EnterAlwaysScrollBehavior(
override val state: TopAppBarState,
override val snapAnimationSpec: AnimationSpec<Float>?,
override val flingAnimationSpec: DecayAnimationSpec<Float>?,
val canScroll: () -> Boolean = { true }
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override var nestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (!canScroll()) 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 (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) {
if (consumed.y == 0f && available.y > 0f) {
// Reset the total content offset to zero when scrolling all the way down.
// This will eliminate some float precision inaccuracies.
state.contentOffset = 0f
}
}
state.heightOffset = state.heightOffset + consumed.y
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)
}
}
}
private class EnterAlwaysCollapsedScrollBehavior(
@ -571,27 +967,20 @@ private class EnterAlwaysCollapsedScrollBehavior(
val canScroll: () -> Boolean = { true },
// FIXME: See if it's possible to eliminate this argument
val isAtTop: () -> Boolean = { true },
val topHeightPx: Float,
val totalHeightPx: Float,
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
override var nestedScrollConnection =
object : NestedScrollConnection {
private fun TopAppBarState.setClampedOffsetIfAtTop(offset: Float) {
heightOffset = if (isAtTop()) {
offset
} else {
offset.fastCoerceIn(-totalHeightPx, (topHeightPx - totalHeightPx))
}
}
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Don't intercept if scrolling down.
if (!canScroll() || (available.y > 0f && state.rawTopHeightOffset(topHeightPx, totalHeightPx) >= 0f))
if (!canScroll() || (available.y > 0f && state.heightOffset(0) >= 0f))
return Offset.Zero
val prevHeightOffset = state.heightOffset
state.setClampedOffsetIfAtTop(state.heightOffset + available.y)
state.setHeightOffsetAgainstIndex(
index = if (isAtTop()) Int.MIN_VALUE else 0,
offset = 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.
@ -612,7 +1001,10 @@ private class EnterAlwaysCollapsedScrollBehavior(
if (available.y < 0f || consumed.y < 0f) {
// When scrolling up, just update the state's height offset.
val oldHeightOffset = state.heightOffset
state.setClampedOffsetIfAtTop(state.heightOffset + consumed.y)
state.setHeightOffsetAgainstIndex(
index = if (isAtTop()) Int.MIN_VALUE else 0,
offset = state.heightOffset + consumed.y,
)
return Offset(0f, state.heightOffset - oldHeightOffset)
}
@ -626,7 +1018,10 @@ private class EnterAlwaysCollapsedScrollBehavior(
// 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.setClampedOffsetIfAtTop(state.heightOffset + available.y)
state.setHeightOffsetAgainstIndex(
index = if (isAtTop()) Int.MIN_VALUE else 0,
offset = state.heightOffset + available.y,
)
return Offset(0f, state.heightOffset - oldHeightOffset)
}
return Offset.Zero
@ -635,7 +1030,70 @@ private class EnterAlwaysCollapsedScrollBehavior(
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val superConsumed = super.onPostFling(consumed, available)
return superConsumed +
settleAppBar(state, available.y, topHeightPx, totalHeightPx, flingAnimationSpec, snapAnimationSpec)
settleAppBar(state, available.y, flingAnimationSpec, snapAnimationSpec, true)
}
}
}
private class ExitUntilCollapsedScrollBehavior(
override val state: TopAppBarState,
override val snapAnimationSpec: AnimationSpec<Float>?,
override val flingAnimationSpec: DecayAnimationSpec<Float>?,
val canScroll: () -> Boolean = { true }
) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false
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 += 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 += 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 += 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)
}
}
}