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.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.OverscrollEffect
import androidx.compose.foundation.OverscrollFactory
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.nestedscroll.NestedScrollSource 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 androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
private const val MAX_STRETCH_FRACTION = 0.30f 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 @Composable
fun StretchOverscroll( fun StretchOverscroll(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val overscroll = remember { Animatable(0f) } CompositionLocalProvider(LocalOverscrollFactory provides StretchOverscrollFactory) {
val scope = rememberCoroutineScope() Box(modifier) { content() }
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()
}
} }
} }
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -136,7 +137,7 @@ fun AlbumDetailScreen(
var headerHeightPx by remember { mutableFloatStateOf(0f) } var headerHeightPx by remember { mutableFloatStateOf(0f) }
var rowHeightPx 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 { val scrollPx by remember {
derivedStateOf { derivedStateOf {
val index = listState.firstVisibleItemIndex val index = listState.firstVisibleItemIndex
@@ -146,10 +147,10 @@ fun AlbumDetailScreen(
} }
val barFraction by remember { val barFraction by remember {
derivedStateOf { derivedStateOf {
if (titleTopPx <= 0f || fadeDistancePx <= 0f) { if (titleBottomPx <= 0f || fadeDistancePx <= 0f) {
0f 0f
} else { } else {
((scrollPx - (titleTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f) ((scrollPx - (titleBottomPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
} }
} }
} }
@@ -164,7 +165,7 @@ fun AlbumDetailScreen(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(maxHeight * GRADIENT_SCREENS) .requiredHeight(maxHeight * GRADIENT_SCREENS)
.offset { IntOffset(0, -scrollPx.roundToInt()) } .offset { IntOffset(0, -scrollPx.roundToInt()) }
.alpha(gradientAlpha), .alpha(gradientAlpha),
) )
@@ -188,7 +189,10 @@ fun AlbumDetailScreen(
onDownload = onDownload, onDownload = onDownload,
onMore = onMore, onMore = onMore,
topInset = statusBarDp, 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() }, modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
) )
} }
@@ -217,6 +221,18 @@ fun AlbumDetailScreen(
).alpha(1f - barFraction), ).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( TopAppBar(
title = { title = {
Text( Text(
@@ -236,7 +252,6 @@ fun AlbumDetailScreen(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.background(Color.Black.copy(alpha = barFraction))
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
@@ -264,7 +279,7 @@ private fun AlbumHeader(
modifier = modifier =
modifier modifier
.fillMaxWidth() .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, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CoverArt( CoverArt(