From 6ae6f609266ab87c346d2dcab77248aad48745cd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:46:12 +0530 Subject: [PATCH] feat: hero overscroll --- .../app/core/ui/HeroStretchOverscroll.kt | 147 ++++++++++++++++++ .../app/features/details/MetaDetailsScreen.kt | 5 + .../features/details/components/DetailHero.kt | 22 ++- .../com/nuvio/app/features/home/HomeScreen.kt | 12 +- .../home/components/HomeHeroSection.kt | 44 ++++-- 5 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt new file mode 100644 index 000000000..50afd2b6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt @@ -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( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 320f, +) +private val HeroStretchBounceSpring = spring( + 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) { + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 80c3265c2..5fb5607ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 32d6dd332..b60064fe1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index bd0ee9f0c..f502369f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 439aa0f11..4844027b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -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(