From 9b3c6e203018fc0376965035b15f99c473d9eddf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 22:40:11 +0200 Subject: [PATCH] Der schwarze Balken hat einen Namen: die Safe Area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec für die randlose Navigationsinsel auf dem iPad. Der Baum sitzt heute vollständig innerhalb der Safe Area — deshalb erreicht kein safeAreaInset weiter unten den Streifen über der Insel. Co-Authored-By: Claude Opus 5 --- ...-09-13-randlose-navigationsinsel-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-13-randlose-navigationsinsel-design.md diff --git a/docs/superpowers/specs/2026-09-13-randlose-navigationsinsel-design.md b/docs/superpowers/specs/2026-09-13-randlose-navigationsinsel-design.md new file mode 100644 index 0000000..e2e0af5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-randlose-navigationsinsel-design.md @@ -0,0 +1,91 @@ +# Randlose Navigationsinsel auf dem iPad + +## Kontext + +Auf dem iPad schwebt die Navigation als Insel — wahlweise als Seitenleiste links oder als Kapsel oben. Über der Insel liegt ein schwarzer Streifen, der **nie** von Inhalt belegt wird, auch beim Scrollen nicht. Der Aufmacher hört an der Unterkante der Insel auf, statt hinter ihr bis zum Bildschirmrand durchzulaufen. + +Im Arbeitsverzeichnis lagen bereits zwei ungetestete Versuche in diese Richtung: + +- [`FlixyNavigationBar.swift`](../../../Flixy/Views/Navigation/FlixyNavigationBar.swift) — `.fixedSize()` am Menütitel, gegen das abgeschnittene „Mediathe…" +- [`RootView.swift`](../../../Flixy/Views/Navigation/RootView.swift) — `.padding(.top,…)` wurde zu `.safeAreaInset(edge: .top,…)` + +Der zweite Versuch geht in die richtige Richtung, **greift aber eine Ebene zu tief**. Darum geht es in dieser Spec. + +## Ursache + +In der Kette `WindowGroup → RootView → library → HStack → tabs` ignoriert nichts die Safe Area. Das einzige `.ignoresSafeArea()` der Datei sitzt auf dem Player-Overlay (`RootView.swift:114`). + +Damit sitzt der **gesamte** Navigationsbaum unterhalb der Statusleiste. Ein `safeAreaInset` auf `tabs` fügt Platz nur *innerhalb* dieses bereits beschnittenen Bereichs hinzu — der Inhalt kann beim Scrollen bestenfalls bis zur Unterkante der Statusleiste wandern, nie bis zum Bildschirmrand. Genau der Streifen, der übrig bleibt, ist der schwarze Balken. + +`.background(Color.flixyCanvas)` färbt den Streifen, weil `.background` mit einer Farbe sich in die Safe Area ausdehnt. Deshalb ist er schwarz statt durchsichtig — und deshalb sah es nach einer gezeichneten Fläche aus statt nach fehlendem Inhalt. + +## Entscheidungen (mit dem Nutzer abgestimmt) + +| Thema | Entscheidung | +|---|---| +| Layout | **Randlos.** Der Aufmacher füllt von Anfang an bis zum Bildschirmrand, die Insel schwebt darauf. Kein Balken zu keiner Zeit. | +| Leiste oben | Blendet beim Scrollen aus, kommt zurück, wenn man **ganz oben** ist — nicht beim ersten Hochwischen. | +| Seitenleiste | Blendet **nicht** aus. Sie steht seitlich und stört beim Scrollen nicht. | +| Detailseiten | Die doppelte Chrome-Zeile („Staffel 2" unter der Insel) bleibt **unangetastet** und wird bewertet, wenn der Balken weg ist. Das Ausblenden beim Scrollen gilt dort trotzdem — ein Verhalten, das auf vier von sechs Bildschirmen greift, ist kein Verhalten, sondern ein Fehler. | +| iPhone | Unangetastet. Alles hier gilt nur für `sizeClass == .regular`. | + +Verworfen wurde die zurückhaltendere Variante, bei der der Balken im Ruhezustand bestehen bleibt und der Inhalt nur beim Scrollen hindurchwandert. Begründung: der Ruhezustand ist das, was man beim Öffnen der App sieht. + +## Umsetzung + +### 1. Der Baum reicht bis zum Bildschirmrand + +`.ignoresSafeArea(.container, edges: .top)` auf den `HStack` in `RootView.library`. Was die Safe Area vorher geleistet hat, kommt danach kontrolliert zurück: + +- **Seitenleiste und Insel** bekommen ihren Abstand von oben explizit: Statusleistenhöhe + Rand. Ohne das rutschen sie in die Uhrzeit. +- **`tabs`** behält den `safeAreaInset`, aber mit der vollen Höhe: Statusleiste + Leiste. Damit beginnen alle Bildschirme unter der Insel, und ihr Inhalt wandert beim Scrollen bis zum Bildschirmrand durch. +- Die **Statusleistenhöhe** liest ein `GeometryReader` um `library` herum — also außerhalb des `HStack` und damit, bevor die Safe Area ignoriert wird. Innerhalb wäre sie bereits 0. + +Der `safeAreaInset` ist bewusst kein `padding`: ein Abstand verkleinert die Fläche und der Inhalt hört an seiner Kante auf; ein Sicherheitsrand verschiebt nur den Anfang und lässt den Inhalt darunter durchlaufen. + +### 2. Der Aufmacher füllt von y=0 + +Das ist der Unterschied zwischen „durchlässig" und „randlos": der Aufmacher zieht sich mit negativem Padding über das Inset zurück und beginnt am echten Bildschirmrand. Sein Text sitzt unten und bleibt frei. + +`HeroArtwork` wächst über ein Seitenverhältnis, nicht über eine feste Höhe — „nach oben wachsen" heißt hier also: etwas mehr beschneiden. + +### 3. Ein Verlauf für die Statusleiste + +Uhrzeit und Batterie liegen künftig auf dem Bild. `HeroArtwork` bringt keinen Verlauf nach oben mit; es kommt einer dazu. Ohne ihn steht die Uhrzeit auf einem hellen Bild unlesbar da. + +### 4. Die Leiste geht beim Scrollen weg + +Ein `@Observable` im Environment trägt einen Wahrheitswert: ganz oben oder nicht. `RootView` liest ihn und schiebt die Insel mit Versatz und Deckkraft weg. + +Gemeldet wird über einen Modifier `.flixyRevealsChrome()` auf der **äußeren, senkrechten** ScrollView jedes Bildschirms. Das Muster steht bereits im Haus (`ProgramGuide.swift:198`): + +```swift +.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } + action: { _, y in visibility.isRevealed = y <= 0 } +``` + +`MediaRow` bekommt den Modifier **nicht** — das ist die waagerechte Reihe innerhalb der Startseite und würde beim seitlichen Blättern falsch melden. + +Betroffene Bildschirme: `HomeView`, `LibraryView`, `SearchView`, `LiveTVView` sowie die Detailseiten (`PlayableDetailView`, `SeriesDetailView`). Bildschirme, die statt einer `ScrollView` eine `List` oder `Form` tragen — `SettingsView`, `DownloadsView` —, bekommen denselben Modifier; `onScrollGeometryChange` gilt dort ebenso. + +**Das Inset bleibt konstant**, egal ob die Insel zu sehen ist. Ließe man es mitschrumpfen, verschöbe sich der Inhalt bei jedem Ein- und Ausblenden — der Inhalt würde beim Scrollen nachrucken. + +Beim Wechsel des Eintrags wird zurückgesetzt: eine neue Seite beginnt oben, also ist die Insel da. + +### 5. „Mediathe…" + +`.fixedSize()` liegt bereits im Arbeitsverzeichnis und wird beim Bauen verifiziert. Reicht es nicht, liegt die Ursache darin, dass `Menu` mit `.menuStyle(.button)` sein Label beschneidet — dann muss der Griff auf die Kapsel statt auf den Text. + +## Zwei Punkte, die gemessen und nicht geraten werden + +1. **Setzt der eingebettete `UITabBarController` seine Safe Area erneut?** `TabChromeSuppressor` hängt einen UIKit-Controller in den Baum, und eingebettete Controller setzen ihre Insets gern selbst. Falls ja, kommt dort `additionalSafeAreaInsets` dazu. +2. **Greift der Scroll-Melder zentral?** Wenn `onScrollGeometryChange` an einer Stelle in `mediaStack` für alle Bildschirme meldet, ist das einer Stelle statt vier vorzuziehen. Bei verschachtelten ScrollViews (`MediaRow` in `HomeView`) ist aber unklar, welche gemeldet wird — das entscheidet die Messung. + +## Fertig ist es, wenn + +- Auf dem iPad ist über der Insel **kein** schwarzer Streifen mehr zu sehen, weder beim Öffnen noch beim Scrollen. +- Der Aufmacher läuft hinter der Insel hindurch bis zum oberen Bildschirmrand und verschwindet dort. +- Uhrzeit und Batterie sind über jedem Aufmacher lesbar, auch über einem hellen. +- Beim Runterscrollen verschwindet die Leiste oben; ganz oben ist sie wieder da. Der Inhalt ruckt dabei nicht. +- In der Leiste steht „Mediatheken", nicht „Mediathe…". +- Auf dem iPhone hat sich nichts verändert.