mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-04 10:36:56 +00:00
feat: hero overscroll
This commit is contained in:
parent
57a8405a54
commit
6ae6f60926
5 changed files with 207 additions and 23 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue