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:
Scarriffle
2026-10-01 12:18:58 +02:00
co-authored by Claude Opus 5
parent 63c664e5a9
commit 37bb153dd8
2 changed files with 113 additions and 62 deletions
+12 -1
View File
@@ -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,38 +188,33 @@ 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)
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) { ZStack(alignment: .bottomLeading) {
titleView(scale: scale) // **Der weichende Text bleibt stehen, bis der neue
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
if let metadataContent { // keiner da: der neue blendet ein, der alte wäre
metadataContent // schon weg. Er liegt über dem Verlauf — dort kostet
.font(.system(size: 12 * scale)) // er die Abdunklung nichts.
.foregroundStyle(Color.flixyTextSecondary) if let outgoing, crossfade < 1 {
} else if let metadata { inhalt(
Text(metadata) title: outgoing.title,
.font(.system(size: 12 * scale)) metadata: outgoing.metadata,
.foregroundStyle(Color.flixyTextSecondary) logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
scale: scale
)
} }
if let summary { // Der neue — mit demselben Einblenden und demselben
Text(summary) // Hereinrutschen wie das Bild darunter, damit beides
.font(.system(size: 12 * scale)) // als eine Bewegung erscheint.
.foregroundStyle(Color.flixyTextSecondary) inhalt(
.lineLimit(2) title: title,
// Ein Satz über die ganze Breite eines großen metadata: metadata,
// Fensters ist unlesbar; das Auge verliert die logo: availableLogo,
// Zeile. Achtzig Zeichen sind die Grenze, ab scale: scale
// der Satzspiegel unangenehm werden. )
.frame(maxWidth: 640 * scale, alignment: .leading) .opacity(crossfade)
} .offset(x: incomingOffset)
HStack(spacing: FlixySpacing.regular) {
actions
}
.padding(.top, FlixySpacing.tight)
} }
.padding(.horizontal, FlixySpacing.screenMargin * scale)
.padding(.bottom, FlixySpacing.loose * scale)
.frame( .frame(
width: geometry.size.width, width: geometry.size.width,
height: geometry.size.height, height: geometry.size.height,
@@ -247,5 +222,70 @@ public struct HeroBanner<Actions: View>: View {
) )
} }
} }
.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) {
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 {
metadataContent
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
} else if let metadata {
Text(metadata)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
}
if let summary {
Text(summary)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(2)
// Ein Satz über die ganze Breite eines großen Fensters ist
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
// sind die Grenze, ab der Satzspiegel unangenehm werden.
.frame(maxWidth: 640 * scale, alignment: .leading)
}
HStack(spacing: FlixySpacing.regular) {
actions
}
.padding(.top, FlixySpacing.tight)
}
.padding(.horizontal, FlixySpacing.screenMargin * scale)
.padding(.bottom, FlixySpacing.loose * scale)
} }
} }