diff --git a/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt b/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt index 1431abad..d8442a9b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/StretchOverscroll.kt @@ -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() } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index cb3f38be..a9513f6d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -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(