render the stretch from the wrapper layer, later bar fade, punchier blobs

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 9e33b09af2
commit c787335a1e
3 changed files with 83 additions and 64 deletions
@@ -10,51 +10,26 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.unit.Velocity
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 const val MAX_STRETCH_FRACTION = 0.14f
private class StretchOverscrollEffect : OverscrollEffect {
private val overscroll = mutableFloatStateOf(0f)
val overscroll = mutableFloatStateOf(0f)
var limitPx: Float = 1f
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()
}
}
}
}
private val emptyNode = object : Modifier.Node() {}
override val node: DelegatableNode get() = drawNode
override val node: DelegatableNode get() = emptyNode
override val isInProgress: Boolean get() = overscroll.floatValue != 0f
@@ -76,7 +51,7 @@ private class StretchOverscrollEffect : OverscrollEffect {
val leftover = remainingY - consumed.y
if (leftover != 0f) {
val current = overscroll.floatValue
val next = current + leftover
val next = (current + leftover).coerceIn(-limitPx, limitPx)
overscroll.floatValue =
when {
current > 0f -> next.coerceAtLeast(0f)
@@ -92,9 +67,10 @@ private class StretchOverscrollEffect : OverscrollEffect {
performFling: suspend (Velocity) -> Velocity,
) {
if (overscroll.floatValue != 0f) {
Animatable(overscroll.floatValue).animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) {
overscroll.floatValue = value
}
Animatable(overscroll.floatValue)
.animateTo(0f, spring(stiffness = Spring.StiffnessMedium)) {
overscroll.floatValue = value
}
}
performFling(velocity)
}
@@ -105,7 +81,34 @@ fun StretchOverscroll(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(LocalOverscrollFactory provides StretchOverscrollFactory) {
Box(modifier) { content() }
val effect = remember { StretchOverscrollEffect() }
val factory =
remember {
object : OverscrollFactory {
override fun createOverscrollEffect(): OverscrollEffect = effect
override fun hashCode(): Int = 0x5EED_1234
override fun equals(other: Any?): Boolean = other === this
}
}
CompositionLocalProvider(LocalOverscrollFactory provides factory) {
Box(
modifier =
modifier
.onSizeChanged { effect.limitPx = (it.height * MAX_STRETCH_FRACTION).coerceAtLeast(1f) }
.graphicsLayer {
val value = effect.overscroll.floatValue
if (value == 0f) return@graphicsLayer
val height = size.height
val resisted = value / (1f + abs(value) / (height * 0.35f))
scaleY = 1f + abs(resisted) / height
translationY = resisted
transformOrigin = TransformOrigin(0.5f, if (resisted > 0f) 0f else 1f)
},
) {
content()
}
}
}
@@ -137,7 +137,7 @@ fun AlbumDetailScreen(
var headerHeightPx by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableFloatStateOf(0f) }
var titleBottomPx by remember { mutableFloatStateOf(0f) }
var controlsTopPx by remember { mutableFloatStateOf(0f) }
val scrollPx by remember {
derivedStateOf {
val index = listState.firstVisibleItemIndex
@@ -147,10 +147,10 @@ fun AlbumDetailScreen(
}
val barFraction by remember {
derivedStateOf {
if (titleBottomPx <= 0f || fadeDistancePx <= 0f) {
if (controlsTopPx <= 0f || fadeDistancePx <= 0f) {
0f
} else {
((scrollPx - (titleBottomPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
((scrollPx - (controlsTopPx - barHeightPx)) / fadeDistancePx).coerceIn(0f, 1f)
}
}
}
@@ -189,9 +189,9 @@ fun AlbumDetailScreen(
onDownload = onDownload,
onMore = onMore,
topInset = statusBarDp,
titleModifier =
controlsModifier =
Modifier.onGloballyPositioned {
titleBottomPx = it.positionInParent().y + it.size.height
controlsTopPx = it.positionInParent().y
},
modifier = Modifier.onSizeChanged { headerHeightPx = it.height.toFloat() },
)
@@ -272,7 +272,7 @@ private fun AlbumHeader(
onDownload: () -> Unit,
onMore: () -> Unit,
topInset: Dp,
titleModifier: Modifier = Modifier,
controlsModifier: Modifier = Modifier,
modifier: Modifier = Modifier,
) {
Column(
@@ -294,7 +294,6 @@ private fun AlbumHeader(
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = titleModifier,
)
val subtitle =
listOfNotNull(
@@ -314,6 +313,7 @@ private fun AlbumHeader(
}
Spacer(Modifier.height(20.dp))
Row(
modifier = controlsModifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.palette.graphics.Palette
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext
@@ -45,8 +46,7 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
sat: Float,
l: Float,
) = Color.hsl(h % 360f, sat.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
val primary = tone(hue, s, 0.62f)
val primary = toneMaxLuminance(hue, s, 0.62f, 0.45f)
val background = tone(hue, (s * 0.35f).coerceAtMost(0.20f), 0.06f)
val onBackground = tone(hue, (s * 0.10f).coerceAtMost(0.08f), 0.95f)
@@ -82,12 +82,28 @@ fun artworkColorsFromSeed(seed: Int): ArtworkColors {
return ArtworkColors(
scheme = scheme,
gradientHigh = tone(hue, (s * 0.8f).coerceAtMost(0.60f), 0.30f),
gradientLow = tone(hue, s * 0.25f, 0.04f),
accents = listOf(tone(hue, s, 0.78f), tone(hue, s * 0.9f, 0.20f)),
gradientHigh = tone(hue, (s * 0.6f).coerceAtMost(0.50f), 0.22f),
gradientLow = tone(hue, s * 0.30f, 0.03f),
accents = listOf(tone(hue, s * 0.75f, 0.95f), tone(hue, s, 0.08f)),
)
}
private fun toneMaxLuminance(
h: Float,
s: Float,
lightness: Float,
maxLuminance: Float,
): Color {
var l = lightness
var color = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
var guard = 0
while (color.luminance() > maxLuminance && guard++ < 30) {
l = (l - 0.02f).coerceAtLeast(0.05f)
color = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l)
}
return color
}
@Composable
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
val seed by rememberArtworkSeed(ref)
@@ -169,9 +185,9 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.55f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.18f, size.height * 0.03f),
radius = size.width * 0.95f,
colors = listOf(accent.copy(alpha = 0.85f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.20f, size.height * 0.00f),
radius = size.width * 0.60f,
),
)
}
@@ -179,9 +195,9 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.50f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.92f, size.height * 0.28f),
radius = size.width * 0.85f,
colors = listOf(accent.copy(alpha = 0.80f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.90f, size.height * 0.26f),
radius = size.width * 0.70f,
),
)
}
@@ -189,18 +205,18 @@ fun HeroGradient(
drawRect(
brush =
Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.30f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.85f, size.height * 0.02f),
radius = size.width * 0.70f,
colors = listOf(accent.copy(alpha = 0.55f), accent.copy(alpha = 0f)),
center = Offset(size.width * 0.82f, size.height * 0.00f),
radius = size.width * 0.45f,
),
)
}
drawRect(
brush =
Brush.radialGradient(
colors = listOf(high.copy(alpha = 0.45f), high.copy(alpha = 0f)),
center = Offset(size.width * 0.35f, size.height * 0.52f),
radius = size.width * 0.95f,
colors = listOf(high.copy(alpha = 0.55f), high.copy(alpha = 0f)),
center = Offset(size.width * 0.40f, size.height * 0.50f),
radius = size.width * 0.80f,
),
)
drawRect(