Files
Flaro/docs/superpowers/specs/2026-09-13-randlose-navigationsinsel-design.md
T
ScarriffleandClaude Opus 5 9b3c6e2030 Der schwarze Balken hat einen Namen: die Safe Area
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 <noreply@anthropic.com>
2026-09-13 22:40:11 +02:00

92 lines
6.9 KiB
Markdown

# 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.