mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
refactor: Try to mimic ExpandedAppBarLayout for Compose
This commit is contained in:
parent
55fad67223
commit
448c93365a
2 changed files with 627 additions and 2 deletions
|
@ -6,7 +6,6 @@ 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
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.LargeTopAppBar
|
|
||||||
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
|
||||||
|
@ -28,6 +27,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import dev.icerock.moko.resources.compose.stringResource
|
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
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun YokaiScaffold(
|
fun YokaiScaffold(
|
||||||
|
@ -78,7 +78,7 @@ fun YokaiScaffold(
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
actions = actions,
|
actions = actions,
|
||||||
)
|
)
|
||||||
AppBarType.LARGE -> LargeTopAppBar(
|
AppBarType.LARGE -> ExpandedAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(text = title)
|
Text(text = title)
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,625 @@
|
||||||
|
package yokai.presentation.core
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
|
import androidx.compose.animation.core.AnimationState
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.animation.core.DecayAnimationSpec
|
||||||
|
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||||
|
import androidx.compose.animation.core.animateDecay
|
||||||
|
import androidx.compose.animation.core.animateTo
|
||||||
|
import androidx.compose.animation.rememberSplineBasedDecay
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
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.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.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.layout.AlignmentLine
|
||||||
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.isFinite
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
|
import androidx.compose.ui.util.fastFirst
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable replacement for [eu.kanade.tachiyomi.ui.base.ExpandedAppBarLayout]
|
||||||
|
*
|
||||||
|
* Copied from [androidx.compose.material3.LargeTopAppBar], modified to mimic J2K's
|
||||||
|
* [eu.kanade.tachiyomi.ui.base.ExpandedAppBarLayout] behaviors
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ExpandedAppBar(
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
|
||||||
|
colors: TopAppBarColors = TopAppBarDefaults.largeTopAppBarColors(),
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||||
|
) {
|
||||||
|
TwoRowsTopAppBar(
|
||||||
|
title = title,
|
||||||
|
titleTextStyle = MaterialTheme.typography.headlineMedium,
|
||||||
|
smallTitleTextStyle = MaterialTheme.typography.titleLarge,
|
||||||
|
titleBottomPadding = LargeTitleBottomPadding,
|
||||||
|
smallTitle = title,
|
||||||
|
modifier = modifier,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
collapsedHeight = CollapsedContainerHeight,
|
||||||
|
expandedHeight = ExpandedContainerHeight,
|
||||||
|
windowInsets = windowInsets,
|
||||||
|
colors = colors,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TwoRowsTopAppBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
titleTextStyle: TextStyle,
|
||||||
|
titleBottomPadding: Dp,
|
||||||
|
smallTitle: @Composable () -> Unit,
|
||||||
|
smallTitleTextStyle: TextStyle,
|
||||||
|
navigationIcon: @Composable () -> Unit,
|
||||||
|
actions: @Composable RowScope.() -> Unit,
|
||||||
|
collapsedHeight: Dp,
|
||||||
|
expandedHeight: Dp,
|
||||||
|
windowInsets: WindowInsets,
|
||||||
|
colors: TopAppBarColors,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior?
|
||||||
|
) {
|
||||||
|
require(collapsedHeight.isSpecified && collapsedHeight.isFinite) {
|
||||||
|
"The collapsedHeight is expected to be specified and finite"
|
||||||
|
}
|
||||||
|
require(expandedHeight.isSpecified && expandedHeight.isFinite) {
|
||||||
|
"The expandedHeight 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 titleBottomPaddingPx: Int
|
||||||
|
LocalDensity.current.run {
|
||||||
|
expandedHeightPx = expandedHeight.toPx()
|
||||||
|
collapsedHeightPx = collapsedHeight.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtain the container Color from the TopAppBarColors using the `collapsedFraction`, as the
|
||||||
|
// bottom part of this TwoRowsTopAppBar changes color at the same rate the app bar expands or
|
||||||
|
// 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?.collapsedFraction ?: 0f
|
||||||
|
val topColorTransitionFraction = scrollBehavior?.state?.topCollapsedFraction(collapsedHeightPx) ?: 0f
|
||||||
|
val bottomColorTransitionFraction = scrollBehavior?.state?.bottomCollapsedFraction(collapsedHeightPx, expandedHeightPx) ?: 0f
|
||||||
|
|
||||||
|
val appBarContainerColor =
|
||||||
|
lerp(
|
||||||
|
colors.containerColor,
|
||||||
|
colors.scrolledContainerColor,
|
||||||
|
FastOutLinearInEasing.transform(colorTransitionFraction)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wrap the given actions in a Row.
|
||||||
|
val actionsRow =
|
||||||
|
@Composable {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = actions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val topTitleAlpha = TopTitleAlphaEasing.transform(topColorTransitionFraction)
|
||||||
|
val bottomTitleAlpha = 1f - bottomColorTransitionFraction
|
||||||
|
// Hide the top row title semantics when its alpha value goes below 0.5 threshold.
|
||||||
|
// Hide the bottom row title semantics when the top title semantics are active.
|
||||||
|
val hideTopRowSemantics = topColorTransitionFraction < 0.5f
|
||||||
|
val hideBottomRowSemantics = bottomColorTransitionFraction < 0.5f
|
||||||
|
|
||||||
|
// Set up support for resizing the top app bar when vertically dragging the bar itself.
|
||||||
|
val appBarDragModifier =
|
||||||
|
if (scrollBehavior != null && !scrollBehavior.isPinned) {
|
||||||
|
Modifier.draggable(
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
state =
|
||||||
|
rememberDraggableState { delta -> scrollBehavior.state.heightOffset += delta },
|
||||||
|
onDragStopped = { velocity ->
|
||||||
|
settleAppBar(
|
||||||
|
scrollBehavior.state,
|
||||||
|
velocity,
|
||||||
|
scrollBehavior.flingAnimationSpec,
|
||||||
|
scrollBehavior.snapAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) {
|
||||||
|
Column {
|
||||||
|
AppBarLayout(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.windowInsetsPadding(windowInsets)
|
||||||
|
// clip after padding so we don't show the title over the inset area
|
||||||
|
.clipToBounds()
|
||||||
|
.heightIn(max = collapsedHeight),
|
||||||
|
scrolledOffset = {
|
||||||
|
scrollBehavior?.state?.topHeightOffset(
|
||||||
|
topHeightPx = collapsedHeightPx,
|
||||||
|
totalHeightPx = expandedHeightPx,
|
||||||
|
) ?: 0f
|
||||||
|
},
|
||||||
|
navigationIconContentColor = colors.navigationIconContentColor,
|
||||||
|
titleContentColor = colors.titleContentColor,
|
||||||
|
actionIconContentColor = colors.actionIconContentColor,
|
||||||
|
title = smallTitle,
|
||||||
|
titleTextStyle = smallTitleTextStyle,
|
||||||
|
titleAlpha = topTitleAlpha,
|
||||||
|
titleVerticalArrangement = Arrangement.Bottom,
|
||||||
|
titleHorizontalArrangement = Arrangement.Start,
|
||||||
|
titleBottomPadding = 0,
|
||||||
|
hideTitleSemantics = hideTopRowSemantics,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actionsRow,
|
||||||
|
)
|
||||||
|
AppBarLayout(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
// only apply the horizontal sides of the window insets padding, since the
|
||||||
|
// top
|
||||||
|
// padding will always be applied by the layout above
|
||||||
|
.windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal))
|
||||||
|
.clipToBounds()
|
||||||
|
.heightIn(max = expandedHeight - collapsedHeight),
|
||||||
|
scrolledOffset = {
|
||||||
|
scrollBehavior?.state?.bottomHeightOffset(
|
||||||
|
topHeightPx = collapsedHeightPx,
|
||||||
|
totalHeightPx = expandedHeightPx,
|
||||||
|
) ?: 0f
|
||||||
|
},
|
||||||
|
navigationIconContentColor = colors.navigationIconContentColor,
|
||||||
|
titleContentColor = colors.titleContentColor,
|
||||||
|
actionIconContentColor = colors.actionIconContentColor,
|
||||||
|
title = title,
|
||||||
|
titleTextStyle = titleTextStyle,
|
||||||
|
titleAlpha = bottomTitleAlpha,
|
||||||
|
titleVerticalArrangement = Arrangement.Bottom,
|
||||||
|
titleHorizontalArrangement = Arrangement.Start,
|
||||||
|
titleBottomPadding = titleBottomPaddingPx,
|
||||||
|
hideTitleSemantics = hideBottomRowSemantics,
|
||||||
|
navigationIcon = {},
|
||||||
|
actions = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppBarLayout(
|
||||||
|
modifier: Modifier,
|
||||||
|
scrolledOffset: ScrolledOffset,
|
||||||
|
navigationIconContentColor: Color,
|
||||||
|
titleContentColor: Color,
|
||||||
|
actionIconContentColor: Color,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
titleTextStyle: TextStyle,
|
||||||
|
titleAlpha: Float,
|
||||||
|
titleVerticalArrangement: Arrangement.Vertical,
|
||||||
|
titleHorizontalArrangement: Arrangement.Horizontal,
|
||||||
|
titleBottomPadding: Int,
|
||||||
|
hideTitleSemantics: Boolean,
|
||||||
|
navigationIcon: @Composable () -> Unit,
|
||||||
|
actions: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Layout(
|
||||||
|
{
|
||||||
|
Box(Modifier
|
||||||
|
.layoutId("navigationIcon")
|
||||||
|
.padding(start = TopAppBarHorizontalPadding)) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContentColor provides navigationIconContentColor,
|
||||||
|
content = navigationIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.layoutId("title")
|
||||||
|
.padding(horizontal = TopAppBarHorizontalPadding)
|
||||||
|
.then(if (hideTitleSemantics) Modifier.clearAndSetSemantics {} else Modifier)
|
||||||
|
.graphicsLayer(alpha = titleAlpha)
|
||||||
|
) {
|
||||||
|
ProvideContentColorTextStyle(
|
||||||
|
contentColor = titleContentColor,
|
||||||
|
textStyle = titleTextStyle,
|
||||||
|
content = title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier
|
||||||
|
.layoutId("actionIcons")
|
||||||
|
.padding(end = TopAppBarHorizontalPadding)) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContentColor provides actionIconContentColor,
|
||||||
|
content = actions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { measurables, constraints ->
|
||||||
|
val navigationIconPlaceable =
|
||||||
|
measurables
|
||||||
|
.fastFirst { it.layoutId == "navigationIcon" }
|
||||||
|
.measure(constraints.copy(minWidth = 0))
|
||||||
|
val actionIconsPlaceable =
|
||||||
|
measurables
|
||||||
|
.fastFirst { it.layoutId == "actionIcons" }
|
||||||
|
.measure(constraints.copy(minWidth = 0))
|
||||||
|
|
||||||
|
val maxTitleWidth =
|
||||||
|
if (constraints.maxWidth == Constraints.Infinity) {
|
||||||
|
constraints.maxWidth
|
||||||
|
} else {
|
||||||
|
(constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width)
|
||||||
|
.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
val titlePlaceable =
|
||||||
|
measurables
|
||||||
|
.fastFirst { it.layoutId == "title" }
|
||||||
|
.measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth))
|
||||||
|
|
||||||
|
// Locate the title's baseline.
|
||||||
|
val titleBaseline =
|
||||||
|
if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) {
|
||||||
|
titlePlaceable[LastBaseline]
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtract the scrolledOffset from the maxHeight. The scrolledOffset is expected to be
|
||||||
|
// equal or smaller than zero.
|
||||||
|
val scrolledOffsetValue = scrolledOffset.offset()
|
||||||
|
val heightOffset = if (scrolledOffsetValue.isNaN()) 0 else scrolledOffsetValue.roundToInt()
|
||||||
|
|
||||||
|
val layoutHeight =
|
||||||
|
if (constraints.maxHeight == Constraints.Infinity) {
|
||||||
|
constraints.maxHeight
|
||||||
|
} else {
|
||||||
|
constraints.maxHeight + heightOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
layout(constraints.maxWidth, layoutHeight) {
|
||||||
|
// Navigation icon
|
||||||
|
navigationIconPlaceable.placeRelative(
|
||||||
|
x = 0,
|
||||||
|
y =
|
||||||
|
when (titleVerticalArrangement) {
|
||||||
|
Arrangement.Bottom -> {
|
||||||
|
val padding = (constraints.maxHeight - navigationIconPlaceable.height) / 2
|
||||||
|
val paddingFromBottom = padding - (navigationIconPlaceable.height - titleBaseline)
|
||||||
|
val heightWithPadding = paddingFromBottom + navigationIconPlaceable.height
|
||||||
|
val adjustedBottomPadding =
|
||||||
|
if (heightWithPadding > constraints.maxHeight) {
|
||||||
|
paddingFromBottom -
|
||||||
|
(heightWithPadding - constraints.maxHeight)
|
||||||
|
} else {
|
||||||
|
paddingFromBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutHeight - navigationIconPlaceable.height - max(0, adjustedBottomPadding)
|
||||||
|
}
|
||||||
|
else -> (layoutHeight - navigationIconPlaceable.height) / 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
titlePlaceable.placeRelative(
|
||||||
|
x =
|
||||||
|
when (titleHorizontalArrangement) {
|
||||||
|
Arrangement.Center -> {
|
||||||
|
var baseX = (constraints.maxWidth - titlePlaceable.width) / 2
|
||||||
|
if (baseX < navigationIconPlaceable.width) {
|
||||||
|
// May happen if the navigation is wider than the actions and the
|
||||||
|
// title is long. In this case, prioritize showing more of the title
|
||||||
|
// by
|
||||||
|
// offsetting it to the right.
|
||||||
|
baseX += (navigationIconPlaceable.width - baseX)
|
||||||
|
} else if (
|
||||||
|
baseX + titlePlaceable.width >
|
||||||
|
constraints.maxWidth - actionIconsPlaceable.width
|
||||||
|
) {
|
||||||
|
// May happen if the actions are wider than the navigation and the
|
||||||
|
// title
|
||||||
|
// is long. In this case, offset to the left.
|
||||||
|
baseX +=
|
||||||
|
((constraints.maxWidth - actionIconsPlaceable.width) -
|
||||||
|
(baseX + titlePlaceable.width))
|
||||||
|
}
|
||||||
|
baseX
|
||||||
|
}
|
||||||
|
Arrangement.End ->
|
||||||
|
constraints.maxWidth - titlePlaceable.width - actionIconsPlaceable.width
|
||||||
|
// Arrangement.Start.
|
||||||
|
// An TopAppBarTitleInset will make sure the title is offset in case the
|
||||||
|
// navigation icon is missing.
|
||||||
|
else -> max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width)
|
||||||
|
},
|
||||||
|
y =
|
||||||
|
when (titleVerticalArrangement) {
|
||||||
|
Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2
|
||||||
|
// Apply bottom padding from the title's baseline only when the Arrangement
|
||||||
|
// is
|
||||||
|
// "Bottom".
|
||||||
|
Arrangement.Bottom -> {
|
||||||
|
val padding = if (titleBottomPadding == 0) {
|
||||||
|
(constraints.maxHeight - titlePlaceable.height) / 2
|
||||||
|
} else {
|
||||||
|
titleBottomPadding
|
||||||
|
}
|
||||||
|
// Calculate the actual padding from the bottom of the title, taking
|
||||||
|
// into account its baseline.
|
||||||
|
val paddingFromBottom =
|
||||||
|
padding - (titlePlaceable.height - titleBaseline)
|
||||||
|
// Adjust the bottom padding to a smaller number if there is no room
|
||||||
|
// to
|
||||||
|
// fit the title.
|
||||||
|
val heightWithPadding = paddingFromBottom + titlePlaceable.height
|
||||||
|
val adjustedBottomPadding =
|
||||||
|
if (heightWithPadding > constraints.maxHeight) {
|
||||||
|
paddingFromBottom -
|
||||||
|
(heightWithPadding - constraints.maxHeight)
|
||||||
|
} else {
|
||||||
|
paddingFromBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutHeight - titlePlaceable.height - max(0, adjustedBottomPadding)
|
||||||
|
}
|
||||||
|
// Arrangement.Top
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action icons
|
||||||
|
actionIconsPlaceable.placeRelative(
|
||||||
|
x = constraints.maxWidth - actionIconsPlaceable.width,
|
||||||
|
y = (layoutHeight - actionIconsPlaceable.height) / 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ProvideContentColorTextStyle(
|
||||||
|
contentColor: Color,
|
||||||
|
textStyle: TextStyle,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val mergedStyle = LocalTextStyle.current.merge(textStyle)
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContentColor provides contentColor,
|
||||||
|
LocalTextStyle provides mergedStyle,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun interface ScrolledOffset {
|
||||||
|
fun offset(): Float
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun settleAppBar(
|
||||||
|
state: TopAppBarState,
|
||||||
|
velocity: Float,
|
||||||
|
flingAnimationSpec: DecayAnimationSpec<Float>?,
|
||||||
|
snapAnimationSpec: AnimationSpec<Float>?
|
||||||
|
): Velocity {
|
||||||
|
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
|
||||||
|
// and just return Zero Velocity.
|
||||||
|
// Note that we don't check for 0f due to float precision with the collapsedFraction
|
||||||
|
// calculation.
|
||||||
|
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
|
||||||
|
return Velocity.Zero
|
||||||
|
}
|
||||||
|
var remainingVelocity = velocity
|
||||||
|
// In case there is an initial velocity that was left after a previous user fling, animate to
|
||||||
|
// continue the motion to expand or collapse the app bar.
|
||||||
|
if (flingAnimationSpec != null && abs(velocity) > 1f) {
|
||||||
|
var lastValue = 0f
|
||||||
|
AnimationState(
|
||||||
|
initialValue = 0f,
|
||||||
|
initialVelocity = velocity,
|
||||||
|
)
|
||||||
|
.animateDecay(flingAnimationSpec) {
|
||||||
|
val delta = value - lastValue
|
||||||
|
val initialHeightOffset = state.heightOffset
|
||||||
|
state.heightOffset = initialHeightOffset + delta
|
||||||
|
val consumed = abs(initialHeightOffset - state.heightOffset)
|
||||||
|
lastValue = value
|
||||||
|
remainingVelocity = this.velocity
|
||||||
|
// avoid rounding errors and stop if anything is unconsumed
|
||||||
|
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Snap if animation specs were provided.
|
||||||
|
if (snapAnimationSpec != null) {
|
||||||
|
// FIXME: Only snap the top app bar
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TopAppBarState.topHeightOffset(topHeightPx: Float, totalHeightPx: Float): Float {
|
||||||
|
val offset = heightOffset + (totalHeightPx - topHeightPx)
|
||||||
|
return offset.coerceIn(-topHeightPx, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TopAppBarState.bottomHeightOffset(topHeightPx: Float, totalHeightPx: Float): Float {
|
||||||
|
return heightOffset.coerceIn(topHeightPx - totalHeightPx, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TopAppBarState.topCollapsedFraction(topHeightPx: Float): Float {
|
||||||
|
return heightOffset / -topHeightPx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TopAppBarState.bottomCollapsedFraction(topHeightPx: Float, totalHeightPx: Float): Float {
|
||||||
|
return heightOffset / (topHeightPx - totalHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun enterAlwaysCollapsedScrollBehavior(
|
||||||
|
state: TopAppBarState = rememberTopAppBarState(),
|
||||||
|
canScroll: () -> Boolean = { true },
|
||||||
|
flingAnimationSpec: DecayAnimationSpec<Float>? = rememberSplineBasedDecay()
|
||||||
|
): TopAppBarScrollBehavior {
|
||||||
|
val topHeightPx: Float
|
||||||
|
val totalHeightPx: Float
|
||||||
|
LocalDensity.current.run {
|
||||||
|
topHeightPx = CollapsedContainerHeight.toPx()
|
||||||
|
totalHeightPx = ExpandedContainerHeight.toPx()
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnterAlwaysCollapsedScrollBehavior(
|
||||||
|
state = state,
|
||||||
|
flingAnimationSpec = flingAnimationSpec,
|
||||||
|
canScroll = canScroll,
|
||||||
|
topHeightPx = topHeightPx,
|
||||||
|
totalHeightPx = totalHeightPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Current it behaves exactly like EnterAlways
|
||||||
|
private class EnterAlwaysCollapsedScrollBehavior(
|
||||||
|
override val state: TopAppBarState,
|
||||||
|
override val flingAnimationSpec: DecayAnimationSpec<Float>?,
|
||||||
|
val canScroll: () -> Boolean = { true },
|
||||||
|
val topHeightPx: Float,
|
||||||
|
val totalHeightPx: Float,
|
||||||
|
) : TopAppBarScrollBehavior {
|
||||||
|
override val snapAnimationSpec: AnimationSpec<Float>? = null // J2K's app bar doesn't do snap
|
||||||
|
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()) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val CollapsedContainerHeight = 64.0.dp
|
||||||
|
val ExpandedContainerHeight = 152.0.dp
|
||||||
|
internal val TopTitleAlphaEasing = CubicBezierEasing(.8f, 0f, .8f, .15f)
|
||||||
|
private val MediumTitleBottomPadding = 24.dp
|
||||||
|
private val LargeTitleBottomPadding = 28.dp
|
||||||
|
private val TopAppBarHorizontalPadding = 4.dp
|
||||||
|
private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding
|
Loading…
Add table
Add a link
Reference in a new issue