Ein negativer Rand dehnt, was er umschließt, meldet nach außen aber die alte Breite. Er stand ganz oben, also legten sich Verlauf und Schrift nur über den alten Bereich — und die zwölf Punkte daneben blieben ohne Verlauf und damit hell. Gemessen: dort 60 Helligkeit, im Inhaltsbereich 19, bei gleichem Bild. Jetzt wird erst der Verlauf gelegt, dann gedehnt, dann die Schrift gesetzt. Der Verlauf deckt bis zum Rand, die Schrift bleibt auf der gemeldeten Breite und fängt von selbst dort an, wo auch die Reihen darunter anfangen. Der Weg dorthin ging über drei Irrtümer, die alle widerlegt sind: nicht `glassEffect` (der Streifen blieb bei undurchsichtiger Fläche), nicht der Platzhalter (er blieb bei grünem Platzhalter), nicht der Scroll-Anzeiger (er blieb ohne Bildlaufansicht). Die Bildlaufansicht in der Leiste ist trotzdem draußen — sie wurde nicht gebraucht. Dazu: der Aufmacher nimmt die volle Höhe statt achtundachtzig Prozent, der Haarstrich der Leiste ist zurückhaltender, und auf dem iPhone ist der Aufmacher das **Plakat** statt des Hintergrundbilds. Ein 16:9-Bild hochkant zeigt einen Ausschnitt aus der Mitte; ein Plakat ist von vornherein hochkant und passt ohne Beschnitt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
9.4 KiB
Swift
214 lines
9.4 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
|
///
|
|
/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wechselt der Aufmacher alle
|
|
/// paar Sekunden; die Punkte darunter zeigen, wie viele es sind und wo man
|
|
/// gerade ist.
|
|
///
|
|
/// **Übergeblendet, nicht durchgeblättert.** Vorher lag hier ein einrastender
|
|
/// Streifen, der die Titel wie Buchseiten seitwärts durchschob. Das hatte eine
|
|
/// Nebenwirkung, die nichts mit dem Blättern zu tun schien: der Aufmacher füllt
|
|
/// die Fläche unter der Seitenleiste, indem er seinen eigenen Rand gespiegelt
|
|
/// weiterzeichnet. Steht das Bild still, fällt das niemandem auf. Schiebt es
|
|
/// sich dagegen seitwärts, wandern das echte und das gespiegelte Stück
|
|
/// gegeneinander, und die Naht dazwischen wird zum auffälligsten Ding auf dem
|
|
/// Bildschirm.
|
|
///
|
|
/// Jetzt rutscht das neue Bild nur ein kleines Stück herein und legt sich
|
|
/// dabei über das alte. Nichts wandert weit genug, als dass ein Versatz
|
|
/// auffiele.
|
|
///
|
|
/// **Es wird nicht gekreuzt, sondern überdeckt.** Beim Kreuzblenden sind
|
|
/// beide Bilder gleichzeitig halbdurchsichtig — und dazwischen scheint der
|
|
/// Untergrund durch, was als helles Aufblitzen des vorigen Titels zu sehen
|
|
/// war. Hier bleibt das alte Bild voll deckend liegen, bis das neue darüber
|
|
/// undurchsichtig geworden ist; erst dann verschwindet es. Zu jedem Zeitpunkt
|
|
/// ist die Fläche gedeckt.
|
|
struct HeroCarousel: View {
|
|
let items: [MediaItem]
|
|
|
|
/// Wie lange ein Titel stehen bleibt.
|
|
///
|
|
/// Lang genug, um Titel, Beschreibung und Knöpfe zu lesen — bei drei
|
|
/// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat.
|
|
private static let dwell: Duration = .seconds(7)
|
|
|
|
/// Wie weit das Bild dabei rutscht.
|
|
///
|
|
/// Klein gehalten: es soll eine Richtung andeuten, nicht eine Strecke
|
|
/// zurücklegen. Alles darüber sieht wieder nach Blättern aus.
|
|
private static let slide: CGFloat = 32
|
|
|
|
@State private var shownID: String?
|
|
/// Der Titel, der noch darunter liegt und gleich verschwindet.
|
|
///
|
|
/// Er wird voll deckend gezeigt, nicht ausgeblendet — das Ausblenden
|
|
/// übernimmt das neue Bild, indem es ihn zudeckt.
|
|
@State private var fadingID: String?
|
|
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
|
@State private var cover: CGFloat = 1
|
|
/// Ob noch von selbst weitergewechselt wird.
|
|
///
|
|
/// Hört auf, sobald jemand die Fläche berührt. Wer hier die Hand hat,
|
|
/// fährt selbst — sonst wechselt der Aufmacher genau in der Sekunde, in
|
|
/// der man nach „Abspielen" greift, und man startet den falschen Film.
|
|
@State private var isAdvancing = true
|
|
/// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll
|
|
/// das Bild auch von der anderen Seite hereinkommen sehen.
|
|
@State private var isMovingForward = true
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.flixyAccent) private var accent
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
private var currentIndex: Int {
|
|
items.firstIndex { $0.id == shownID } ?? 0
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: FlixySpacing.regular) {
|
|
ZStack {
|
|
// Unten: der vorige Titel, unverändert deckend.
|
|
if let fadingID, let previous = items.first(where: { $0.id == fadingID }) {
|
|
HeroSection(item: previous)
|
|
}
|
|
|
|
// Oben: der neue, der ihn zudeckt und dabei hereinrutscht.
|
|
if items.indices.contains(currentIndex) {
|
|
HeroSection(item: items[currentIndex])
|
|
.id(items[currentIndex].id)
|
|
.opacity(cover)
|
|
.offset(x: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide))
|
|
}
|
|
}
|
|
// Die Höhe: fast das ganze Fenster.
|
|
//
|
|
// Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein
|
|
// breites Vorschaubild. So hoch wie die Seitenleiste soll er sein,
|
|
// und die ist so hoch wie das Fenster.
|
|
//
|
|
// Auf einem schmalen Gerät nicht: dort ist der Aufmacher hochkant
|
|
// und rechnet sich seine Höhe aus der Breite aus.
|
|
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 1))
|
|
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
|
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
|
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
|
// greift, blieb dann rechts genau so viel schwarz übrig, wie er
|
|
// links gewonnen hatte — auf dem Mac ein Streifen von gut zweihundert
|
|
// Punkten.
|
|
// **Nicht** beschneiden: der Aufmacher greift bewusst nach links
|
|
// über seinen Bereich hinaus, damit sein Bild unter der
|
|
// Seitenleiste durchläuft. Ein `.clipped()` schnitt genau das ab —
|
|
// und übrig blieb ein schwarzer Streifen neben der Leiste.
|
|
//
|
|
// Für den Platz ist es auch nicht nötig: das Hereinrutschen läuft
|
|
// über `offset`, und das verschiebt nur, was gezeichnet wird.
|
|
// Wischen blättert weiter, jede Berührung hält das Wechseln an —
|
|
// auch die, die nur ein Antippen wird. Gleichzeitig, damit weder
|
|
// dem Wischen noch den Knöpfen darunter etwas weggenommen wird.
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in isAdvancing = false }
|
|
.onEnded { value in
|
|
guard abs(value.translation.width) > 50 else { return }
|
|
step(by: value.translation.width < 0 ? 1 : -1)
|
|
}
|
|
)
|
|
|
|
if items.count > 1 { indicator }
|
|
}
|
|
.task(id: shownID) { await advance() }
|
|
.onChange(of: items.map(\.id), initial: true) { _, ids in
|
|
// Beim ersten Zeichnen und nach jedem Aktualisieren: steht die
|
|
// gezeigte Kennung nicht mehr in der Liste, beginnt es von vorn.
|
|
if shownID == nil || !ids.contains(shownID ?? "") { shownID = ids.first }
|
|
}
|
|
}
|
|
|
|
private var indicator: some View {
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
ForEach(items.indices, id: \.self) { position in
|
|
Capsule()
|
|
.fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
|
|
.frame(width: position == currentIndex ? 18 : 6, height: 6)
|
|
}
|
|
}
|
|
.animation(.snappy(duration: 0.25), value: currentIndex)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
/// Einen Titel weiter oder zurück.
|
|
private func step(by direction: Int) {
|
|
guard items.count > 1 else { return }
|
|
let next = (currentIndex + direction + items.count) % items.count
|
|
|
|
isMovingForward = direction > 0
|
|
fadingID = shownID
|
|
shownID = items[next].id
|
|
cover = 0
|
|
|
|
withAnimation(.easeInOut(duration: 0.55)) {
|
|
cover = 1
|
|
} completion: {
|
|
// Erst wenn oben nichts mehr durchscheint, darf unten etwas
|
|
// verschwinden. Andersherum gäbe es genau den Moment, in dem man
|
|
// durch beide hindurch auf den Untergrund sieht.
|
|
fadingID = nil
|
|
}
|
|
}
|
|
|
|
private func advance() async {
|
|
guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return }
|
|
|
|
// Das nächste Bild schon holen, solange das aktuelle noch steht. Ohne
|
|
// das blendet der Aufmacher auf eine graue Fläche über und das Bild
|
|
// erscheint erst danach — beim Blättern fiel das nicht auf, beim
|
|
// Überblenden sofort.
|
|
prefetchNeighbours()
|
|
|
|
try? await Task.sleep(for: Self.dwell)
|
|
guard !Task.isCancelled, isAdvancing else { return }
|
|
step(by: 1)
|
|
}
|
|
|
|
private func prefetchNeighbours() {
|
|
guard let backend = session.backend, !items.isEmpty else { return }
|
|
let item = items[(currentIndex + 1) % items.count]
|
|
let compact = sizeClass == .compact
|
|
|
|
// Hintergrundbild und Schriftzug zusammen: fehlt der Schriftzug beim
|
|
// Überblenden, steht dort erst der gesetzte Titel und das Logo springt
|
|
// eine Sekunde später hinterher — zwei Bewegungen, wo eine sein soll.
|
|
let urls = [
|
|
HeroArtwork.backdropURL(for: item, backend: backend, compact: compact),
|
|
HeroArtwork.logoURL(for: item, backend: backend),
|
|
]
|
|
|
|
for url in urls.compactMap(\.self) {
|
|
Task { _ = await ArtworkLoader.shared.image(for: url) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Gibt dem Aufmacher einen Anteil an der sichtbaren Höhe — oder gar nichts.
|
|
///
|
|
/// Eigener Baustein, weil `containerRelativeFrame` sich nicht abschalten lässt:
|
|
/// es wirkt, sobald es dasteht. Ein `if` in der Ansicht brächte den beiden
|
|
/// Zweigen außerdem verschiedene Identitäten ein, und der Aufmacher würde beim
|
|
/// Drehen neu geladen statt mitzuwandern.
|
|
private struct HeroHeight: ViewModifier {
|
|
let fraction: CGFloat?
|
|
|
|
func body(content: Content) -> some View {
|
|
if let fraction {
|
|
content.containerRelativeFrame(.vertical) { height, _ in height * fraction }
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|