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:
co-authored by
Claude Opus 5
parent
9b3c6e2030
commit
81e22c4f97
@@ -58,6 +58,7 @@ struct PlayableDetailView: View {
|
||||
.padding(.bottom, FlixySpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
private var currentMedia: MediaVersion? {
|
||||
|
||||
@@ -35,6 +35,7 @@ struct SeriesDetailView: View {
|
||||
.padding(.bottom, FlixySpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.flixyRevealsTopChrome()
|
||||
.task(id: item.id) { await loadChildren() }
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ struct DownloadsView: View {
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
private var active: [DownloadItem] {
|
||||
|
||||
@@ -64,6 +64,23 @@ struct HeroCarousel: View {
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@Environment(TopChrome.self) private var chrome
|
||||
|
||||
/// Wie weit der Aufmacher nach oben unter die Insel greift.
|
||||
///
|
||||
/// Der Inhalt beginnt unter der Insel — das ist für Reihen und Listen
|
||||
/// richtig, für den Aufmacher aber nicht: er ist das Bild, auf dem die
|
||||
/// Insel schweben soll, und ein Bild, das an ihrer Unterkante anfängt,
|
||||
/// lässt darüber einen schwarzen Streifen stehen, den nie etwas füllt.
|
||||
///
|
||||
/// Also nimmt er sich den Platz zurück: um genau diese Strecke höher und
|
||||
/// um genau diese Strecke größer. Unten ändert sich dadurch nichts, die
|
||||
/// Reihen bleiben, wo sie waren.
|
||||
///
|
||||
/// Auf dem iPhone null — dort schwebt nichts über dem Inhalt.
|
||||
private var overlap: CGFloat {
|
||||
sizeClass == .compact ? 0 : chrome.inset
|
||||
}
|
||||
|
||||
private var currentIndex: Int {
|
||||
items.firstIndex { $0.id == shownID } ?? 0
|
||||
@@ -95,7 +112,7 @@ struct HeroCarousel: View {
|
||||
//
|
||||
// Auf einem schmalen Gerät nicht: dort ist der Aufmacher hochkant
|
||||
// und rechnet sich seine Höhe aus der Breite aus.
|
||||
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 0.82))
|
||||
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 0.82, extra: overlap))
|
||||
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
||||
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
||||
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
||||
@@ -136,6 +153,10 @@ struct HeroCarousel: View {
|
||||
|
||||
if items.count > 1 { indicator }
|
||||
}
|
||||
// Zieht den ganzen Aufmacher unter die Insel. Zusammen mit der
|
||||
// zusätzlichen Höhe oben hebt sich das unten auf: er beginnt am
|
||||
// Bildschirmrand und endet, wo er vorher endete.
|
||||
.padding(.top, -overlap)
|
||||
.task(id: shownID) { await advance() }
|
||||
.onChange(of: items.map(\.id), initial: true) { _, ids in
|
||||
// Beim ersten Zeichnen und nach jedem Aktualisieren: steht die
|
||||
@@ -217,10 +238,13 @@ struct HeroCarousel: View {
|
||||
/// Drehen neu geladen statt mitzuwandern.
|
||||
private struct HeroHeight: ViewModifier {
|
||||
let fraction: CGFloat?
|
||||
/// Was über den Anteil hinaus dazukommt: der Platz unter der Insel, den
|
||||
/// der Aufmacher mitfüllt, weil er nach oben unter sie greift.
|
||||
let extra: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let fraction {
|
||||
content.containerRelativeFrame(.vertical) { height, _ in height * fraction }
|
||||
content.containerRelativeFrame(.vertical) { height, _ in height * fraction + extra }
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ struct HomeView: View {
|
||||
.padding(.bottom, FlixySpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
|
||||
|
||||
@@ -99,6 +99,7 @@ struct LibraryView: View {
|
||||
|
||||
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
|
||||
}
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
@@ -117,6 +118,7 @@ struct LibraryView: View {
|
||||
|
||||
if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) }
|
||||
}
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
|
||||
@@ -196,6 +196,7 @@ struct LiveTVView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
|
||||
@@ -406,7 +406,12 @@ struct FlixyTopBar: View {
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: FlixySpacing.tight) {
|
||||
if let title = group.title { Text(title) }
|
||||
if let title = group.title {
|
||||
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
||||
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
||||
// Grund, an einem Wort zu sparen.
|
||||
Text(title).fixedSize()
|
||||
}
|
||||
Image(systemName: "chevron.down").font(.flixyCaption)
|
||||
}
|
||||
.font(.flixyBody)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ struct SearchView: View {
|
||||
}
|
||||
.padding(.vertical, FlixySpacing.loose)
|
||||
}
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -74,6 +75,7 @@ struct SearchView: View {
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ struct SettingsView: View {
|
||||
.tint(accent)
|
||||
.flixyForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.flixyRevealsTopChrome()
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("sidebar.settings"))
|
||||
.flixyInlineTitle()
|
||||
|
||||
@@ -144,6 +144,24 @@ public struct HeroBanner<Actions: View>: View {
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
|
||||
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
|
||||
// reicht. Über einem dunklen Aufmacher geht das gut; über
|
||||
// einem hellen verschwinden sie.
|
||||
//
|
||||
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
|
||||
// weit, wie die Insel baut, und läuft schnell aus. Ein
|
||||
// großzügigerer deckte das Bild zu, und genau das Bild ist der
|
||||
// Grund, warum es randlos sein soll.
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.45), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 150)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
GeometryReader { geometry in
|
||||
let scale = scale(for: geometry.size.width)
|
||||
|
||||
Reference in New Issue
Block a user