mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
feat(android,ios): parallax title page with a sticky bar
On a phone the backdrop at the top of a title page now moves at half the speed of the page scrolling over it, and the back button sits in a bar that fills in with the title's name once the backdrop has gone, so a long episode list still says whose it is. The fade into the page is tinted with the backdrop's colour, as on the library's banner. iOS keeps its slide in from the edge: the zoom transition needs a NavigationStack, and the title page is shown over the library by hand.
This commit is contained in:
1 parent
c6d303f269
commit
c026f81283
7 files changed
+158
-30
No files matched your search
@@ -5,6 +5,12 @@ import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -174,8 +180,12 @@ internal fun TitleScreen(
|
||||
) {
|
||||
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||
val list = rememberLazyListState()
|
||||
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
|
||||
val veil = moodVeil(mood)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = list,
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -183,26 +193,27 @@ internal fun TitleScreen(
|
||||
// Edge to edge, fading into the page: the art is the first thing
|
||||
// the page says, and a framed thumbnail with a margin round it read
|
||||
// as one card among the many below it.
|
||||
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||
Backdrop(title, Modifier.fillMaxSize())
|
||||
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) {
|
||||
// At half the speed of the page, so the picture stays
|
||||
// behind the page rather than being part of it.
|
||||
Backdrop(
|
||||
title,
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f
|
||||
},
|
||||
onMood = { mood = it },
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.45f to Color.Transparent,
|
||||
0.4f to Color.Transparent,
|
||||
// The picture's own colour on the way into the
|
||||
// page, as on the library's banner.
|
||||
0.75f to veil.copy(alpha = 0.55f),
|
||||
1f to MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "heading") {
|
||||
@@ -439,6 +450,7 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack)
|
||||
}
|
||||
if (showMatch) {
|
||||
ChangeMatchDialog(
|
||||
@@ -450,6 +462,62 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** How fast the backdrop moves against the page scrolling over it. */
|
||||
private const val PARALLAX = 0.5f
|
||||
|
||||
/**
|
||||
* The bar over the title page: only the back button while the backdrop is on
|
||||
* screen, then — once the backdrop has scrolled away — a solid bar with the
|
||||
* title's name, so the page still says whose episodes these are.
|
||||
*/
|
||||
@Composable
|
||||
private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack: () -> Unit) {
|
||||
val reach = with(LocalDensity.current) { TITLE_BAR.toPx() }
|
||||
val solid by remember(list, backdrop) {
|
||||
derivedStateOf {
|
||||
if (list.firstVisibleItemIndex > 0) {
|
||||
1f
|
||||
} else {
|
||||
((list.firstVisibleItemScrollOffset - (backdrop - 2 * reach)) / reach).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(scheme.surfaceContainer.copy(alpha = solid))
|
||||
.statusBarsPadding()
|
||||
.height(TITLE_BAR),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
// A disc behind the arrow over the picture, gone once the bar
|
||||
// is there to sit on.
|
||||
.background(Color.Black.copy(alpha = 0.4f * (1f - solid))),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to library",
|
||||
tint = lerp(Color.White, scheme.onSurface, solid),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp).graphicsLayer { alpha = solid },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TITLE_BAR = 56.dp
|
||||
|
||||
/**
|
||||
* One part of the franchise: its cover, what it is and when. This title's own
|
||||
* is marked rather than left out, so the row says where it sits in the story.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 635 KiB After Width: | Height: | Size: 644 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 636 KiB After Width: | Height: | Size: 641 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
Reference in new issue
Block a user