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 wandert der Aufmacher alle /// paar Sekunden weiter und lässt sich auch von Hand durchblättern; die Punkte /// darunter zeigen, wie viele es sind und wo man gerade ist. /// /// Das Weiterwandern hört auf, sobald jemand die Fläche berührt. Wer hier die /// Hand hat, fährt selbst — sonst rutscht der Aufmacher genau in der Sekunde /// weiter, in der man nach „Abspielen" greift, und man startet den falschen /// Film. Das ist mir beim Ausprobieren selbst passiert. /// /// Geblättert wird mit einem einrastenden Streifen, nicht mit `TabView`: den /// Seitenstil gibt es auf dem Mac nicht, und ein TabView im TabView hatte /// nebenbei die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu /// zeigen. 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) @State private var shownID: String? /// Ob noch von selbst weitergewandert wird. @State private var isAdvancing = 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(\.flixyAccent) private var accent private var currentIndex: Int { items.firstIndex { $0.id == shownID } ?? 0 } var body: some View { VStack(spacing: FlixySpacing.regular) { ScrollView(.horizontal) { LazyHStack(spacing: 0) { ForEach(items) { item in HeroSection(item: item) .containerRelativeFrame(.horizontal) .id(item.id) } } .scrollTargetLayout() } .scrollTargetBehavior(.paging) .scrollPosition(id: $shownID) .scrollIndicators(.hidden) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) // Jede Berührung hält das Weiterwandern an — auch die, die nur ein // Antippen wird. Gleichzeitig und ohne Mindestweg, damit weder dem // Blättern noch den Knöpfen etwas weggenommen wird. .simultaneousGesture( DragGesture(minimumDistance: 0).onChanged { _ in isAdvancing = false } ) 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) } private func advance() async { guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return } try? await Task.sleep(for: Self.dwell) guard !Task.isCancelled, isAdvancing else { return } let next = (currentIndex + 1) % items.count withAnimation(.easeInOut(duration: 0.5)) { shownID = items[next].id } } }