drive overscroll from the factory, fix the gradient height, share the bar colour

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 651d548c41
commit 9e33b09af2
2 changed files with 109 additions and 79 deletions
@@ -4,93 +4,108 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.OverscrollFactory
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch
import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.30f
private object StretchOverscrollFactory : OverscrollFactory {
override fun createOverscrollEffect(): OverscrollEffect = StretchOverscrollEffect()
override fun hashCode(): Int = 0x5EED_5EED
override fun equals(other: Any?): Boolean = other is StretchOverscrollFactory
}
private class StretchOverscrollEffect : OverscrollEffect {
private val overscroll = mutableFloatStateOf(0f)
private val drawNode =
object : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
val raw = overscroll.floatValue
if (raw == 0f) {
drawContent()
return
}
val limit = size.height * MAX_STRETCH_FRACTION
val capped = raw.coerceIn(-limit, limit)
val resisted = capped / (1f + abs(capped) / (size.height * 0.5f))
val pivot = Offset(size.width / 2f, if (resisted > 0f) 0f else size.height)
scale(scaleX = 1f, scaleY = 1f + abs(resisted) / size.height, pivot = pivot) {
translate(left = 0f, top = resisted) {
this@draw.drawContent()
}
}
}
}
override val node: DelegatableNode get() = drawNode
override val isInProgress: Boolean get() = overscroll.floatValue != 0f
override fun applyToScroll(
delta: Offset,
source: NestedScrollSource,
performScroll: (Offset) -> Offset,
): Offset {
if (delta.y == 0f) return performScroll(delta)
var remainingY = delta.y
if (overscroll.floatValue != 0f) {
val current = overscroll.floatValue
val next = current + remainingY
val clamped = if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f)
remainingY -= clamped - current
overscroll.floatValue = clamped
}
val consumed = performScroll(Offset(delta.x, remainingY))
val leftover = remainingY - consumed.y
if (leftover != 0f) {
val current = overscroll.floatValue
val next = current + leftover
overscroll.floatValue =
when {
current > 0f -> next.coerceAtLeast(0f)
current < 0f -> next.coerceAtMost(0f)
else -> next
}
}
return Offset(consumed.x, delta.y)
}
override suspend fun applyToFling(
velocity: Velocity,
performFling: suspend (Velocity) -> Velocity,
) {
if (overscroll.floatValue != 0f) {
Animatable(overscroll.floatValue).animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) {
overscroll.floatValue = value
}
}
performFling(velocity)
}
}
@Composable
fun StretchOverscroll(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val overscroll = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val connection =
remember(overscroll) {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
val current = overscroll.value
if (current == 0f || available.y == 0f) return Offset.Zero
val next = current + available.y
scope.launch {
overscroll.snapTo(if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f))
}
return available
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
val current = overscroll.value
if (available.y == 0f) return Offset.Zero
val next = current + available.y
scope.launch {
overscroll.snapTo(if (current > 0f) next.coerceAtLeast(0f) else next.coerceAtMost(0f))
}
return available
}
override suspend fun onPostFling(
consumed: Velocity,
available: Velocity,
): Velocity {
overscroll.animateTo(0f, spring(stiffness = Spring.StiffnessMedium))
return available
}
}
}
CompositionLocalProvider(LocalOverscrollFactory provides null) {
Box(
modifier =
modifier
.nestedScroll(connection)
.graphicsLayer {
val raw = overscroll.value
if (raw == 0f) {
scaleY = 1f
translationY = 0f
} else {
val limit = size.height * MAX_STRETCH_FRACTION
val capped = raw.coerceIn(-limit, limit)
val resisted = capped / (1f + abs(capped) / (size.height * 0.5f))
scaleY = 1f + abs(resisted) / size.height
translationY = resisted
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f)
}
},
) {
content()
}
CompositionLocalProvider(LocalOverscrollFactory provides StretchOverscrollFactory) {
Box(modifier) { content() }
}
}
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -136,7 +137,7 @@ fun AlbumDetailScreen(
var headerHeightPx by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableFloatStateOf(0f) }
var titleTopPx by remember { mutableFloatStateOf(0f) }
var titleBottomPx by remember { mutableFloatStateOf(0f) }
val scrollPx by remember {
derivedStateOf {
val index = listState.firstVisibleItemIndex
@@ -146,10 +147,10 @@ fun AlbumDetailScreen(
}
val barFraction by remember {
derivedStateOf {
if (titleTopPx <= 0f || fadeDistancePx <= 0f) {
if (titleBottomPx <= 0f || fadeDistancePx <= 0f) {
0f
} else {
((scrollPx - (titleTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
((scrollPx - (titleBottomPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
}
}
}
@@ -164,7 +165,7 @@ fun AlbumDetailScreen(
modifier =
Modifier
.fillMaxWidth()
.height(maxHeight * GRADIENT_SCREENS)
.requiredHeight(maxHeight * GRADIENT_SCREENS)
.offset { IntOffset(0, -scrollPx.roundToInt()) }
.alpha(gradientAlpha),
)
@@ -188,7 +189,10 @@ fun AlbumDetailScreen(
onDownload = onDownload,
onMore = onMore,
topInset = statusBarDp,
titleModifier = Modifier.onGloballyPositioned { titleTopPx = it.positionInParent().y },
titleModifier =
Modifier.onGloballyPositioned {
titleBottomPx = it.positionInParent().y + it.size.height
},
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
)
}
@@ -217,6 +221,18 @@ fun AlbumDetailScreen(
).alpha(1f - barFraction),
)
Box(
Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(statusBarDp)
.background(
Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent),
),
).alpha(1f - barFraction),
)
TopAppBar(
title = {
Text(
@@ -236,7 +252,6 @@ fun AlbumDetailScreen(
modifier =
Modifier
.align(Alignment.TopStart)
.background(Color.Black.copy(alpha = barFraction))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -264,7 +279,7 @@ private fun AlbumHeader(
modifier =
modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = topInset + 8.dp, bottom = 16.dp),
.padding(start = 24.dp, end = 24.dp, top = topInset + 24.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CoverArt(