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

View file

@ -11,8 +11,6 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -41,6 +39,8 @@ import yokai.presentation.AppBarType
import yokai.presentation.YokaiScaffold import yokai.presentation.YokaiScaffold
import yokai.presentation.component.EmptyScreen import yokai.presentation.component.EmptyScreen
import yokai.presentation.component.ToolTipButton 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.ExtensionRepoInput
import yokai.presentation.extension.repo.component.ExtensionRepoItem import yokai.presentation.extension.repo.component.ExtensionRepoItem
import yokai.util.Screen import yokai.util.Screen
@ -67,7 +67,7 @@ class ExtensionRepoScreen(
onNavigationIconClicked = onBackPress, onNavigationIconClicked = onBackPress,
title = title, title = title,
appBarType = AppBarType.SMALL, appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( scrollBehavior = enterAlwaysScrollBehavior(
state = rememberTopAppBarState(), state = rememberTopAppBarState(),
canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, 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.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Preference
import yokai.presentation.component.preference.PreferenceItem import yokai.presentation.component.preference.PreferenceItem
import yokai.presentation.component.preference.widget.PreferenceGroupHeader import yokai.presentation.component.preference.widget.PreferenceGroupHeader
import yokai.presentation.core.TopAppBarScrollBehavior
import yokai.presentation.core.drawVerticalScrollbar import yokai.presentation.core.drawVerticalScrollbar
import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior import yokai.presentation.core.enterAlwaysCollapsedScrollBehavior
import yokai.presentation.core.rememberTopAppBarState
@Composable @Composable
fun SettingsScaffold( fun SettingsScaffold(

View file

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

View file

@ -1,7 +1,6 @@
package yokai.presentation.settings.screen.about package yokai.presentation.settings.screen.about
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
@ -13,6 +12,7 @@ import eu.kanade.tachiyomi.util.compose.currentOrThrow
import yokai.i18n.MR import yokai.i18n.MR
import yokai.presentation.AppBarType import yokai.presentation.AppBarType
import yokai.presentation.YokaiScaffold import yokai.presentation.YokaiScaffold
import yokai.presentation.core.pinnedScrollBehavior
import yokai.util.Screen import yokai.util.Screen
class AboutLicenseScreen : Screen() { class AboutLicenseScreen : Screen() {
@ -25,7 +25,7 @@ class AboutLicenseScreen : Screen() {
onNavigationIconClicked = backPress, onNavigationIconClicked = backPress,
title = stringResource(MR.strings.open_source_licenses), title = stringResource(MR.strings.open_source_licenses),
appBarType = AppBarType.SMALL, appBarType = AppBarType.SMALL,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), scrollBehavior = pinnedScrollBehavior(),
) { innerPadding -> ) { innerPadding ->
LibrariesContainer( LibrariesContainer(
modifier = Modifier.fillMaxSize(), 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.HorizontalDivider
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.CustomIcons
import yokai.presentation.core.icons.Discord import yokai.presentation.core.icons.Discord
import yokai.presentation.core.icons.GitHub import yokai.presentation.core.icons.GitHub
import yokai.presentation.core.rememberTopAppBarState
import yokai.presentation.settings.SettingsScaffold import yokai.presentation.settings.SettingsScaffold
import yokai.util.Screen import yokai.util.Screen
import yokai.util.lang.getString import yokai.util.lang.getString

View file

@ -1,5 +1,6 @@
package yokai.presentation.core package yokai.presentation.core
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.CubicBezierEasing 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.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
@ -62,6 +70,35 @@ import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt 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] * Composable replacement for [eu.kanade.tachiyomi.ui.base.ExpandedAppBarLayout]
* *
@ -87,14 +124,99 @@ fun ExpandedAppBar(
modifier = modifier, modifier = modifier,
navigationIcon = navigationIcon, navigationIcon = navigationIcon,
actions = actions, actions = actions,
collapsedHeight = CollapsedContainerHeight, topHeight = CollapsedContainerHeight,
expandedHeight = ExpandedContainerHeight, bottomHeight = ExpandedContainerHeight - CollapsedContainerHeight,
windowInsets = windowInsets, windowInsets = windowInsets,
colors = colors, colors = colors,
scrollBehavior = scrollBehavior, 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 @Composable
private fun TwoRowsTopAppBar( private fun TwoRowsTopAppBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -105,35 +227,33 @@ private fun TwoRowsTopAppBar(
smallTitleTextStyle: TextStyle, smallTitleTextStyle: TextStyle,
navigationIcon: @Composable () -> Unit, navigationIcon: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit, actions: @Composable RowScope.() -> Unit,
collapsedHeight: Dp, topHeight: Dp,
expandedHeight: Dp, bottomHeight: Dp,
windowInsets: WindowInsets, windowInsets: WindowInsets,
colors: TopAppBarColors, colors: TopAppBarColors,
scrollBehavior: TopAppBarScrollBehavior? scrollBehavior: TopAppBarScrollBehavior?
) { ) {
require(collapsedHeight.isSpecified && collapsedHeight.isFinite) { require(topHeight.isSpecified && topHeight.isFinite) {
"The collapsedHeight is expected to be specified and finite" "The topHeight is expected to be specified and finite"
} }
require(expandedHeight.isSpecified && expandedHeight.isFinite) { require(bottomHeight.isSpecified && bottomHeight.isFinite) {
"The expandedHeight is expected to be specified and finite" "The bottomHeight is expected to be specified and finite"
} }
require(expandedHeight >= collapsedHeight) { val topHeightPx: Float
"The expandedHeight is expected to be greater or equal to the collapsedHeight" val bottomHeightPx: Float
}
val expandedHeightPx: Float
val collapsedHeightPx: Float
val titleBottomPaddingPx: Int val titleBottomPaddingPx: Int
LocalDensity.current.run { LocalDensity.current.run {
expandedHeightPx = expandedHeight.toPx() topHeightPx = topHeight.toPx()
collapsedHeightPx = collapsedHeight.toPx() bottomHeightPx = bottomHeight.toPx()
titleBottomPaddingPx = titleBottomPadding.roundToPx() titleBottomPaddingPx = titleBottomPadding.roundToPx()
} }
// Sets the app bar's height offset limit to hide just the bottom title area and keep top title // Sets the app bar's height offset limit to hide just the bottom title area and keep top title
// visible when collapsed. // visible when collapsed.
SideEffect { SideEffect {
if (scrollBehavior?.state?.heightOffsetLimit != -expandedHeightPx) { if (scrollBehavior?.state?.heights != listOf(topHeightPx, bottomHeightPx)) {
scrollBehavior?.state?.heightOffsetLimit = -expandedHeightPx scrollBehavior?.state?.heights?.clear()
scrollBehavior?.state?.heights?.addAll(listOf(topHeightPx, bottomHeightPx))
} }
} }
@ -142,7 +262,7 @@ private fun TwoRowsTopAppBar(
// collapse. // collapse.
// This will potentially animate or interpolate a transition between the container color and the // 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. // 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 = val appBarContainerColor =
lerp( lerp(
@ -175,12 +295,9 @@ private fun TwoRowsTopAppBar(
.windowInsetsPadding(windowInsets) .windowInsetsPadding(windowInsets)
// clip after padding so we don't show the title over the inset area // clip after padding so we don't show the title over the inset area
.clipToBounds() .clipToBounds()
.heightIn(max = collapsedHeight), .heightIn(max = topHeight),
scrolledOffset = { scrolledOffset = {
scrollBehavior?.state?.topHeightOffset( scrollBehavior?.state?.heightOffset(0) ?: 0f
topHeightPx = collapsedHeightPx,
totalHeightPx = expandedHeightPx,
) ?: 0f
}, },
navigationIconContentColor = colors.navigationIconContentColor, navigationIconContentColor = colors.navigationIconContentColor,
titleContentColor = colors.titleContentColor, titleContentColor = colors.titleContentColor,
@ -203,12 +320,9 @@ private fun TwoRowsTopAppBar(
// padding will always be applied by the layout above // padding will always be applied by the layout above
.windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal)) .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal))
.clipToBounds() .clipToBounds()
.heightIn(max = expandedHeight - collapsedHeight), .heightIn(max = bottomHeight),
scrolledOffset = { scrolledOffset = {
scrollBehavior?.state?.bottomHeightOffset( scrollBehavior?.state?.heightOffset(1) ?: 0f
topHeightPx = collapsedHeightPx,
totalHeightPx = expandedHeightPx,
) ?: 0f
}, },
navigationIconContentColor = colors.navigationIconContentColor, navigationIconContentColor = colors.navigationIconContentColor,
titleContentColor = colors.titleContentColor, 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 @Composable
private fun AppBarLayout( private fun AppBarLayout(
modifier: Modifier, modifier: Modifier,
@ -457,16 +725,17 @@ private fun interface ScrolledOffset {
private suspend fun settleAppBar( private suspend fun settleAppBar(
state: TopAppBarState, state: TopAppBarState,
velocity: Float, velocity: Float,
topHeightPx: Float,
totalHeightPx: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?, flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?, snapAnimationSpec: AnimationSpec<Float>?,
topOnly: Boolean = false,
): Velocity { ): Velocity {
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar, // Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
// and just return Zero Velocity. // and just return Zero Velocity.
// Note that we don't check for 0f due to float precision with the collapsedFraction // Note that we don't check for 0f due to float precision with the collapsedFraction
// calculation. // 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 return Velocity.Zero
} }
var remainingVelocity = velocity var remainingVelocity = velocity
@ -491,52 +760,95 @@ private suspend fun settleAppBar(
} }
// Snap if animation specs were provided. // Snap if animation specs were provided.
if (snapAnimationSpec != null) { if (snapAnimationSpec != null) {
if (state.topHeightOffset(topHeightPx, totalHeightPx) < 0 && state.topHeightOffset(topHeightPx, totalHeightPx) > -topHeightPx) { when (topOnly) {
AnimationState(initialValue = state.topHeightOffset(topHeightPx, totalHeightPx)).animateTo( true ->
if (state.topCollapsedFraction(topHeightPx, totalHeightPx) < 0.5f) { if (state.heightOffset(0) < 0 && state.heightOffset(0) > -state.heights[0]) {
AnimationState(initialValue = state.heightOffset(0)).animateTo(
if (state.collapsedFraction(0) < 0.5f) {
0f 0f
} else { } else {
-topHeightPx -state.heights[0]
}, },
animationSpec = snapAnimationSpec animationSpec = snapAnimationSpec
) { ) {
state.heightOffset = value + (topHeightPx - totalHeightPx) 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) return Velocity(0f, remainingVelocity)
} }
interface TopAppBarScrollBehavior {
/** /**
* Default values: * A [TopAppBarState] that is attached to this behavior and is read and updated when scrolling
* - Top app bar height: 128px * happens.
* - 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)
*/ */
val state: TopAppBarState
private fun TopAppBarState.rawTopHeightOffset(topHeightPx: Float, totalHeightPx: Float): Float { /**
return heightOffset + (totalHeightPx - topHeightPx) * 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 { @Composable
return rawTopHeightOffset(topHeightPx, totalHeightPx).fastCoerceIn(-topHeightPx, 0f) 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 { @Composable
return heightOffset.fastCoerceIn(topHeightPx - totalHeightPx, 0f) fun enterAlwaysScrollBehavior(
} state: TopAppBarState = rememberTopAppBarState(),
canScroll: () -> Boolean = { true },
private fun TopAppBarState.topCollapsedFraction(topHeightPx: Float, totalHeightPx: Float): Float { snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
val offset = topHeightOffset(topHeightPx, totalHeightPx) flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
return offset / -topHeightPx ): TopAppBarScrollBehavior =
} remember(state, canScroll, snapAnimationSpec, flingAnimationSpec) {
EnterAlwaysScrollBehavior(
private fun TopAppBarState.bottomCollapsedFraction(topHeightPx: Float, totalHeightPx: Float): Float { state = state,
val offset = bottomHeightOffset(topHeightPx, totalHeightPx) snapAnimationSpec = snapAnimationSpec,
return offset / (topHeightPx - totalHeightPx) flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll
)
} }
@Composable @Composable
@ -546,22 +858,106 @@ fun enterAlwaysCollapsedScrollBehavior(
isAtTop: () -> Boolean = { true }, isAtTop: () -> Boolean = { true },
snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow), snapAnimationSpec: AnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay() flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
): TopAppBarScrollBehavior { ): TopAppBarScrollBehavior =
val (topHeightPx, totalHeightPx) = with(LocalDensity.current) { remember(state, canScroll, isAtTop, snapAnimationSpec, flingAnimationSpec) {
CollapsedContainerHeight.toPx() to ExpandedContainerHeight.toPx()
}
return remember(state, canScroll, isAtTop, snapAnimationSpec, flingAnimationSpec, topHeightPx, totalHeightPx) {
EnterAlwaysCollapsedScrollBehavior( EnterAlwaysCollapsedScrollBehavior(
state = state, state = state,
snapAnimationSpec = snapAnimationSpec, snapAnimationSpec = snapAnimationSpec,
flingAnimationSpec = flingAnimationSpec, flingAnimationSpec = flingAnimationSpec,
canScroll = canScroll, canScroll = canScroll,
isAtTop = isAtTop, 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( private class EnterAlwaysCollapsedScrollBehavior(
@ -571,27 +967,20 @@ private class EnterAlwaysCollapsedScrollBehavior(
val canScroll: () -> Boolean = { true }, val canScroll: () -> Boolean = { true },
// FIXME: See if it's possible to eliminate this argument // FIXME: See if it's possible to eliminate this argument
val isAtTop: () -> Boolean = { true }, val isAtTop: () -> Boolean = { true },
val topHeightPx: Float,
val totalHeightPx: Float,
) : TopAppBarScrollBehavior { ) : TopAppBarScrollBehavior {
override val isPinned: Boolean = false override val isPinned: Boolean = false
override var nestedScrollConnection = override var nestedScrollConnection =
object : 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 { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Don't intercept if scrolling down. // 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 return Offset.Zero
val prevHeightOffset = state.heightOffset 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) { return if (prevHeightOffset != state.heightOffset) {
// We're in the middle of top app bar collapse or expand. // We're in the middle of top app bar collapse or expand.
// Consume only the scroll on the Y axis. // Consume only the scroll on the Y axis.
@ -612,7 +1001,10 @@ private class EnterAlwaysCollapsedScrollBehavior(
if (available.y < 0f || consumed.y < 0f) { if (available.y < 0f || consumed.y < 0f) {
// When scrolling up, just update the state's height offset. // When scrolling up, just update the state's height offset.
val oldHeightOffset = state.heightOffset 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) 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 // Adjust the height offset in case the consumed delta Y is less than what was
// recorded as available delta Y in the pre-scroll. // recorded as available delta Y in the pre-scroll.
val oldHeightOffset = state.heightOffset 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(0f, state.heightOffset - oldHeightOffset)
} }
return Offset.Zero return Offset.Zero
@ -635,7 +1030,70 @@ private class EnterAlwaysCollapsedScrollBehavior(
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val superConsumed = super.onPostFling(consumed, available) val superConsumed = super.onPostFling(consumed, available)
return superConsumed + 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)
} }
} }
} }