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 haloRevealsTopChrome() -> 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 } } }