make the library header fade over the scrolling hero gradient

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 5535a41f5d
commit e5416e1d33
3 changed files with 98 additions and 54 deletions
@@ -50,7 +50,6 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
@@ -63,10 +62,8 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.gradientColorAt import com.subtracks.ui.theme.heroBarColor
import com.subtracks.ui.theme.heroDarkenAt
private const val GRADIENT_SCREENS = 2.0f
private const val FADE_DISTANCE_DP = 64 private const val FADE_DISTANCE_DP = 64
/** /**
@@ -92,7 +89,6 @@ fun HeroDetailScaffold(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
val gradientHeightPx = with(density) { (maxHeight * GRADIENT_SCREENS).toPx() }
val screenHeightPx = with(density) { maxHeight.toPx() } val screenHeightPx = with(density) { maxHeight.toPx() }
val statusBarTop = WindowInsets.statusBars.getTop(density) val statusBarTop = WindowInsets.statusBars.getTop(density)
val statusBarDp = with(density) { statusBarTop.toDp() } val statusBarDp = with(density) { statusBarTop.toDp() }
@@ -120,18 +116,9 @@ fun HeroDetailScaffold(
} }
} }
val barColor by val barColor by
remember(artwork, barHeightPx, gradientHeightPx, screenHeightPx) { remember(artwork, barHeightPx, screenHeightPx) {
derivedStateOf { derivedStateOf {
if (artwork == null || gradientHeightPx <= 0f) { heroBarColor(artwork, scrollPx, barHeightPx, screenHeightPx)
Color.Black
} else {
val mid = scrollPx + barHeightPx / 2f
lerp(
artwork.gradientColorAt(mid / gradientHeightPx),
artwork.darkPrimary,
heroDarkenAt(mid, screenHeightPx),
)
}
} }
} }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -39,7 +40,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -50,10 +53,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -71,7 +80,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.LocalPlayerArtwork import com.subtracks.ui.theme.LocalPlayerArtwork
import com.subtracks.ui.theme.playerSurfaceColor import com.subtracks.ui.theme.heroBarColor
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -170,64 +179,100 @@ fun LibraryScreen(
SideEffect { scrollBehavior.state.heightOffsetLimit = -with(density) { titleHeight.toPx() } } SideEffect { scrollBehavior.state.heightOffsetLimit = -with(density) { titleHeight.toPx() } }
val titleFraction = scrollBehavior.state.collapsedFraction val titleFraction = scrollBehavior.state.collapsedFraction
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } var scrollPx by remember { mutableFloatStateOf(0f) }
val artwork = LocalPlayerArtwork.current val scrollTracker =
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.surfaceContainerHigh remember {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
scrollPx = (scrollPx - consumed.y).coerceAtLeast(0f)
return Offset.Zero
}
}
}
Column( val artwork = LocalPlayerArtwork.current
val statusBarTopPx = WindowInsets.statusBars.getTop(density)
val statusBarTop = with(density) { statusBarTopPx.toDp() }
BoxWithConstraints(
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.nestedScroll(scrollTracker)
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
) { ) {
Column( val screenHeightPx = with(density) { maxHeight.toPx() }
val barHeightPx = statusBarTopPx + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val selectedColor = heroBarColor(artwork, 0f, barHeightPx, screenHeightPx)
val barColor by
remember(artwork, barHeightPx, screenHeightPx) {
derivedStateOf { heroBarColor(artwork, scrollPx, barHeightPx, screenHeightPx) }
}
HeroGradient(
colors = artwork,
scrollPx = { scrollPx },
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart)
.fillMaxWidth() .fillMaxWidth()
.background(headerColor) .height(statusBarTop + 8.dp)
.padding(top = statusBarTop), .background(
) { Brush.verticalGradient(
Box( listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent),
),
).graphicsLayer { alpha = 1f - titleFraction },
)
Column(Modifier.fillMaxSize()) {
Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(titleHeight * (1f - titleFraction)) .drawBehind { drawRect(barColor.copy(alpha = titleFraction)) }
.clipToBounds(), .padding(top = statusBarTop),
) { ) {
Text( Box(
text = selectedTab.label,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
modifier = modifier =
Modifier Modifier
.offset(y = -(titleHeight * titleFraction)) .fillMaxWidth()
.wrapContentHeight(unbounded = true) .height(titleHeight * (1f - titleFraction))
.padding(start = 8.dp, end = 16.dp, top = titleTop), .clipToBounds(),
) {
Text(
text = selectedTab.label,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
modifier =
Modifier
.offset(y = -(titleHeight * titleFraction))
.wrapContentHeight(unbounded = true)
.padding(start = 8.dp, end = 16.dp, top = titleTop),
)
}
LibraryTabs(
pagerState = pagerState,
onTabSelected = onTabSelected,
syncing = syncing,
onSync = onSync,
onOpenSettings = onOpenSettings,
selectedColor = selectedColor,
) )
} }
LibraryTabs(
pagerState = pagerState,
onTabSelected = onTabSelected,
syncing = syncing,
onSync = onSync,
onOpenSettings = onOpenSettings,
selectedColor = headerColor,
)
}
Box(Modifier.fillMaxWidth().weight(1f)) {
if (artwork != null) {
HeroGradient(
colors = artwork,
scrollPx = { 0f },
modifier = Modifier.fillMaxSize(),
)
}
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxWidth().weight(1f),
) { page -> ) { page ->
when (LibraryTab.entries[page]) { when (LibraryTab.entries[page]) {
LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick) LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick)
@@ -210,6 +210,18 @@ val LocalPlayerArtwork = compositionLocalOf<ArtworkColors?> { null }
fun playerSurfaceColor(artwork: ArtworkColors): Color = lerp(artwork.scheme.surfaceContainerHigh, artwork.gradientHigh, 0.5f) fun playerSurfaceColor(artwork: ArtworkColors): Color = lerp(artwork.scheme.surfaceContainerHigh, artwork.gradientHigh, 0.5f)
fun heroBarColor(
artwork: ArtworkColors?,
scrollPx: Float,
barHeightPx: Float,
screenHeightPx: Float,
): Color {
if (artwork == null || screenHeightPx <= 0f) return Color.Black
val mid = scrollPx + barHeightPx / 2f
val gradientHeightPx = screenHeightPx * PERIOD_SCREENS
return lerp(artwork.gradientColorAt(mid / gradientHeightPx), artwork.darkPrimary, heroDarkenAt(mid, screenHeightPx))
}
@Composable @Composable
fun HeroGradient( fun HeroGradient(
colors: ArtworkColors?, colors: ArtworkColors?,