diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 3ac1d48..a4a27fd 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -58,6 +58,7 @@ struct PlayableDetailView: View { .padding(.bottom, FlixySpacing.section) } .scrollIndicators(.never) + .flixyRevealsTopChrome() } private var currentMedia: MediaVersion? { diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 65a4ed1..f775c08 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -35,6 +35,7 @@ struct SeriesDetailView: View { .padding(.bottom, FlixySpacing.section) } .scrollIndicators(.never) + .flixyRevealsTopChrome() .task(id: item.id) { await loadChildren() } } diff --git a/Flixy/Views/Downloads/DownloadsView.swift b/Flixy/Views/Downloads/DownloadsView.swift index bbe2489..1640946 100644 --- a/Flixy/Views/Downloads/DownloadsView.swift +++ b/Flixy/Views/Downloads/DownloadsView.swift @@ -81,6 +81,7 @@ struct DownloadsView: View { } .scrollContentBackground(.hidden) .listRowBackground(Color.flixySurface) + .flixyRevealsTopChrome() } private var active: [DownloadItem] { diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 7ca61c7..4f7b869 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -64,6 +64,23 @@ struct HeroCarousel: View { @Environment(\.flixyAccent) private var accent @Environment(FlixySession.self) private var session @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(TopChrome.self) private var chrome + + /// Wie weit der Aufmacher nach oben unter die Insel greift. + /// + /// Der Inhalt beginnt unter der Insel — das ist für Reihen und Listen + /// richtig, für den Aufmacher aber nicht: er ist das Bild, auf dem die + /// Insel schweben soll, und ein Bild, das an ihrer Unterkante anfängt, + /// lässt darüber einen schwarzen Streifen stehen, den nie etwas füllt. + /// + /// Also nimmt er sich den Platz zurück: um genau diese Strecke höher und + /// um genau diese Strecke größer. Unten ändert sich dadurch nichts, die + /// Reihen bleiben, wo sie waren. + /// + /// Auf dem iPhone null — dort schwebt nichts über dem Inhalt. + private var overlap: CGFloat { + sizeClass == .compact ? 0 : chrome.inset + } private var currentIndex: Int { items.firstIndex { $0.id == shownID } ?? 0 @@ -95,7 +112,7 @@ struct HeroCarousel: View { // // 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 : 0.82)) + .modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 0.82, extra: overlap)) // **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 @@ -136,6 +153,10 @@ struct HeroCarousel: View { if items.count > 1 { indicator } } + // Zieht den ganzen Aufmacher unter die Insel. Zusammen mit der + // zusätzlichen Höhe oben hebt sich das unten auf: er beginnt am + // Bildschirmrand und endet, wo er vorher endete. + .padding(.top, -overlap) .task(id: shownID) { await advance() } .onChange(of: items.map(\.id), initial: true) { _, ids in // Beim ersten Zeichnen und nach jedem Aktualisieren: steht die @@ -217,10 +238,13 @@ struct HeroCarousel: View { /// Drehen neu geladen statt mitzuwandern. private struct HeroHeight: ViewModifier { let fraction: CGFloat? + /// Was über den Anteil hinaus dazukommt: der Platz unter der Insel, den + /// der Aufmacher mitfüllt, weil er nach oben unter sie greift. + let extra: CGFloat func body(content: Content) -> some View { if let fraction { - content.containerRelativeFrame(.vertical) { height, _ in height * fraction } + content.containerRelativeFrame(.vertical) { height, _ in height * fraction + extra } } else { content } diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index ca5dbf0..6087319 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -73,6 +73,7 @@ struct HomeView: View { .padding(.bottom, FlixySpacing.section) } .scrollIndicators(.never) + .flixyRevealsTopChrome() } /// Bringt die Reihen vom Server in die Form, die eingestellt ist. diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 3c4a162..b14a579 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -99,6 +99,7 @@ struct LibraryView: View { if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) } } + .flixyRevealsTopChrome() } private var list: some View { @@ -117,6 +118,7 @@ struct LibraryView: View { if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) } } + .flixyRevealsTopChrome() } @ToolbarContentBuilder diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 1c461c7..e9d694a 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -196,6 +196,7 @@ struct LiveTVView: View { } } } + .flixyRevealsTopChrome() } private func load() async { diff --git a/Flixy/Views/Navigation/FlixyNavigationBar.swift b/Flixy/Views/Navigation/FlixyNavigationBar.swift index 5299bcd..3577204 100644 --- a/Flixy/Views/Navigation/FlixyNavigationBar.swift +++ b/Flixy/Views/Navigation/FlixyNavigationBar.swift @@ -406,7 +406,12 @@ struct FlixyTopBar: View { } } label: { HStack(spacing: FlixySpacing.tight) { - if let title = group.title { Text(title) } + if let title = group.title { + // Ohne das wurde aus „Mediatheken" ein „Mediathe…". + // Die Leiste ist so breit wie ihr Inhalt; sie hat keinen + // Grund, an einem Wort zu sparen. + Text(title).fixedSize() + } Image(systemName: "chevron.down").font(.flixyCaption) } .font(.flixyBody) diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index e74355b..0e70834 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -60,6 +60,17 @@ struct RootView: View { /// überhaupt dastehen soll. @State private var navigationLayout = NavigationLayoutSettings() + /// Die Insel oben: wie hoch sie baut und ob sie gerade zu sehen ist. + @State private var topChrome = TopChrome() + + /// Wie hoch die Statusleiste baut. + /// + /// Gemessen und nicht angenommen: es sind nicht überall dieselben Punkte, + /// und beim Drehen ändert sich der Wert. Gemessen wird **außerhalb** des + /// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb + /// wäre er längst null. + @State private var statusBarHeight: CGFloat = 0 + var body: some View { Group { switch session.phase { @@ -98,6 +109,13 @@ struct RootView: View { } } .background(Color.flixyCanvas) + .environment(topChrome) + // Hier und nicht weiter innen: ab dem `HStack` wird der obere + // Sicherheitsrand ignoriert, damit der Inhalt bis an den + // Bildschirmrand reicht. Wer danach misst, bekommt null. + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.safeAreaInsets.top + } action: { statusBarHeight = $0 } // Hier statt in der Scene: eine View wertet Beobachtungsänderungen // zuverlässig neu aus, ein Scene-Body nicht. .flixyAccent(theme) @@ -311,13 +329,35 @@ struct RootView: View { // als zöge dieselbe Leiste um: erst zusammenklappen, // dann hinüberschieben. .transition(.move(edge: .top).combined(with: .opacity)) + // Die Statusleiste gehört ihr nicht. Seit der Baum den + // Sicherheitsrand ignoriert, säße ihre Oberkante sonst + // unter der Uhrzeit. + .padding(.top, statusBarHeight) } tabs - // Bündig mit der Oberkante der Leiste. Ohne das fing - // der Aufmacher weiter oben an als sie, und genau an - // dieser Kante sah man es. - .padding(.top, placement == .sidebar ? FlixySidebar.margin : Self.topBarHeight) + // Der Platz oben als **Sicherheitsrand**, nicht als + // Abstand. + // + // Der Unterschied ist der schwarze Balken, über den du + // dich zu Recht gewundert hast: mit einem Abstand hört + // der Inhalt dort auf und die Fläche darüber bleibt für + // immer leer. Als Sicherheitsrand fängt er im + // Ruhezustand ebenso darunter an — aber beim Scrollen + // wandert er **hindurch**, bis an den Bildschirmrand. + // Die Leiste ist eine Insel; hinter einer Insel läuft + // das Wasser durch. + // + // Anders als `safeAreaPadding` erreicht das auch die + // geschobenen Bildschirme: deren Navigationsleisten + // richten sich danach. + // + // Die Statusleiste ist mit eingerechnet, weil der Baum + // sie gleich unten ignoriert. Ohne sie begänne der + // Inhalt hinter der Uhrzeit. + .safeAreaInset(edge: .top, spacing: 0) { + Color.clear.frame(height: topInset) + } .background { TabChromeSuppressor().frame(width: 0, height: 0) } .overlay(alignment: .top) { if placement == .top { @@ -327,9 +367,28 @@ struct RootView: View { placement: $placement.animation(Self.placementAnimation) ) .transition(.move(edge: .leading).combined(with: .opacity)) + .padding(.top, statusBarHeight) + // Beim Scrollen fährt sie nach oben weg. + // + // Nur sie: der Sicherheitsrand darunter bleibt, + // wie er ist. Schrumpfte er mit, rückte der + // Inhalt bei jedem Ein- und Ausblenden nach — + // man scrollt, und die Seite ruckt einem unter + // dem Finger weg. + .offset(y: topChrome.isRevealed ? 0 : -topInset) + .opacity(topChrome.isRevealed ? 1 : 0) + .animation(.snappy(duration: 0.28), value: topChrome.isRevealed) } } } + // Hier reißt der Baum bis an den Bildschirmrand auf. + // + // Das ist der eigentliche Griff. Vorher saß alles — Leiste, + // Bildschirme, Bildlaufansichten — innerhalb des + // Sicherheitsrands, und deshalb konnte kein noch so gut + // gesetzter Rand weiter unten den schwarzen Streifen über der + // Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er. + .ignoresSafeArea(.container, edges: .top) } else { tabs } @@ -339,12 +398,31 @@ struct RootView: View { } .miniPlayer(player, isVisible: playback.presentation == .mini) .onAppear { placement = storedPlacement } - .onChange(of: placement) { _, new in storedPlacement = new } + .onChange(of: placement) { _, new in + storedPlacement = new + // Zieht die Navigation um, soll die neue Leiste nicht + // ausgeblendet ankommen. + topChrome.isRevealed = true + } + // Weitergereicht, damit der Aufmacher weiß, worunter er greifen muss. + .onChange(of: topInset, initial: true) { _, new in topChrome.inset = new } } /// Wie viel Platz die Leiste oben braucht. private static let topBarHeight: CGFloat = 52 + /// Wie tief der Inhalt unter dem oberen Bildschirmrand beginnt. + /// + /// Die Statusleiste plus das, was die Navigation dort belegt — bei der + /// Seitenleiste nur ihr Rand, bei der Leiste oben deren volle Höhe. + /// + /// Auf dem iPhone null: dort sitzt die Leiste unten, und über dem Inhalt + /// schwebt nichts, worunter man greifen müsste. + private var topInset: CGFloat { + guard sizeClass == .regular else { return 0 } + return statusBarHeight + (placement == .sidebar ? FlixySidebar.margin : Self.topBarHeight) + } + private var navigationGroups: [NavigationGroup] { var groups: [NavigationGroup] = [ NavigationGroup(id: "main", title: nil, entries: [ diff --git a/Flixy/Views/Navigation/TopChrome.swift b/Flixy/Views/Navigation/TopChrome.swift new file mode 100644 index 0000000..e1ca5aa --- /dev/null +++ b/Flixy/Views/Navigation/TopChrome.swift @@ -0,0 +1,68 @@ +import SwiftUI +import Observation + +/// Was oben über dem Inhalt schwebt — und wie viel Platz es einnimmt. +/// +/// Zwei Dinge, die zusammengehören und deshalb an einer Stelle liegen: die +/// Höhe, die Statusleiste und Insel zusammen belegen, und ob die Insel gerade +/// zu sehen ist. +/// +/// Der Umweg über ein geteiltes Objekt ist nötig, weil beide Enden weit +/// auseinanderliegen. Die Höhe kennt nur `RootView` — sie hängt am Gerät und +/// daran, ob die Navigation an der Seite oder oben sitzt. Brauchen tut sie der +/// Aufmacher, der unter ihr durchgreifen soll, und der sitzt drei Ebenen +/// tiefer in einem eigenen Navigationsstapel. Umgekehrt weiß nur die +/// Bildlaufansicht des gerade sichtbaren Bildschirms, ob man oben steht — +/// entscheiden muss es `RootView`. +@MainActor +@Observable +final class TopChrome { + /// Wie hoch Statusleiste und Insel zusammen bauen. + /// + /// Der Wert, um den der Inhalt nach unten beginnt. Wer ihn überspringen + /// und bis an den Bildschirmrand greifen will, zieht sich um genau diese + /// Strecke nach oben. + var inset: CGFloat = 0 + + /// Ob die Insel gerade zu sehen ist. + /// + /// Steht man ganz oben, ist sie da. Beim Scrollen fährt sie weg — der + /// Inhalt ist dann das Wichtigere, und die Navigation kommt zurück, sobald + /// man wieder oben ankommt. + var isRevealed = true +} + +extension View { + /// Meldet dieser Bildlaufansicht an, ob sie ganz oben steht. + /// + /// Gehört an die **äußere, senkrechte** Bildlaufansicht eines Bildschirms. + /// Nicht an die waagerechten Reihen darin: `onScrollGeometryChange` meldet + /// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht + /// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite + /// weit unten steht. + func flixyRevealsTopChrome() -> some View { + modifier(RevealsTopChrome()) + } +} + +private struct RevealsTopChrome: ViewModifier { + @Environment(TopChrome.self) private var chrome + + func body(content: Content) -> some View { + content + // Der Sicherheitsrand gehört dazugerechnet: `contentOffset` allein + // beginnt bei einer Ansicht mit Rand oben im Negativen, und „ganz + // oben" wäre dann nie null. + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top + } action: { _, offset in + // Eine Schwelle statt genau null: das Gummiband am oberen Ende + // liefert auch im Ruhezustand Werte, die um Bruchteile eines + // Punktes schwanken, und die Insel flackerte sonst. + chrome.isRevealed = offset <= 1 + } + // Wer einen Bildschirm verlässt, während er weit unten steht, käme + // sonst auf dem nächsten ohne Navigation an. + .onDisappear { chrome.isRevealed = true } + } +} diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index cd3215c..7efc2e2 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -43,6 +43,7 @@ struct SearchView: View { } .padding(.vertical, FlixySpacing.loose) } + .flixyRevealsTopChrome() } @ViewBuilder @@ -74,6 +75,7 @@ struct SearchView: View { .listRowBackground(Color.flixySurface) } .scrollContentBackground(.hidden) + .flixyRevealsTopChrome() } } diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 66661d3..0f63db4 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -82,6 +82,7 @@ struct SettingsView: View { .tint(accent) .flixyForm() .scrollContentBackground(.hidden) + .flixyRevealsTopChrome() .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.settings")) .flixyInlineTitle() diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 86feee9..ee56494 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -144,6 +144,24 @@ public struct HeroBanner: View { .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)