feat: hero overscroll

This commit is contained in:
tapframe 2026-07-16 13:46:12 +05:30
parent 57a8405a54
commit 6ae6f60926
5 changed files with 207 additions and 23 deletions

View file

@ -0,0 +1,147 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
private val HERO_STRETCH_MAX = 200.dp
private const val HERO_STRETCH_DRAG_RESISTANCE = 0.55f
private const val HERO_STRETCH_RELEASE_ABSORB = 0.35f
private const val HERO_STRETCH_FRAME_TO_VELOCITY = 60f
private val HeroStretchReleaseSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = 320f,
)
private val HeroStretchBounceSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = 110f,
)
@Stable
class HeroStretchState internal constructor(
private val scope: CoroutineScope,
private val maxStretchPx: Float,
private val isAtTop: () -> Boolean,
) {
private val stretchAnim = Animatable(0f)
private var settleJob: Job? = null
private var flingAbsorbed = false
val stretchPx: Float get() = stretchAnim.value
val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.UserInput) {
flingAbsorbed = false
}
val stretch = stretchAnim.value
if (available.y < 0f && stretch > 0f) {
val consumedY = available.y.coerceAtLeast(-stretch)
snapTo(stretch + consumedY)
return Offset(0f, consumedY)
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (available.y <= 0f || !isAtTop()) return Offset.Zero
if (source == NestedScrollSource.UserInput) {
val stretch = stretchAnim.value
val resistance = HERO_STRETCH_DRAG_RESISTANCE *
(1f - stretch / maxStretchPx).coerceIn(0f, 1f)
snapTo(stretch + available.y * resistance)
} else if (!flingAbsorbed) {
flingAbsorbed = true
if (settleJob?.isActive != true) {
settle(available.y * HERO_STRETCH_FRAME_TO_VELOCITY, HeroStretchBounceSpring)
}
}
return Offset(0f, available.y)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (stretchAnim.value <= 0.5f) return Velocity.Zero
if (settleJob?.isActive != true) {
settle(available.y * HERO_STRETCH_RELEASE_ABSORB, HeroStretchReleaseSpring)
}
return Velocity(0f, available.y)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
flingAbsorbed = false
if (stretchAnim.value > 0.5f && settleJob?.isActive != true) {
settle(0f, HeroStretchReleaseSpring)
}
return if (available.y > 0f && isAtTop()) {
Velocity(0f, available.y)
} else {
Velocity.Zero
}
}
}
private fun snapTo(value: Float) {
scope.launch { stretchAnim.snapTo(value.coerceIn(0f, maxStretchPx)) }
}
private fun settle(initialVelocity: Float, spec: SpringSpec<Float>) {
settleJob = scope.launch {
stretchAnim.animateTo(
targetValue = 0f,
animationSpec = spec,
initialVelocity = initialVelocity,
)
}
}
}
@Composable
fun rememberHeroStretchState(listState: LazyListState): HeroStretchState {
val scope = rememberCoroutineScope()
val maxStretchPx = with(LocalDensity.current) { HERO_STRETCH_MAX.toPx() }
return remember(listState, maxStretchPx) {
HeroStretchState(scope, maxStretchPx) { !listState.canScrollBackward }
}
}
fun Modifier.heroStretchHeight(baseHeight: Dp, stretchPx: () -> Float): Modifier =
layout { measurable, constraints ->
val height = baseHeight.roundToPx() + stretchPx().coerceAtLeast(0f).roundToInt()
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height),
)
layout(placeable.width, height) { placeable.place(0, 0) }
}
fun Modifier.heroStretchZoom(stretchPx: () -> Float): Modifier = graphicsLayer {
val zoom = 1f + stretchPx().coerceAtLeast(0f) / size.height.coerceAtLeast(1f)
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = zoom
scaleY = zoom
}

View file

@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
@ -79,6 +80,7 @@ import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.rememberHeroStretchState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import com.nuvio.app.features.details.components.DetailActionButtons
@ -754,6 +756,7 @@ fun MetaDetailsScreen(
)
}
val listState = rememberLazyListState()
val heroStretchState = rememberHeroStretchState(listState)
val density = LocalDensity.current
val safeAreaTopPx = with(density) {
WindowInsets.statusBars
@ -882,6 +885,7 @@ fun MetaDetailsScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.nestedScroll(heroStretchState.nestedScrollConnection)
.zIndex(1f),
) {
item(key = "detail-hero") {
@ -890,6 +894,7 @@ fun MetaDetailsScreen(
isTablet = isTablet,
contentMaxWidth = contentMaxWidth,
scrollOffset = heroScrollOffset,
stretchPx = { heroStretchState.stretchPx },
onHeightChanged = { heroHeightPx = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,

View file

@ -31,6 +31,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -41,13 +42,15 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.graphicsLayer
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.heroStretchHeight
import com.nuvio.app.core.ui.heroStretchZoom
import com.nuvio.app.features.details.MetaDetails
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -57,6 +60,7 @@ fun DetailHero(
meta: MetaDetails,
isTablet: Boolean = false,
scrollOffset: Int = 0,
stretchPx: () -> Float = { 0f },
contentMaxWidth: Dp = 560.dp,
onHeightChanged: (Int) -> Unit = {},
heroTrailerSourceUrl: String? = null,
@ -91,11 +95,13 @@ fun DetailHero(
}
val logoUrl = meta.logo?.takeIf { it.isNotBlank() }
val heroBaseHeightPx = with(LocalDensity.current) { heroHeight.roundToPx() }
LaunchedEffect(heroBaseHeightPx) { onHeightChanged(heroBaseHeightPx) }
Box(
modifier = Modifier
.fillMaxWidth()
.height(heroHeight)
.onSizeChanged { onHeightChanged(it.height) }
.heroStretchHeight(heroHeight, stretchPx)
.graphicsLayer {
clip = true
},
@ -111,7 +117,10 @@ fun DetailHero(
model = imageUrl,
contentDescription = meta.name,
modifier = Modifier
.fillMaxSize()
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(heroHeight)
.heroStretchZoom(stretchPx)
.graphicsLayer {
translationY = scrollOffset * 0.5f
scaleX = 1.08f
@ -140,7 +149,10 @@ fun DetailHero(
playWhenReady = heroTrailerPlayWhenReady,
muted = heroTrailerMuted,
modifier = Modifier
.fillMaxSize()
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(heroHeight)
.heroStretchZoom(stretchPx)
.graphicsLayer {
alpha = trailerAlpha
translationY = scrollOffset * 0.5f

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository
@ -22,6 +23,7 @@ import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.rememberHeroStretchState
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
@ -833,8 +835,15 @@ fun HomeScreen(
}
}
val heroStretchState = rememberHeroStretchState(homeListState)
val heroStretchModifier = if (showHeroSlot) {
Modifier.nestedScroll(heroStretchState.nestedScrollConnection)
} else {
Modifier
}
NuvioScreen(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().then(heroStretchModifier),
horizontalPadding = 0.dp,
topPadding = if (showHeroSlot) 0.dp else null,
listState = homeListState,
@ -854,6 +863,7 @@ fun HomeScreen(
viewportHeight = maxHeight,
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
listState = homeListState,
stretchPx = { heroStretchState.stretchPx },
onItemClick = onPosterClick,
)

View file

@ -52,6 +52,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.heroStretchHeight
import com.nuvio.app.core.ui.heroStretchZoom
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@ -92,6 +94,7 @@ fun HomeHeroSection(
viewportHeight: Dp? = null,
mobileBelowSectionHeightHint: Dp? = null,
listState: LazyListState? = null,
stretchPx: () -> Float = { 0f },
onItemClick: ((MetaPreview) -> Unit)? = null,
) {
if (items.isEmpty()) return
@ -170,7 +173,7 @@ fun HomeHeroSection(
Box(
modifier = Modifier
.fillMaxWidth()
.height(layout.heroHeight),
.heroStretchHeight(layout.heroHeight, stretchPx),
) {
HorizontalPager(
state = pagerState,
@ -185,22 +188,29 @@ fun HomeHeroSection(
Box(
modifier = Modifier.fillMaxSize(),
) {
visiblePages.forEach { layer ->
AsyncImage(
model = items[layer.page].banner ?: items[layer.page].poster,
contentDescription = items[layer.page].name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
translationY = heroScrollTranslationY
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(layout.heroHeight)
.heroStretchZoom(stretchPx),
) {
visiblePages.forEach { layer ->
AsyncImage(
model = items[layer.page].banner ?: items[layer.page].poster,
contentDescription = items[layer.page].name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
translationY = heroScrollTranslationY
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
)
}
}
Box(