diff --git a/app/src/main/java/yokai/presentation/Scaffold.kt b/app/src/main/java/yokai/presentation/Scaffold.kt index 4c63800a1e..35dfcef2ac 100644 --- a/app/src/main/java/yokai/presentation/Scaffold.kt +++ b/app/src/main/java/yokai/presentation/Scaffold.kt @@ -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( diff --git a/app/src/main/java/yokai/presentation/extension/repo/ExtensionRepoScreen.kt b/app/src/main/java/yokai/presentation/extension/repo/ExtensionRepoScreen.kt index b85255cd00..fb2be45a5b 100644 --- a/app/src/main/java/yokai/presentation/extension/repo/ExtensionRepoScreen.kt +++ b/app/src/main/java/yokai/presentation/extension/repo/ExtensionRepoScreen.kt @@ -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 }, ), diff --git a/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt b/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt index 34feac3f93..17e4e2cbf0 100644 --- a/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt +++ b/app/src/main/java/yokai/presentation/settings/SettingsCommonWidget.kt @@ -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( diff --git a/app/src/main/java/yokai/presentation/settings/screen/about/AboutLibraryLicenseScreen.kt b/app/src/main/java/yokai/presentation/settings/screen/about/AboutLibraryLicenseScreen.kt index 7ed11d10d8..7666f1abde 100644 --- a/app/src/main/java/yokai/presentation/settings/screen/about/AboutLibraryLicenseScreen.kt +++ b/app/src/main/java/yokai/presentation/settings/screen/about/AboutLibraryLicenseScreen.kt @@ -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 = { diff --git a/app/src/main/java/yokai/presentation/settings/screen/about/AboutLicenseScreen.kt b/app/src/main/java/yokai/presentation/settings/screen/about/AboutLicenseScreen.kt index 6d4ca8be2a..0e4ac1e3c5 100644 --- a/app/src/main/java/yokai/presentation/settings/screen/about/AboutLicenseScreen.kt +++ b/app/src/main/java/yokai/presentation/settings/screen/about/AboutLicenseScreen.kt @@ -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(), diff --git a/app/src/main/java/yokai/presentation/settings/screen/about/AboutScreen.kt b/app/src/main/java/yokai/presentation/settings/screen/about/AboutScreen.kt index 77ff7c7cdd..948f27d4bd 100644 --- a/app/src/main/java/yokai/presentation/settings/screen/about/AboutScreen.kt +++ b/app/src/main/java/yokai/presentation/settings/screen/about/AboutScreen.kt @@ -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 diff --git a/presentation/core/src/main/java/yokai/presentation/core/AppBar.kt b/presentation/core/src/main/java/yokai/presentation/core/AppBar.kt index 0dcaf44d35..594691aa35 100644 --- a/presentation/core/src/main/java/yokai/presentation/core/AppBar.kt +++ b/presentation/core/src/main/java/yokai/presentation/core/AppBar.kt @@ -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 = 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, + 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 = + 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?, snapAnimationSpec: AnimationSpec?, + 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? + + /** + * 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? + + /** + * 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? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = 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? = spring(stiffness = Spring.StiffnessMediumLow), flingAnimationSpec: DecayAnimationSpec? = 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? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = 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? = null + override val flingAnimationSpec: DecayAnimationSpec? = 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?, + override val flingAnimationSpec: DecayAnimationSpec?, + 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?, + override val flingAnimationSpec: DecayAnimationSpec?, + 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) } } }