From e5416e1d3375ff321d4cce448682eb4f0924c8c5 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:52:14 +0000 Subject: [PATCH] make the library header fade over the scrolling hero gradient --- .../ui/components/HeroDetailScaffold.kt | 19 +-- .../com/subtracks/ui/library/LibraryScreen.kt | 121 ++++++++++++------ .../com/subtracks/ui/theme/ArtworkTheme.kt | 12 ++ 3 files changed, 98 insertions(+), 54 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index aa1186f8..e7b987c6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged 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.ArtworkTheme import com.subtracks.ui.theme.HeroGradient -import com.subtracks.ui.theme.gradientColorAt -import com.subtracks.ui.theme.heroDarkenAt +import com.subtracks.ui.theme.heroBarColor -private const val GRADIENT_SCREENS = 2.0f private const val FADE_DISTANCE_DP = 64 /** @@ -92,7 +89,6 @@ fun HeroDetailScaffold( val listState = rememberLazyListState() val fill = rememberViewportFill(listState) val density = LocalDensity.current - val gradientHeightPx = with(density) { (maxHeight * GRADIENT_SCREENS).toPx() } val screenHeightPx = with(density) { maxHeight.toPx() } val statusBarTop = WindowInsets.statusBars.getTop(density) val statusBarDp = with(density) { statusBarTop.toDp() } @@ -120,18 +116,9 @@ fun HeroDetailScaffold( } } val barColor by - remember(artwork, barHeightPx, gradientHeightPx, screenHeightPx) { + remember(artwork, barHeightPx, screenHeightPx) { derivedStateOf { - if (artwork == null || gradientHeightPx <= 0f) { - Color.Black - } else { - val mid = scrollPx + barHeightPx / 2f - lerp( - artwork.gradientColorAt(mid / gradientHeightPx), - artwork.darkPrimary, - heroDarkenAt(mid, screenHeightPx), - ) - } + heroBarColor(artwork, scrollPx, barHeightPx, screenHeightPx) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index e1f8c62d..9bc67ef3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -39,7 +40,9 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,10 +53,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp 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.layout.boundsInParent 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.ui.theme.HeroGradient 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 kotlin.math.abs import kotlin.math.roundToInt @@ -170,64 +179,100 @@ fun LibraryScreen( SideEffect { scrollBehavior.state.heightOffsetLimit = -with(density) { titleHeight.toPx() } } val titleFraction = scrollBehavior.state.collapsedFraction - val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } - val artwork = LocalPlayerArtwork.current - val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.surfaceContainerHigh + var scrollPx by remember { mutableFloatStateOf(0f) } + val scrollTracker = + 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 .fillMaxSize() .background(MaterialTheme.colorScheme.background) + .nestedScroll(scrollTracker) .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 + .align(Alignment.TopStart) .fillMaxWidth() - .background(headerColor) - .padding(top = statusBarTop), - ) { - Box( + .height(statusBarTop + 8.dp) + .background( + Brush.verticalGradient( + listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), + ), + ).graphicsLayer { alpha = 1f - titleFraction }, + ) + + Column(Modifier.fillMaxSize()) { + Column( modifier = Modifier .fillMaxWidth() - .height(titleHeight * (1f - titleFraction)) - .clipToBounds(), + .drawBehind { drawRect(barColor.copy(alpha = titleFraction)) } + .padding(top = statusBarTop), ) { - Text( - text = selectedTab.label, - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, + Box( modifier = Modifier - .offset(y = -(titleHeight * titleFraction)) - .wrapContentHeight(unbounded = true) - .padding(start = 8.dp, end = 16.dp, top = titleTop), + .fillMaxWidth() + .height(titleHeight * (1f - titleFraction)) + .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( state = pagerState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth().weight(1f), ) { page -> when (LibraryTab.entries[page]) { LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick) diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index ae29f0b5..828ccdbc 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -210,6 +210,18 @@ val LocalPlayerArtwork = compositionLocalOf { null } 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 fun HeroGradient( colors: ArtworkColors?,