mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +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 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user