Der Aufmacher bleibt stehen, und Folgen bekommen ein Bild

Drei Sachen am Karussell.

Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein
Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position
in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder
aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und
„Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der
Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests,
weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann.

Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie
hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es
das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge
wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine
Adresse antwortet.

Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger
ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und
auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man
ohnehin, und zwei Knoepfe staenden nur im Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 20:46:34 +02:00
co-authored by Claude Opus 5
parent 4b084bc926
commit 2a2afa8573
11 changed files with 427 additions and 104 deletions
+94 -2
View File
@@ -59,6 +59,10 @@ struct HeroCarousel: View {
/// 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
/// Ob der Zeiger über dem Aufmacher liegt — dann erscheinen die Pfeile.
@State private var isPointerInside = false
/// Ob er über den Punkten liegt — dann werden sie gross genug zum Treffen.
@State private var isHoveringDots = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.flixyAccent) private var accent
@@ -151,6 +155,12 @@ struct HeroCarousel: View {
// eine Geste, die schon bei null Punkten greift, das Scrollen
// schluckt.
.simultaneousGesture(TapGesture().onEnded { isAdvancing = false })
// **Nur auf dem Mac.** Dort gibt es einen Zeiger, und Wischen ist
// mit dem Trackpad zwar möglich, aber nicht naheliegend — man
// sieht dem Aufmacher nicht an, dass er weitergeht. Auf dem iPad
// und dem iPhone wischt man ohnehin, und zwei Knöpfe ständen nur
// im Bild.
.overlay { if isMac, items.count > 1 { arrows } }
if items.count > 1 { indicator }
}
@@ -166,18 +176,100 @@ struct HeroCarousel: View {
}
}
private var isMac: Bool { FlixyPlatform.isMac }
/// Die beiden Pfeile links und rechts.
///
/// Zurückhaltend: sie sollen im Bild liegen, ohne es zu beherrschen.
/// Deshalb erscheinen sie erst, wenn der Zeiger über dem Aufmacher ist —
/// vorher gibt es nichts zu bedienen, und ein Knopf, der dauerhaft über
/// einem Filmplakat klebt, ist ein Fremdkörper.
private var arrows: some View {
HStack {
arrow(symbol: "chevron.left", direction: -1)
Spacer()
arrow(symbol: "chevron.right", direction: 1)
}
.padding(.horizontal, FlixySpacing.regular)
.opacity(isPointerInside ? 1 : 0)
.animation(.easeOut(duration: 0.2), value: isPointerInside)
.onContinuousHover { phase in
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
}
}
private func arrow(symbol: String, direction: Int) -> some View {
Button {
isAdvancing = false
step(by: direction)
} label: {
Image(systemName: symbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.padding(FlixySpacing.regular)
.background(.black.opacity(0.45), in: .circle)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(direction > 0 ? "hero.next" : "hero.previous"))
}
/// Die Punkte darunter — auf dem Mac auch ein Bedienelement.
///
/// **Warum sie wachsen.** Sechs Punkte hoch trifft niemand, und drei
/// Punkte breit schon gar nicht. Unter dem Zeiger werden sie deshalb
/// grösser, sobald er in ihrer Nähe ist — dieselbe Bewegung, mit der das
/// iPad seine Fensterknöpfe unter dem Finger vergrössert. Dass etwas
/// wächst, wenn man sich nähert, heisst: hier kann man etwas tun.
///
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
private var indicator: some View {
HStack(spacing: FlixySpacing.snug) {
HStack(spacing: isEnlarged ? FlixySpacing.regular : 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)
.frame(
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
height: isEnlarged ? 12 : 6
)
.contentShape(.rect)
.onTapGesture {
guard isMac else { return }
isAdvancing = false
jump(to: position)
}
}
}
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
// beim Annähern etwas bewirken, nicht erst beim Treffen.
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
.onContinuousHover { phase in
guard isMac else { return }
if case .active = phase { isHoveringDots = true } else { isHoveringDots = false }
}
.animation(.snappy(duration: 0.25), value: currentIndex)
.animation(.snappy(duration: 0.2), value: isEnlarged)
.accessibilityHidden(true)
}
private var isEnlarged: Bool { isMac && isHoveringDots }
/// Springt unmittelbar zu einem Titel.
private func jump(to position: Int) {
guard position != currentIndex, items.indices.contains(position) else { return }
isMovingForward = position > currentIndex
fadingID = shownID
shownID = items[position].id
cover = 0
withAnimation(.easeInOut(duration: 0.55)) {
cover = 1
} completion: {
fadingID = nil
}
}
/// Einen Titel weiter oder zurück.
private func step(by direction: Int) {
guard items.count > 1 else { return }