diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 5156895..7ca61c7 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -112,14 +112,27 @@ struct HeroCarousel: View { // 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. + // Wischen blättert weiter — aber erst ab einem ordentlichen Weg + // und nur, wenn es waagerecht gemeint ist. + // + // Vorher stand hier `minimumDistance: 0`. Damit griff die Geste + // sofort und nahm der Seite das senkrechte Scrollen weg: wer auf + // dem Aufmacher nach oben wischte, bei dem passierte einfach + // nichts. Vierundzwanzig Punkte lassen dem Scrollen den Vortritt. .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in isAdvancing = false } + DragGesture(minimumDistance: 24) .onEnded { value in - guard abs(value.translation.width) > 50 else { return } - step(by: value.translation.width < 0 ? 1 : -1) + let width = value.translation.width + guard abs(width) > abs(value.translation.height), abs(width) > 40 else { return } + isAdvancing = false + step(by: width < 0 ? 1 : -1) } ) + // Das Weiterwechseln hält an, sobald jemand etwas antippt. Getrennt + // von der Wischgeste, weil ein Tippen kein Ziehen ist — und weil + // eine Geste, die schon bei null Punkten greift, das Scrollen + // schluckt. + .simultaneousGesture(TapGesture().onEnded { isAdvancing = false }) if items.count > 1 { indicator } } diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 9344fed..ca5dbf0 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -47,6 +47,13 @@ struct HomeView: View { // lesbar. `.ultraThinMaterial` verschleierte dagegen den halben // Aufmacher. .flixyClearToolbarBackground() + // Gar keine Navigationsleiste auf der Startseite. + // + // Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre + // Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste + // daneben, und genau diese Kante sah man. Auf den geschobenen + // Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg. + .toolbar(.hidden, for: .navigationBar) .task(id: session.serverName) { await load() } .refreshable { await load() } } diff --git a/Flixy/Views/Navigation/FlixyNavigationBar.swift b/Flixy/Views/Navigation/FlixyNavigationBar.swift index 2ce932d..5299bcd 100644 --- a/Flixy/Views/Navigation/FlixyNavigationBar.swift +++ b/Flixy/Views/Navigation/FlixyNavigationBar.swift @@ -157,7 +157,12 @@ struct FlixySidebar: View { .background { Self.surface(in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)) } .clipShape(.rect(cornerRadius: FlixyRadius.panel, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 22, y: 8) - .padding(Self.margin) + // **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar + // an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher + // und Leiste. Der schwebende Eindruck kommt von den übrigen drei + // Seiten und vom Schatten. + .padding(.leading, Self.margin) + .padding(.vertical, Self.margin) } private var header: some View { diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 72c1386..9ef11ee 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -297,25 +297,34 @@ struct RootView: View { Group { #if os(iOS) if sizeClass == .regular { - tabs - // Der Platz für die Leiste: als **echter** Rand am ganzen - // TabView, nicht als Sicherheitsrand je Bildschirm. - // - // Ein Sicherheitsrand wandert nicht in einen geschobenen - // Bildschirm mit. Die Einstellungen schieben ihre - // Unterseiten über gewöhnliche Verweise, und dort fehlte - // er: die Beschriftungen lagen hinter der Leiste und waren - // von der Maske abgeschnitten. Ein Rand am TabView gilt - // für alles, was darin aufgeht. - .padding(chromeInsets) - .background { TabChromeSuppressor().frame(width: 0, height: 0) } - .mask(alignment: .topLeading) { chromeMask } - // Immer dieselbe Ausrichtung: wechselte sie mit der - // Stellung, sprang die Leiste im Übergang an die neue Ecke, - // statt dorthin zu wandern. - .overlay(alignment: .topLeading) { - chrome + HStack(spacing: 0) { + if placement == .sidebar { + FlixySidebar( + groups: navigationGroups, + selection: $selection, + placement: $placement.animation(Self.placementAnimation), + layout: navigationLayout + ) + .transition(.move(edge: .leading).combined(with: .opacity)) } + + 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) + .background { TabChromeSuppressor().frame(width: 0, height: 0) } + .overlay(alignment: .top) { + if placement == .top { + FlixyTopBar( + groups: navigationGroups.map(visible), + selection: $selection, + placement: $placement.animation(Self.placementAnimation) + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } } else { tabs } @@ -328,91 +337,9 @@ struct RootView: View { .onChange(of: placement) { _, new in storedPlacement = new } } - @ViewBuilder - private var chrome: some View { - switch placement { - case .sidebar: - FlixySidebar( - groups: navigationGroups, - selection: $selection, - placement: $placement.animation(Self.placementAnimation), - layout: navigationLayout - ) - // Sie kommt von der Seite herein und geht dorthin zurück. Ohne das - // erschien sie schlagartig an ihrem Platz, und beim Umschalten war - // nicht zu sehen, dass dasselbe umgezogen ist. - .transition(.move(edge: .leading).combined(with: .opacity)) + /// Wie viel Platz die Leiste oben braucht. + private static let topBarHeight: CGFloat = 52 - case .top: - // In der Mitte, und zwar erzwungen: die Leiste ist so breit wie - // ihr Inhalt, und eine Überlagerung richtet sie dann nicht - // zuverlässig mittig aus. - HStack { - Spacer(minLength: 0) - FlixyTopBar( - groups: navigationGroups.map(visible), - selection: $selection, - placement: $placement.animation(Self.placementAnimation) - ) - Spacer(minLength: 0) - } - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - - /// Wo der Inhalt überhaupt zu sehen sein darf. - /// - /// Der Aufmacher greift nach links unter die Seitenleiste. Er scrollt - /// dabei, die Leiste nicht — also blieb über und unter ihr je ein - /// Streifen Bild stehen, den nichts abdeckte. Eine Maske kann das - /// abschneiden, weil sie **hier** liegt, im festen Teil, und nicht im - /// Inhalt, der wandert. - /// - /// Sichtbar ist die Vereinigung aus zwei Flächen: alles ab der rechten - /// Kante der Leiste, und dazu genau die Fläche der Leiste. Links davon — - /// der schmale Streifen zum Fensterrand, ober- und unterhalb der Leiste — - /// bleibt nichts übrig. - @ViewBuilder - private var chromeMask: some View { - switch placement { - case .sidebar: - ZStack(alignment: .topLeading) { - Color.black - // Bis an die **Kante der Leiste**, nicht bis an die - // Inhaltskante: dazwischen liegen zwölf Punkte, und die - // standen als schwarze Lücke zwischen Bild und Leiste. - // Jetzt reicht das Bild bis an sie heran. - .padding(.leading, FlixySidebar.width + FlixySidebar.margin) - RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous) - .frame(width: FlixySidebar.width) - .padding(FlixySidebar.margin) - } - case .top: - // Oben greift nichts über den Rand hinaus; eine Maske hätte nichts - // zu tun und könnte nur schaden. - Color.black - } - } - - private var chromeInsets: EdgeInsets { - switch placement { - case .sidebar: - EdgeInsets( - top: 0, - leading: FlixySidebar.width + FlixySidebar.margin * 2, - bottom: 0, - trailing: 0 - ) - case .top: - EdgeInsets(top: 52, leading: 0, bottom: 0, trailing: 0) - } - } - - /// Die Einträge, aus denen die Leiste gebaut wird. - /// - /// Dieselbe Liste für beide Formen: was an der Seite steht, steht auch - /// oben. Sonst wäre das Umschalten ein Wechsel der App und nicht nur - /// einer der Anordnung. private var navigationGroups: [NavigationGroup] { var groups: [NavigationGroup] = [ NavigationGroup(id: "main", title: nil, entries: [ @@ -466,53 +393,18 @@ struct RootView: View { /// nicht; es passiert einfach nichts. private func mediaStack(@ViewBuilder content: () -> Content) -> some View { NavigationStack { - chromed { content() } - .navigationDestination(for: MediaRoute.self) { route in - chromed { MediaDetailView(route: route) } - } + content() + .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .navigationDestination(for: LibraryRoute.self) { route in - chromed { - if let library = session.libraries.first(where: { $0.id == route.id }) { - LibraryView(library: library) - } + if let library = session.libraries.first(where: { $0.id == route.id }) { + LibraryView(library: library) } } - .navigationDestination(for: LiveTVRoute.self) { _ in - chromed { LiveTVView() } - } - .navigationDestination(for: SearchRoute.self) { _ in - chromed { SearchView() } - } + .navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() } + .navigationDestination(for: SearchRoute.self) { _ in SearchView() } } } - /// Gibt einem Bildschirm den Platz, den unsere Leiste einnimmt. - /// - /// Je Bildschirm und nicht einmal um den Stapel herum: weder der `TabView` - /// noch der `NavigationStack` reichen einen Sicherheitsrand an ihren Inhalt - /// weiter — der Aufmacher lag beide Male zur Hälfte hinter der Leiste. - /// - /// Als **Sicherheitsrand**, nicht als Abstand. Der Unterschied ist der - /// ganze Witz: eine Reihe bleibt damit fensterbreit und schiebt ihre - /// Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand - /// keine dorthin. Mit einem gewöhnlichen Abstand endete sie an der Kante. - /// Sagt einem Bildschirm, wie weit er nach links ausbrechen darf. - /// - /// Nur noch das: den Platz nimmt sich der Rand am TabView. Hier bleibt die - /// Angabe für den Aufmacher, der bewusst darüber hinausgreift. - @ViewBuilder - private func chromed(@ViewBuilder content: () -> Content) -> some View { - #if os(iOS) - content() - .environment( - \.flixyChromeInset, - sizeClass == .regular ? max(chromeInsets.leading - FlixySidebar.margin, 0) : 0 - ) - #else - content() - #endif - } - /// Die Breite der Seitenleiste auf dem Mac. /// /// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei diff --git a/FlixyUITests/LayoutSnapshots.swift b/FlixyUITests/LayoutSnapshots.swift index 419eb05..045cd3e 100644 --- a/FlixyUITests/LayoutSnapshots.swift +++ b/FlixyUITests/LayoutSnapshots.swift @@ -146,7 +146,9 @@ final class LayoutSnapshots: XCTestCase { // nicht die Seite. Gezogen wird deshalb tief unten, wo nur die Seite // selbst zuhört. func scroll() { - let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.92)) + // Mitten auf dem Aufmacher anfassen, nicht darunter: dort schluckte + // seine Wischgeste das Scrollen, und genau das soll sie nicht mehr. + let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.55)) let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.35)) from.press(forDuration: 0.05, thenDragTo: to) Thread.sleep(forTimeInterval: 1.5) diff --git a/Packages/FlixyKit/Sources/FlixyDesign/ChromeInset.swift b/Packages/FlixyKit/Sources/FlixyDesign/ChromeInset.swift deleted file mode 100644 index e85dd65..0000000 --- a/Packages/FlixyKit/Sources/FlixyDesign/ChromeInset.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -public extension EnvironmentValues { - /// Wie weit die Navigationsleiste von links in den Bildschirm hineinragt. - /// - /// Null, wo es keine gibt — auf dem iPhone, bei Leiste oben. - /// - /// Gebraucht, weil Bild und Inhalt zwei verschiedene Dinge wollen: der - /// **Inhalt** — Überschriften, Kacheln, Knöpfe — fängt rechts der Leiste - /// an, dafür sorgt der Sicherheitsrand. Das **Bild** des Aufmachers soll - /// dagegen darunter weiterlaufen und den Fensterrand erreichen, sonst - /// endet es an einer harten Kante und daneben steht ein schwarzer Streifen. - @Entry var flixyChromeInset: CGFloat = 0 -} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 56c9319..4b574f5 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -21,14 +21,6 @@ public struct HeroBanner: View { @State private var logo: PlatformImage? - /// Wie weit die Navigationsleiste hereinragt. - /// - /// Nur das **Bild** greift danach; Titel, Beschreibung und Knöpfe nicht. - /// Die liegen in einer Überlagerung, und die sitzt auf dem ungedehnten - /// Rahmen — sie stehen also von selbst da, wo auch die Reihen darunter - /// anfangen. - @Environment(\.flixyChromeInset) private var chromeInset - public init( title: String, metadata: String? = nil, @@ -142,24 +134,6 @@ public struct HeroBanner: View { .frame(maxHeight: .infinity, alignment: .bottom) .allowsHitTesting(false) } - // Erst **hier** nach links ausbrechen, nach dem Verlauf und vor - // der Schrift. - // - // Ein negativer Rand dehnt zwar, was er umschließt, meldet nach - // außen aber die alte Breite. Stand er ganz oben, legten sich - // Verlauf und Schrift deshalb nur über den alten Bereich — und die - // zwölf Punkte daneben blieben ohne Verlauf und damit hell. Das war - // der leuchtende Streifen an der Kante der Seitenleiste. - // - // In dieser Reihenfolge stimmt beides: der Verlauf wird mitgedehnt - // und deckt bis zum Rand, die Schrift bleibt auf der gemeldeten - // Breite und fängt damit von selbst dort an, wo auch die Reihen - // darunter anfangen. - // - // (Hier stand einmal `backgroundExtensionEffect()`. Der spiegelte - // den Bildrand, statt das Bild weiterlaufen zu lassen — je größer - // die Fläche, desto mehr übernahm der Spiegel das Bild.) - .padding(.leading, -chromeInset) .overlay(alignment: .bottomLeading) { GeometryReader { geometry in let scale = scale(for: geometry.size.width)