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:
nvrl committed 2026-10-30 21:22:56 +01:00
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

+4
View File
@@ -90,6 +90,10 @@ Each of these is the platform's doing, not a choice to diverge.
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back.
- **No zoom into a title page.** iOS 18's `.navigationTransition(.zoom)` only
runs inside a `NavigationStack`, and the title page is shown over the library
by hand, so it keeps its slide in from the edge — Android's shared poster has
no counterpart here.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync
+3 -1
View File
@@ -67,7 +67,9 @@ containers.
## A title's page
Click a title to open its page. **Esc** goes back.
Click a title to open its page. **Esc** goes back. On a phone, the picture at the top slides
away more slowly than the page, and once it has gone a bar with the title's name stays at the
top.
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
the beginning.
+70 -16
View File
@@ -3,6 +3,12 @@ import SwiftUI
/// How wide the page's text and controls run on a window wider than a phone.
private let readableWidth: CGFloat = 720
/// How fast the backdrop moves against the page scrolling over it.
private let parallax: CGFloat = 0.5
/// The bar over the page — Android's `TITLE_BAR`.
private let titleBarHeight: CGFloat = 56
struct TitleView: View {
let title: TitleRecord
let onPlay: ([EpisodeRecord], Int) -> Void
@@ -22,6 +28,10 @@ struct TitleView: View {
// gesture is dragged, and springs back if it is let go of — Android's
// predictive back, so the viewer sees where back goes before committing.
@State private var backProgress: CGFloat = 0
/// How far the backdrop has scrolled up, which is how solid the bar is.
@State private var scrolled: CGFloat = 0
/// The backdrop's colour, once it has arrived — see `pictureMood`.
@State private var mood: UInt32?
init(
title: TitleRecord,
@@ -57,7 +67,7 @@ struct TitleView: View {
let backdropHeight = min(geometry.size.width * 9 / 16, geometry.size.height * 0.55)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
backdrop(height: backdropHeight + geometry.safeAreaInsets.top, top: geometry.safeAreaInsets.top)
backdrop(height: backdropHeight + geometry.safeAreaInsets.top)
heading(playlist: playlist, nextUp: nextUp, upNext: upNext)
.padding(.horizontal, side + 16)
if franchise.count > 1 {
@@ -85,7 +95,11 @@ struct TitleView: View {
.padding(.bottom, 24)
}
.ignoresSafeArea(edges: .top)
.coordinateSpace(name: "title")
.background(scheme.background)
.overlay(alignment: .top) {
titleBar(solid: min(max((scrolled - (backdropHeight - 2 * titleBarHeight)) / titleBarHeight, 0), 1))
}
}
.scaleEffect(1 - 0.1 * backProgress)
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
@@ -110,6 +124,7 @@ struct TitleView: View {
}
.onChange(of: title.key) {
overviewOpen = false
mood = nil
let playlist = title.playlist
let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil
seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
@@ -117,32 +132,71 @@ struct TitleView: View {
}
/// Edge to edge, fading into the page: the art is the first thing the page
/// says.
private func backdrop(height: CGFloat, top: CGFloat) -> some View {
Backdrop(title: title)
/// says. It moves at half the speed of the page, so the picture stays
/// behind the page rather than being part of it.
private func backdrop(height: CGFloat) -> some View {
Backdrop(title: title) { mood = $0 }
.frame(height: height)
.frame(maxWidth: .infinity)
.visualEffect { content, proxy in
content.offset(y: max(-proxy.frame(in: .named("title")).minY, 0) * parallax)
}
.clipped()
.overlay {
LinearGradient(
stops: [.init(color: .clear, location: 0.45), .init(color: scheme.background, location: 1)],
stops: [
.init(color: .clear, location: 0.4),
// The picture's own colour on the way into the page,
// as on the library's banner.
.init(color: scheme.veil(mood).opacity(0.55), location: 0.75),
.init(color: scheme.background, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.animation(Motion.arrive, value: mood)
}
.overlay(alignment: .topLeading) {
Button(action: onBack) {
Image(systemName: "chevron.backward")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(.black.opacity(0.4), in: Circle())
.frame(width: 48, height: 48)
.background {
GeometryReader { geometry in
Color.clear.onChange(of: geometry.frame(in: .named("title")).minY) { _, minY in
scrolled = -minY
}
}
.accessibilityLabel("Back to library")
.padding(8)
.padding(.top, top)
}
// Scrolled far enough to leave the lazy stack: the bar is long
// since solid.
.onDisappear { scrolled = .greatestFiniteMagnitude }
.onAppear { scrolled = 0 }
}
/// The bar over the page: only the back button while the backdrop is on
/// screen, then a solid bar with the title's name once it has scrolled
/// away, so the page still says whose episodes these are.
private func titleBar(solid: CGFloat) -> some View {
HStack(spacing: 0) {
Button(action: onBack) {
Image(systemName: "chevron.backward")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(solid > 0.5 ? scheme.onSurface : .white)
.frame(width: 40, height: 40)
// A disc behind the arrow over the picture, gone once the
// bar is there to sit on.
.background(.black.opacity(0.4 * (1 - solid)), in: Circle())
.frame(width: 48, height: 48)
}
.accessibilityLabel("Back to library")
.padding(.horizontal, 8)
Text(title.displayName)
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.lineLimit(1)
.opacity(solid)
.padding(.trailing, 16)
Spacer(minLength: 0)
}
.frame(height: titleBarHeight)
.background(scheme.surfaceContainer.opacity(solid).ignoresSafeArea(edges: .top))
.animation(.easeOut(duration: 0.15), value: solid > 0.5)
}
@ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {