Der Aufmacher überdeckt, statt zu kreuzen

Beim Kreuzblenden sind für einen Moment beide Bilder halbdurchsichtig — und
dazwischen scheint der Untergrund durch. Das war das helle Aufblitzen des
vorigen Titels.

Jetzt bleibt das alte Bild voll deckend liegen, und das neue legt sich darüber,
während es hereinrutscht. Erst wenn oben nichts mehr durchscheint, verschwindet
unten etwas — die Fläche ist zu keinem Zeitpunkt durchsichtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 18:28:09 +02:00
co-authored by Claude Opus 5
parent e7de8a9055
commit b4c2bd3970
+37 -13
View File
@@ -17,8 +17,16 @@ import MediaKit
/// gegeneinander, und die Naht dazwischen wird zum auffälligsten Ding auf dem /// gegeneinander, und die Naht dazwischen wird zum auffälligsten Ding auf dem
/// Bildschirm. /// Bildschirm.
/// ///
/// Jetzt rutscht das neue Bild nur ein kleines Stück herein und blendet dabei /// Jetzt rutscht das neue Bild nur ein kleines Stück herein und legt sich
/// über das alte. Nichts wandert weit genug, als dass ein Versatz auffiele. /// 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 { struct HeroCarousel: View {
let items: [MediaItem] let items: [MediaItem]
@@ -35,6 +43,13 @@ struct HeroCarousel: View {
private static let slide: CGFloat = 32 private static let slide: CGFloat = 32
@State private var shownID: String? @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. /// Ob noch von selbst weitergewechselt wird.
/// ///
/// Hört auf, sobald jemand die Fläche berührt. Wer hier die Hand hat, /// Hört auf, sobald jemand die Fläche berührt. Wer hier die Hand hat,
@@ -45,8 +60,6 @@ struct HeroCarousel: View {
/// das Bild auch von der anderen Seite hereinkommen sehen. /// das Bild auch von der anderen Seite hereinkommen sehen.
@State private var isMovingForward = true @State private var isMovingForward = true
/// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm
/// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand.
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@Environment(FlixySession.self) private var session @Environment(FlixySession.self) private var session
@@ -58,24 +71,23 @@ struct HeroCarousel: View {
var body: some View { var body: some View {
VStack(spacing: FlixySpacing.regular) { VStack(spacing: FlixySpacing.regular) {
ZStack { 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) { if items.indices.contains(currentIndex) {
HeroSection(item: items[currentIndex]) HeroSection(item: items[currentIndex])
.id(items[currentIndex].id) .id(items[currentIndex].id)
.transition(.asymmetric( .opacity(cover)
insertion: .opacity.combined( .offset(x: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide))
with: .offset(x: isMovingForward ? Self.slide : -Self.slide)
),
removal: .opacity.combined(
with: .offset(x: isMovingForward ? -Self.slide : Self.slide)
)
))
} }
} }
.aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit)
// Das Bild wechselt, der Platz nicht: ohne das Beschneiden schöbe // Das Bild wechselt, der Platz nicht: ohne das Beschneiden schöbe
// das hereinrutschende Bild die Seite um seine Strecke breiter. // das hereinrutschende Bild die Seite um seine Strecke breiter.
.clipped() .clipped()
.animation(.easeInOut(duration: 0.55), value: shownID)
// Wischen blättert weiter, jede Berührung hält das Wechseln an — // Wischen blättert weiter, jede Berührung hält das Wechseln an —
// auch die, die nur ein Antippen wird. Gleichzeitig, damit weder // auch die, die nur ein Antippen wird. Gleichzeitig, damit weder
// dem Wischen noch den Knöpfen darunter etwas weggenommen wird. // dem Wischen noch den Knöpfen darunter etwas weggenommen wird.
@@ -114,8 +126,20 @@ struct HeroCarousel: View {
private func step(by direction: Int) { private func step(by direction: Int) {
guard items.count > 1 else { return } guard items.count > 1 else { return }
let next = (currentIndex + direction + items.count) % items.count let next = (currentIndex + direction + items.count) % items.count
isMovingForward = direction > 0 isMovingForward = direction > 0
fadingID = shownID
shownID = items[next].id 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 { private func advance() async {