Files
Flaro/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift
T
ScarriffleandClaude Opus 5 63c664e5a9 Der Aufmacher hellte beim Wechsel auf — zwei Verläufe ergeben keinen
Beim Wechsel im Karussell war der Verlauf nach unten für einen Moment
weg: das Bild stand bis unten hell da, und erst nach dem Übergang kam das
Abdunkeln zurück.

**Der Grund steckt in der Form der Überblendung.** Das Karussell legte
zwei vollständige Aufmacher übereinander und blendete den oberen ein. Nur
bringt jeder von beiden seinen eigenen Verlauf mit — und zwei
halbdurchsichtige Verläufe ergeben zusammen keinen ganzen.

Gemessen, an derselben Stelle im Bild, über einen auf sechs Sekunden
gestreckten Wechsel:

    Ruhezustand        68 .. 75
    Mitte des Wechsels      137        ← fast doppelt so hell

Zwei naheliegende Reparaturen habe ich davor versucht und verworfen,
weil die Messung sie widerlegt hat: dem weichenden Aufmacher die
Animation zu nehmen (wurde schlechter, 152) und beide Ebenen
gegenläufig zu blenden (brachte einen dunklen Einbruch auf 35 dazu).
Beide behandelten das Symptom, nicht die Form.

**Jetzt wechselt nur das Bild.** `HeroBanner` nimmt zusätzlich die
weichende Bildadresse entgegen und blendet **innen** über; Verlauf,
Schriftzug und Knöpfe liegen einmal darüber und ändern sich dabei nicht.
Damit kann der Verlauf gar nicht mehr schwächer werden — nicht weil es
jetzt richtig gerechnet wird, sondern weil es nur noch einen gibt.

Nachgemessen, derselbe Wechsel:

    vorher   68 .. 154
    jetzt    63 ..  75

Die Aufmacher-Ansicht behält dabei ihre Identität, statt je Titel eine
neue zu bekommen: eine neue hiesse Einfügen und Entfernen, und SwiftUI
blendete sie dann zusätzlich ein — noch eine Überblendung über der
gemeinten.

iOS und tvOS bauen, 476 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:11:22 +02:00

252 lines
11 KiB
Swift

import SwiftUI
/// Das Aufmacher-Bild oben auf der Startseite.
///
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
public struct HeroBanner<Actions: View>: View {
private let title: String
private let metadata: String?
/// Eine Zeile, in der einzelne Teile anklickbar sind.
///
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
/// durch jeden Aufruf.
private let metadataContent: AnyView?
private let summary: String?
private let artworkURLs: [URL]
/// Das Bild, das gerade verschwindet — nur für den Aufmacher mit Wechsel.
///
/// **Warum die Überblendung hier drin steckt und nicht darüber.** Das
/// Karussell legte vorher zwei ganze Aufmacher übereinander und blendete
/// den oberen ein. Jeder bringt seinen eigenen Verlauf nach unten mit —
/// und zwei halbdurchsichtige Verläufe ergeben zusammen **keinen** ganzen.
/// Gemessen an derselben Stelle: im Ruhezustand 68, in der Mitte des
/// Wechsels 137. Sichtbar als ein Aufhellen genau dort, wo der Titel
/// steht.
///
/// Jetzt wechselt nur das **Bild**; Verlauf und Text liegen einmal
/// darüber und ändern sich nicht. Damit kann der Verlauf gar nicht mehr
/// schwächer werden.
private let outgoingArtworkURLs: [URL]
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
private let crossfade: CGFloat
/// Wie weit das neue Bild dabei hereinrutscht.
private let incomingOffset: CGFloat
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
private let logoURL: URL?
private let aspectRatio: CGFloat
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
private let fillsAvailableSpace: Bool
private let actions: Actions
@State private var logo: PlatformImage?
public init(
title: String,
metadata: String? = nil,
metadataContent: AnyView? = nil,
summary: String? = nil,
artworkURLs: [URL],
outgoingArtworkURLs: [URL] = [],
crossfade: CGFloat = 1,
incomingOffset: CGFloat = 0,
logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
// sehen.
aspectRatio: CGFloat = FlixyAspectRatio.hero,
fillsAvailableSpace: Bool = false,
@ViewBuilder actions: () -> Actions
) {
self.title = title
self.metadata = metadata
self.metadataContent = metadataContent
self.summary = summary
self.artworkURLs = artworkURLs
self.outgoingArtworkURLs = outgoingArtworkURLs
self.crossfade = crossfade
self.incomingOffset = incomingOffset
self.logoURL = logoURL
self.aspectRatio = aspectRatio
self.fillsAvailableSpace = fillsAvailableSpace
self.actions = actions()
}
/// Wie stark Schrift und Schriftzug mitwachsen.
///
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
/// Plakatschrift endet.
private func scale(for width: CGFloat) -> CGFloat {
min(max(width / 834, 1), 1.7)
}
/// Das Bild — und, solange gewechselt wird, das vorige darunter.
///
/// Ohne Wechsel ist es genau ein `FlixyArtwork` wie zuvor: die zweite
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
@ViewBuilder
private var bild: some View {
if outgoingArtworkURLs.isEmpty {
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
} else {
ZStack {
FlixyArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
.opacity(crossfade)
.offset(x: incomingOffset)
}
}
}
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
///
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
/// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage`
/// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es
/// erscheint. Der Schriftzug blendete sich dann getrennt und später ein,
/// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren —
/// zwei Bewegungen, wo eine sein soll.
///
/// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das
/// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört
/// zum Aufmacher und macht mit, was der Aufmacher macht.
///
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
/// 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.
///
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
/// steht bis dahin der gesetzte Titel.
private var availableLogo: PlatformImage? {
if let logo { return logo }
guard let logoURL else { return nil }
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 {
bild
.overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
// erste Reihe übergeht, eine sichtbare Kante.
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: Color.flixyCanvas.opacity(0.75), location: 0.45),
.init(color: Color.flixyCanvas, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
.overlay(alignment: .top) {
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
// reicht. Über einem dunklen Aufmacher geht das gut; über
// einem hellen verschwinden sie.
//
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
// weit, wie die Insel baut, und läuft schnell aus. Ein
// großzügigerer deckte das Bild zu, und genau das Bild ist der
// Grund, warum es randlos sein soll.
LinearGradient(
colors: [.black.opacity(0.45), .clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 150)
.allowsHitTesting(false)
}
.overlay(alignment: .bottomLeading) {
GeometryReader { geometry in
let scale = scale(for: geometry.size.width)
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
titleView(scale: scale)
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)
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .bottomLeading
)
}
}
}
}