Der Schriftzug wandert wieder mit — über dem Verlauf statt in ihm
Im Commit davor blendete nur noch das Bild über; Schriftzug, Zeile und
Knöpfe standen still und wechselten schlagartig. Das war zu viel
weggenommen: die Bewegung gehört dazu.
Jetzt wandert der Text wieder mit — aber an der richtigen Stelle im
Stapel. Der Aufmacher zeichnet seinen Inhalt während eines Wechsels
zweimal: den weichenden deckend, den kommenden mit demselben Einblenden
und demselben Hereinrutschen wie das Bild darunter. Beides liegt **über**
dem Verlauf, und dort kostet es ihn nichts.
Der Unterschied zum alten Aufbau ist genau diese Reihenfolge. Vorher
lagen zwei **vollständige** Aufmacher übereinander, jeder mit eigenem
Verlauf — und zwei halbdurchsichtige Verläufe ergeben zusammen keinen
ganzen. Jetzt gibt es einen Verlauf, einmal, in voller Stärke.
Nachgemessen über einen auf sechs Sekunden gestreckten Wechsel, an
derselben Stelle im Bild:
ursprünglich 68 .. 154
jetzt 63 .. 75
Dafür ist der Textsatz in `HeroBanner` eine eigene Funktion geworden —
es gibt ihn während eines Wechsels zweimal — und `HeroSection.metadata`
ist `static`, weil die Zeile auch für den weichenden Titel gebraucht
wird. `titleView` und `plainTitle` sind darin aufgegangen.
iOS und tvOS bauen, 476 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63c664e5a9
commit
37bb153dd8
@@ -29,6 +29,11 @@ struct HeroSection: View {
|
|||||||
outgoingArtworkURLs: session.backend.flatMap { backend in
|
outgoingArtworkURLs: session.backend.flatMap { backend in
|
||||||
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
|
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
|
||||||
} ?? [],
|
} ?? [],
|
||||||
|
outgoingTitle: outgoing?.rowTitle,
|
||||||
|
outgoingMetadata: outgoing.flatMap(Self.metadata(for:)),
|
||||||
|
outgoingLogoURL: session.backend.flatMap { backend in
|
||||||
|
outgoing.flatMap { HeroArtwork.logoURL(for: $0, backend: backend) }
|
||||||
|
},
|
||||||
crossfade: crossfade,
|
crossfade: crossfade,
|
||||||
incomingOffset: incomingOffset,
|
incomingOffset: incomingOffset,
|
||||||
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
||||||
@@ -74,7 +79,13 @@ struct HeroSection: View {
|
|||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadata: String? {
|
private var metadata: String? { Self.metadata(for: item) }
|
||||||
|
|
||||||
|
/// Die Zeile unter dem Titel.
|
||||||
|
///
|
||||||
|
/// `static`, weil sie während eines Wechsels auch für den weichenden
|
||||||
|
/// Titel gebraucht wird — und der ist nicht `item`.
|
||||||
|
static func metadata(for item: MediaItem) -> String? {
|
||||||
var pieces: [String] = []
|
var pieces: [String] = []
|
||||||
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
||||||
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
private let crossfade: CGFloat
|
private let crossfade: CGFloat
|
||||||
/// Wie weit das neue Bild dabei hereinrutscht.
|
/// Wie weit das neue Bild dabei hereinrutscht.
|
||||||
private let incomingOffset: CGFloat
|
private let incomingOffset: CGFloat
|
||||||
|
/// Titel, Zeile und Schriftzug des weichenden Eintrags.
|
||||||
|
///
|
||||||
|
/// **Damit der Schriftzug mitwandert.** Ohne ihn stünde während des
|
||||||
|
/// Wechsels gar kein Text da: der neue blendet erst ein, der alte wäre
|
||||||
|
/// schon weg. Er liegt **über** dem Verlauf, nicht darin — deshalb kann
|
||||||
|
/// er die Abdunklung nicht schwächen, anders als ein zweiter ganzer
|
||||||
|
/// Aufmacher.
|
||||||
|
private let outgoing: (title: String, metadata: String?, logoURL: URL?)?
|
||||||
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
|
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
|
||||||
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
|
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
|
||||||
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
||||||
@@ -54,6 +62,9 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
summary: String? = nil,
|
summary: String? = nil,
|
||||||
artworkURLs: [URL],
|
artworkURLs: [URL],
|
||||||
outgoingArtworkURLs: [URL] = [],
|
outgoingArtworkURLs: [URL] = [],
|
||||||
|
outgoingTitle: String? = nil,
|
||||||
|
outgoingMetadata: String? = nil,
|
||||||
|
outgoingLogoURL: URL? = nil,
|
||||||
crossfade: CGFloat = 1,
|
crossfade: CGFloat = 1,
|
||||||
incomingOffset: CGFloat = 0,
|
incomingOffset: CGFloat = 0,
|
||||||
logoURL: URL? = nil,
|
logoURL: URL? = nil,
|
||||||
@@ -71,6 +82,7 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
self.summary = summary
|
self.summary = summary
|
||||||
self.artworkURLs = artworkURLs
|
self.artworkURLs = artworkURLs
|
||||||
self.outgoingArtworkURLs = outgoingArtworkURLs
|
self.outgoingArtworkURLs = outgoingArtworkURLs
|
||||||
|
self.outgoing = outgoingTitle.map { ($0, outgoingMetadata, outgoingLogoURL) }
|
||||||
self.crossfade = crossfade
|
self.crossfade = crossfade
|
||||||
self.incomingOffset = incomingOffset
|
self.incomingOffset = incomingOffset
|
||||||
self.logoURL = logoURL
|
self.logoURL = logoURL
|
||||||
@@ -125,31 +137,6 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
|
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
|
||||||
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
|
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
|
||||||
/// Kasten.
|
/// Kasten.
|
||||||
@ViewBuilder
|
|
||||||
private func titleView(scale: CGFloat) -> some View {
|
|
||||||
Group {
|
|
||||||
if let logo = availableLogo {
|
|
||||||
Image(platform: logo)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
// Nach oben begrenzt: manche Schriftzüge sind fast
|
|
||||||
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
|
|
||||||
// hohes Logo die Knöpfe aus dem Bild.
|
|
||||||
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
|
|
||||||
.accessibilityLabel(Text(title))
|
|
||||||
} else {
|
|
||||||
// Solange nichts da ist — und wenn es scheitert — bleibt es
|
|
||||||
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
|
|
||||||
// eine Systemschrift.
|
|
||||||
plainTitle(scale: scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task(id: logoURL) {
|
|
||||||
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
|
||||||
logo = await ArtworkLoader.shared.image(for: logoURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
||||||
///
|
///
|
||||||
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
||||||
@@ -161,13 +148,6 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
return ArtworkLoader.cachedImage(for: logoURL)
|
return ArtworkLoader.cachedImage(for: logoURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func plainTitle(scale: CGFloat) -> some View {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 34 * scale, weight: .bold))
|
|
||||||
.foregroundStyle(Color.flixyTextPrimary)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
bild
|
bild
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
@@ -208,8 +188,76 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let scale = scale(for: geometry.size.width)
|
let scale = scale(for: geometry.size.width)
|
||||||
|
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
// **Der weichende Text bleibt stehen, bis der neue
|
||||||
|
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
|
||||||
|
// keiner da: der neue blendet ein, der alte wäre
|
||||||
|
// schon weg. Er liegt über dem Verlauf — dort kostet
|
||||||
|
// er die Abdunklung nichts.
|
||||||
|
if let outgoing, crossfade < 1 {
|
||||||
|
inhalt(
|
||||||
|
title: outgoing.title,
|
||||||
|
metadata: outgoing.metadata,
|
||||||
|
logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
|
||||||
|
scale: scale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der neue — mit demselben Einblenden und demselben
|
||||||
|
// Hereinrutschen wie das Bild darunter, damit beides
|
||||||
|
// als eine Bewegung erscheint.
|
||||||
|
inhalt(
|
||||||
|
title: title,
|
||||||
|
metadata: metadata,
|
||||||
|
logo: availableLogo,
|
||||||
|
scale: scale
|
||||||
|
)
|
||||||
|
.opacity(crossfade)
|
||||||
|
.offset(x: incomingOffset)
|
||||||
|
}
|
||||||
|
.frame(
|
||||||
|
width: geometry.size.width,
|
||||||
|
height: geometry.size.height,
|
||||||
|
alignment: .bottomLeading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: logoURL) {
|
||||||
|
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
||||||
|
logo = await ArtworkLoader.shared.image(for: logoURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schriftzug beziehungsweise Titel, Zeile, Beschreibung und Knöpfe.
|
||||||
|
///
|
||||||
|
/// Als eigene Funktion, weil es sie während eines Wechsels zweimal gibt —
|
||||||
|
/// einmal für den weichenden und einmal für den kommenden Eintrag.
|
||||||
|
@ViewBuilder
|
||||||
|
private func inhalt(
|
||||||
|
title: String,
|
||||||
|
metadata: String?,
|
||||||
|
logo: PlatformImage?,
|
||||||
|
scale: CGFloat
|
||||||
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
|
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
|
||||||
titleView(scale: scale)
|
if let logo {
|
||||||
|
Image(platform: logo)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
// Nach oben begrenzt: manche Schriftzüge sind fast
|
||||||
|
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
|
||||||
|
// hohes Logo die Knöpfe aus dem Bild.
|
||||||
|
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
|
||||||
|
.accessibilityLabel(Text(title))
|
||||||
|
} else {
|
||||||
|
// Solange nichts da ist — und wenn es scheitert — bleibt es
|
||||||
|
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
|
||||||
|
// eine Systemschrift.
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 34 * scale, weight: .bold))
|
||||||
|
.foregroundStyle(Color.flixyTextPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
if let metadataContent {
|
if let metadataContent {
|
||||||
metadataContent
|
metadataContent
|
||||||
@@ -226,10 +274,9 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
.font(.system(size: 12 * scale))
|
.font(.system(size: 12 * scale))
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
// Ein Satz über die ganze Breite eines großen
|
// Ein Satz über die ganze Breite eines großen Fensters ist
|
||||||
// Fensters ist unlesbar; das Auge verliert die
|
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
|
||||||
// Zeile. Achtzig Zeichen sind die Grenze, ab
|
// sind die Grenze, ab der Satzspiegel unangenehm werden.
|
||||||
// der Satzspiegel unangenehm werden.
|
|
||||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,12 +287,5 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.screenMargin * scale)
|
.padding(.horizontal, FlixySpacing.screenMargin * scale)
|
||||||
.padding(.bottom, FlixySpacing.loose * scale)
|
.padding(.bottom, FlixySpacing.loose * scale)
|
||||||
.frame(
|
|
||||||
width: geometry.size.width,
|
|
||||||
height: geometry.size.height,
|
|
||||||
alignment: .bottomLeading
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user