Die Leiste steht jetzt neben dem Inhalt, nicht darüber

Auf dem iPad beschneidet der TabView seinen Inhalt, auf dem Mac nicht.
Gemessen: unter der Leiste war dort gar kein Bild — die Fläche blieb
gleichmäßig dunkel, während der Aufmacher daneben bei 60 lag. Der negative
Rand, mit dem der Aufmacher unter die Leiste greifen sollte, wurde schlicht
abgeschnitten, und übrig blieb der schwarze Spalt dazwischen.

Also weg damit. Die Leiste steht in einem HStack neben dem Inhalt: kein
negativer Rand, keine Maske, kein Sicherheitsrand je Bildschirm, keine
Sonderfälle für geschobene Bildschirme. Die Leiste hat ihren Abstand zu drei
Seiten und zum Inhalt keinen — dort schließt er unmittelbar an.

Die Startseite hat keine Navigationsleiste mehr. Sie war leer und belegte
trotzdem ihre Höhe; der Aufmacher fing dadurch tiefer an als die Leiste
daneben, und genau diese Kante sah man. Auf den geschobenen Bildschirmen
bleibt sie, dort trägt sie den Zurück-Weg.

Und Wischen auf dem Aufmacher schluckt das Scrollen nicht mehr. `minimumDistance: 0`
ließ die Geste sofort greifen — wer nach oben wischte, bei dem passierte
nichts. Jetzt vierundzwanzig Punkte und nur, wenn es waagerecht gemeint ist;
das Anhalten des Wechsels übernimmt eine eigene Tippgeste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 21:55:17 +02:00
co-authored by Claude Opus 5
parent 5b2d902cd9
commit 34593d9973
7 changed files with 68 additions and 189 deletions
+35 -143
View File
@@ -297,25 +297,34 @@ struct RootView: View {
Group {
#if os(iOS)
if sizeClass == .regular {
tabs
// Der Platz für die Leiste: als **echter** Rand am ganzen
// TabView, nicht als Sicherheitsrand je Bildschirm.
//
// Ein Sicherheitsrand wandert nicht in einen geschobenen
// Bildschirm mit. Die Einstellungen schieben ihre
// Unterseiten über gewöhnliche Verweise, und dort fehlte
// er: die Beschriftungen lagen hinter der Leiste und waren
// von der Maske abgeschnitten. Ein Rand am TabView gilt
// für alles, was darin aufgeht.
.padding(chromeInsets)
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.mask(alignment: .topLeading) { chromeMask }
// Immer dieselbe Ausrichtung: wechselte sie mit der
// Stellung, sprang die Leiste im Übergang an die neue Ecke,
// statt dorthin zu wandern.
.overlay(alignment: .topLeading) {
chrome
HStack(spacing: 0) {
if placement == .sidebar {
FlixySidebar(
groups: navigationGroups,
selection: $selection,
placement: $placement.animation(Self.placementAnimation),
layout: navigationLayout
)
.transition(.move(edge: .leading).combined(with: .opacity))
}
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)
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.overlay(alignment: .top) {
if placement == .top {
FlixyTopBar(
groups: navigationGroups.map(visible),
selection: $selection,
placement: $placement.animation(Self.placementAnimation)
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
} else {
tabs
}
@@ -328,91 +337,9 @@ struct RootView: View {
.onChange(of: placement) { _, new in storedPlacement = new }
}
@ViewBuilder
private var chrome: some View {
switch placement {
case .sidebar:
FlixySidebar(
groups: navigationGroups,
selection: $selection,
placement: $placement.animation(Self.placementAnimation),
layout: navigationLayout
)
// Sie kommt von der Seite herein und geht dorthin zurück. Ohne das
// erschien sie schlagartig an ihrem Platz, und beim Umschalten war
// nicht zu sehen, dass dasselbe umgezogen ist.
.transition(.move(edge: .leading).combined(with: .opacity))
/// Wie viel Platz die Leiste oben braucht.
private static let topBarHeight: CGFloat = 52
case .top:
// In der Mitte, und zwar erzwungen: die Leiste ist so breit wie
// ihr Inhalt, und eine Überlagerung richtet sie dann nicht
// zuverlässig mittig aus.
HStack {
Spacer(minLength: 0)
FlixyTopBar(
groups: navigationGroups.map(visible),
selection: $selection,
placement: $placement.animation(Self.placementAnimation)
)
Spacer(minLength: 0)
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
/// Wo der Inhalt überhaupt zu sehen sein darf.
///
/// Der Aufmacher greift nach links unter die Seitenleiste. Er scrollt
/// dabei, die Leiste nicht — also blieb über und unter ihr je ein
/// Streifen Bild stehen, den nichts abdeckte. Eine Maske kann das
/// abschneiden, weil sie **hier** liegt, im festen Teil, und nicht im
/// Inhalt, der wandert.
///
/// Sichtbar ist die Vereinigung aus zwei Flächen: alles ab der rechten
/// Kante der Leiste, und dazu genau die Fläche der Leiste. Links davon —
/// der schmale Streifen zum Fensterrand, ober- und unterhalb der Leiste —
/// bleibt nichts übrig.
@ViewBuilder
private var chromeMask: some View {
switch placement {
case .sidebar:
ZStack(alignment: .topLeading) {
Color.black
// Bis an die **Kante der Leiste**, nicht bis an die
// Inhaltskante: dazwischen liegen zwölf Punkte, und die
// standen als schwarze Lücke zwischen Bild und Leiste.
// Jetzt reicht das Bild bis an sie heran.
.padding(.leading, FlixySidebar.width + FlixySidebar.margin)
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
.frame(width: FlixySidebar.width)
.padding(FlixySidebar.margin)
}
case .top:
// Oben greift nichts über den Rand hinaus; eine Maske hätte nichts
// zu tun und könnte nur schaden.
Color.black
}
}
private var chromeInsets: EdgeInsets {
switch placement {
case .sidebar:
EdgeInsets(
top: 0,
leading: FlixySidebar.width + FlixySidebar.margin * 2,
bottom: 0,
trailing: 0
)
case .top:
EdgeInsets(top: 52, leading: 0, bottom: 0, trailing: 0)
}
}
/// Die Einträge, aus denen die Leiste gebaut wird.
///
/// Dieselbe Liste für beide Formen: was an der Seite steht, steht auch
/// oben. Sonst wäre das Umschalten ein Wechsel der App und nicht nur
/// einer der Anordnung.
private var navigationGroups: [NavigationGroup] {
var groups: [NavigationGroup] = [
NavigationGroup(id: "main", title: nil, entries: [
@@ -466,53 +393,18 @@ struct RootView: View {
/// nicht; es passiert einfach nichts.
private func mediaStack<Content: View>(@ViewBuilder content: () -> Content) -> some View {
NavigationStack {
chromed { content() }
.navigationDestination(for: MediaRoute.self) { route in
chromed { MediaDetailView(route: route) }
}
content()
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.navigationDestination(for: LibraryRoute.self) { route in
chromed {
if let library = session.libraries.first(where: { $0.id == route.id }) {
LibraryView(library: library)
}
if let library = session.libraries.first(where: { $0.id == route.id }) {
LibraryView(library: library)
}
}
.navigationDestination(for: LiveTVRoute.self) { _ in
chromed { LiveTVView() }
}
.navigationDestination(for: SearchRoute.self) { _ in
chromed { SearchView() }
}
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
}
}
/// Gibt einem Bildschirm den Platz, den unsere Leiste einnimmt.
///
/// Je Bildschirm und nicht einmal um den Stapel herum: weder der `TabView`
/// noch der `NavigationStack` reichen einen Sicherheitsrand an ihren Inhalt
/// weiter — der Aufmacher lag beide Male zur Hälfte hinter der Leiste.
///
/// Als **Sicherheitsrand**, nicht als Abstand. Der Unterschied ist der
/// ganze Witz: eine Reihe bleibt damit fensterbreit und schiebt ihre
/// Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand
/// keine dorthin. Mit einem gewöhnlichen Abstand endete sie an der Kante.
/// Sagt einem Bildschirm, wie weit er nach links ausbrechen darf.
///
/// Nur noch das: den Platz nimmt sich der Rand am TabView. Hier bleibt die
/// Angabe für den Aufmacher, der bewusst darüber hinausgreift.
@ViewBuilder
private func chromed<Content: View>(@ViewBuilder content: () -> Content) -> some View {
#if os(iOS)
content()
.environment(
\.flixyChromeInset,
sizeClass == .regular ? max(chromeInsets.leading - FlixySidebar.margin, 0) : 0
)
#else
content()
#endif
}
/// Die Breite der Seitenleiste auf dem Mac.
///
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei