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>
69 lines
2.9 KiB
Swift
69 lines
2.9 KiB
Swift
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 }
|
|
}
|
|
}
|