Hinter der Insel läuft jetzt das Wasser durch

Der schwarze Streifen über der Navigation war kein gezeichneter Balken,
sondern fehlender Inhalt: der gesamte Baum saß innerhalb des
Sicherheitsrands. Deshalb erreichte kein noch so gut gesetzter Rand
weiter unten die Fläche darüber — Inhalt kam dort nie hin.

Jetzt reißt der HStack den Rand oben auf und gibt den Platz kontrolliert
zurück: Seitenleiste und Insel bekommen ihren Abstand zur Statusleiste
explizit, der Inhalt bekommt ihn als Sicherheitsrand. Der Aufmacher
nimmt ihn sich wieder — um genau diese Strecke höher und um genau diese
Strecke größer, sodass unten alles bleibt, wo es war. Dazu ein knapper
Verlauf oben, damit Uhrzeit und Batterie auch über einem hellen Bild
lesbar bleiben.

Und die Leiste oben fährt beim Scrollen weg. Nur sie: der
Sicherheitsrand darunter bleibt stehen, sonst ruckte die Seite bei jedem
Ein- und Ausblenden unter dem Finger weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 22:52:07 +02:00
co-authored by Claude Opus 5
parent 9b3c6e2030
commit 81e22c4f97
13 changed files with 211 additions and 8 deletions
+83 -5
View File
@@ -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: [