Eigene Seitenleiste — überall dieselbe
Die System-Seitenleiste sitzt auf dem iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand. `preferredLayout = .overlap` ist die dafür vorgesehene Bitte, und macOS verwirft sie stillschweigend — gemessen, nicht vermutet. Statt deshalb die ganze Navigation neu zu bauen, bleibt der `TabView` stehen und verliert nur sein Kleid: `TabChromeSuppressor` blendet Seiten- und Tableiste aus, gezeichnet wird darüber. Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener Scrollstand beim Wechseln — all das macht weiter das System. Dass das Ausblenden geht, war kein Ratschluss: derselbe Griff hatte die Leiste vorher schon versuchsweise verschwinden lassen. Neu sind `FlixySidebar` (schwebende Fläche mit eigenem Rand, Mediatheken einklappbar, Konto unten) und `FlixyTopBar` (dieselben Einträge als Leiste oben, Mediatheken als Menü, weil oben der Platz waagerecht begrenzt ist). Das iPhone bleibt beim System: dort gibt es keine Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe. Der Platz für die Leiste ist ein **Sicherheitsrand**, kein Abstand, und er hängt je Bildschirm — weder `TabView` noch `NavigationStack` reichen ihn an ihren Inhalt weiter. Damit bleibt eine Reihe fensterbreit und schiebt ihre Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand keine dorthin. Der Aufmacher darf über seinen Bereich hinaus; das `.clipped()` im Karussell musste dafür weichen, es schnitt genau das ab. Noch nicht dabei: „Bearbeiten" mit Ziehen und Ein-/Ausblenden. Die Einträge stehen in fester Reihenfolge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b4c2bd3970
commit
fccec1217b
@@ -36,6 +36,13 @@ struct RootView: View {
|
||||
@AppStorage("flixy.navigation.customization")
|
||||
private var customization = TabViewCustomization()
|
||||
|
||||
/// Ob die Navigation an der Seite oder oben sitzt.
|
||||
///
|
||||
/// Gespeichert, weil die Wahl dem Nutzer gehört und den Neustart
|
||||
/// überleben muss.
|
||||
@AppStorage("flixy.navigation.placement")
|
||||
private var placement: NavigationPlacement = .sidebar
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch session.phase {
|
||||
@@ -177,7 +184,7 @@ struct RootView: View {
|
||||
/// Unterschied sichtbar.
|
||||
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
|
||||
|
||||
private var library: some View {
|
||||
private var tabs: some View {
|
||||
TabView(selection: $selection) {
|
||||
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
||||
mediaStack { HomeView() }
|
||||
@@ -248,28 +255,108 @@ struct RootView: View {
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.tabViewCustomization($customization)
|
||||
// Auf dem Mac würde die Leiste sonst den Inhalt zur Seite schieben
|
||||
// und selbst am Fensterrand kleben. Auf dem iPad tut das System es
|
||||
// ohnehin so; der Aufruf schadet dort nicht.
|
||||
.background { SidebarOverlapRequest().frame(width: 0, height: 0) }
|
||||
// Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag
|
||||
// zwischen den Mediatheken. Dort sucht man es.
|
||||
.tabViewSidebarBottomBar {
|
||||
AccountBar(selection: $selection)
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
// Der Griff, an dem die Spalte hängt: dieser Inhalt sitzt in
|
||||
// der Seitenleiste, also nimmt sie seine Breitenangabe an.
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
.navigationSplitViewColumnWidth(
|
||||
min: Self.sidebarWidth.min,
|
||||
ideal: Self.sidebarWidth.ideal,
|
||||
max: Self.sidebarWidth.max
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Die Navigation, wie **wir** sie zeichnen.
|
||||
///
|
||||
/// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
|
||||
/// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
|
||||
/// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
|
||||
/// `TabChromeSuppressor`. Gezeichnet wird darüber.
|
||||
///
|
||||
/// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
|
||||
/// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
|
||||
/// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
|
||||
/// zeichnen wir es selbst.
|
||||
///
|
||||
/// Auf dem iPhone bleibt alles beim System: dort gibt es keine
|
||||
/// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
|
||||
private var library: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if sizeClass == .regular {
|
||||
tabs
|
||||
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
||||
.overlay(alignment: placement == .sidebar ? .topLeading : .top) {
|
||||
chrome
|
||||
}
|
||||
} else {
|
||||
tabs
|
||||
}
|
||||
#else
|
||||
tabs
|
||||
#endif
|
||||
}
|
||||
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chrome: some View {
|
||||
switch placement {
|
||||
case .sidebar:
|
||||
FlixySidebar(groups: navigationGroups, selection: $selection, placement: $placement)
|
||||
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, selection: $selection, placement: $placement)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house"),
|
||||
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle"),
|
||||
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape"),
|
||||
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass"),
|
||||
])
|
||||
]
|
||||
|
||||
var media = session.libraries.map { library in
|
||||
NavigationEntry(
|
||||
destination: .library(key: library.id, title: library.title),
|
||||
title: Text(library.title),
|
||||
symbol: symbol(for: library.kind)
|
||||
)
|
||||
}
|
||||
if session.supportsLiveTV {
|
||||
media.append(NavigationEntry(
|
||||
destination: .liveTV,
|
||||
title: Text("sidebar.liveTV"),
|
||||
symbol: "dot.radiowaves.left.and.right"
|
||||
))
|
||||
}
|
||||
if !media.isEmpty {
|
||||
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/// Ein Navigationsstapel, in dem Detailseiten aufgehen.
|
||||
///
|
||||
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
|
||||
@@ -279,17 +366,44 @@ struct RootView: View {
|
||||
/// nicht; es passiert einfach nichts.
|
||||
private func mediaStack<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
NavigationStack {
|
||||
content()
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
chromed { content() }
|
||||
.navigationDestination(for: MediaRoute.self) { route in
|
||||
chromed { MediaDetailView(route: route) }
|
||||
}
|
||||
.navigationDestination(for: LibraryRoute.self) { route in
|
||||
if let library = session.libraries.first(where: { $0.id == route.id }) {
|
||||
LibraryView(library: library)
|
||||
chromed {
|
||||
if let library = session.libraries.first(where: { $0.id == route.id }) {
|
||||
LibraryView(library: library)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
||||
.navigationDestination(for: LiveTVRoute.self) { _ in
|
||||
chromed { LiveTVView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@ViewBuilder
|
||||
private func chromed<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
#if os(iOS)
|
||||
let insets = sizeClass == .regular ? chromeInsets : EdgeInsets()
|
||||
content()
|
||||
.safeAreaPadding(insets)
|
||||
#else
|
||||
content()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Die Breite der Seitenleiste auf dem Mac.
|
||||
///
|
||||
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei
|
||||
|
||||
Reference in New Issue
Block a user