Navigation: das baut JellyTV nicht selbst — das ist Apples Bauteil

Auf deinem vierten Bild steht „Ziehe Objekte zur Seiten- oder Tableiste, um
sie anzupassen". Das ist kein Text, den jemand geschrieben hat, sondern einer
von Apple. JellyTV benutzt dort `TabView` mit `sidebarAdaptable` — und damit
ist die Frage, wie die das hinbekommen, beantwortet: gar nicht, das System
macht es.

Meine drei selbstgebauten Formen fliegen dafür raus: das ausfahrbare Blatt,
die geteilte Ansicht und die Reihe oben, die ich vor einer Stunde noch selbst
gebaut habe. Zusammen konnten sie weniger als das eine Bauteil und mussten
dreimal gepflegt werden. Was jetzt vom System kommt:

- die Seitenleiste aus Glas, mit dem Inhalt **dahinter** statt daneben
- der Knopf in ihrer Kopfzeile, der zwischen Seitenleiste und Leiste **oben**
  umschaltet — genau der, den du auf deinen Bildern siehst
- „Bearbeiten" mit Ziehen, Ein- und Ausblenden, für beide Formen gleichzeitig
- auf dem iPhone die gewohnte Leiste unten statt eines Hamburger-Menüs

Die Einstellung „seitlich oder oben" entfällt damit: sie säße neben einem
Systemknopf, der dasselbe besser kann. Was du umstellst, merkt sich das
System selbst; wir geben ihm nur einen Ort zum Aufbewahren.

Die Mediatheken stehen in einem eigenen Abschnitt, der sich in der
Seitenleiste zuklappen lässt. Die Suche bekommt ihre eigene Rolle und wird
deshalb getrennt von den übrigen Einträgen dargestellt.

Angesehen auf iPad (Seitenleiste, Leiste oben, Bearbeiten-Dialog) und iPhone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 14:37:39 +02:00
co-authored by Claude Opus 5
parent 5cebfed36b
commit 4a8500fc37
8 changed files with 88 additions and 458 deletions
+85 -108
View File
@@ -1,15 +1,15 @@
import SwiftUI
import FlixyDesign
import MediaKit
import FlixyDownloads
import FlixyPlayback
import PlexKit
/// Die Wurzelansicht.
///
/// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu
/// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint:
/// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem
/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`.
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
struct RootView: View {
@Environment(FlixySession.self) private var session
@Environment(FlixyTheme.self) private var theme
@@ -18,7 +18,6 @@ struct RootView: View {
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(NavigationSettings.self) private var navigation
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
///
@@ -29,7 +28,13 @@ struct RootView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selection: SidebarDestination = .home
@State private var isSidebarOpen = false
/// Was der Nutzer an der Navigation umgestellt hat.
///
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
/// zum Aufbewahren.
@AppStorage("flixy.navigation.customization")
private var customization = TabViewCustomization()
var body: some View {
Group {
@@ -141,114 +146,86 @@ struct RootView: View {
await fresh.start()
}
@ViewBuilder
private var library: some View {
switch navigation.placement {
case .top:
topLayout
case .side:
if sizeClass == .regular {
splitLayout
} else {
drawerLayout
}
}
}
/// Die Navigation als Reihe über dem Inhalt.
/// Die Navigation.
///
/// Ohne Seitenleiste und ohne Blatt: die Einträge stehen alle gleichzeitig
/// da. Auf einem Fernseher und auf einem breiten Fenster ist das die
/// natürlichere Form — und auf dem Handy für den, der lieber tippt als
/// wischt.
private var topLayout: some View {
NavigationStack {
destination
.safeAreaInset(edge: .top, spacing: 0) {
TopNavigationBar(items: allDestinations, selection: $selection)
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
/// Jahr vorgibt.
///
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
/// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
/// gepflegt werden.
private var library: some View {
TabView(selection: $selection) {
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
NavigationStack { HomeView() }
}
.customizationID("flixy.tab.home")
TabSection {
ForEach(session.libraries) { library in
Tab(
library.title,
systemImage: symbol(for: library.kind),
value: SidebarDestination.library(key: library.id, title: library.title)
) {
NavigationStack { LibraryView(library: library) }
}
.customizationID("flixy.tab.library.\(library.id)")
}
} header: {
Text("sidebar.libraries")
}
.customizationID("flixy.tab.libraries")
if session.supportsLiveTV {
Tab(
"sidebar.liveTV",
systemImage: "dot.radiowaves.left.and.right",
value: SidebarDestination.liveTV
) {
NavigationStack { LiveTVView() }
}
.customizationID("flixy.tab.liveTV")
}
Tab(
"sidebar.downloads",
systemImage: "arrow.down.circle",
value: SidebarDestination.downloads
) {
NavigationStack { DownloadsView() }
}
.customizationID("flixy.tab.downloads")
Tab("sidebar.settings", systemImage: "gearshape", value: SidebarDestination.settings) {
NavigationStack { SettingsView() }
}
.customizationID("flixy.tab.settings")
// Die Suche bekommt ihre eigene Rolle: das System stellt sie
// getrennt von den übrigen Einträgen dar, so wie man es aus jeder
// anderen App kennt.
Tab(value: SidebarDestination.search, role: .search) {
NavigationStack { SearchView() }
}
.customizationID("flixy.tab.search")
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization)
.miniPlayer(player, isVisible: playback.presentation == .mini)
}
/// Alle Einträge in einer Reihe — für die Navigation oben, die keine
/// Abschnitte kennt.
private var allDestinations: [SidebarDestination] {
var items: [SidebarDestination] = [.home]
items.append(contentsOf: sidebarDestinations)
if session.supportsLiveTV { items.append(.liveTV) }
items.append(contentsOf: [.downloads, .settings])
return items
}
private var sidebarDestinations: [SidebarDestination] {
session.libraries.map { .library(key: $0.id, title: $0.title) }
}
/// iPad: die Seitenleiste ist Teil des Fensters.
private var splitLayout: some View {
NavigationSplitView {
SidebarView(
presentation: .column,
libraries: sidebarDestinations,
showsLiveTV: session.supportsLiveTV,
selection: $selection,
showsHeader: false
) {}
.navigationTitle(Branding.appName)
} detail: {
NavigationStack { destination }
.miniPlayer(player, isVisible: playback.presentation == .mini)
}
}
/// iPhone: die Seitenleiste fährt über den Inhalt.
private var drawerLayout: some View {
DrawerContainer(isOpen: $isSidebarOpen) {
SidebarView(
libraries: sidebarDestinations,
showsLiveTV: session.supportsLiveTV,
selection: $selection
) {
isSidebarOpen = false
}
} content: {
NavigationStack {
destination
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
withAnimation(.snappy(duration: 0.28)) { isSidebarOpen = true }
} label: {
Image(systemName: "line.3.horizontal")
}
.accessibilityLabel(Text("sidebar.open"))
}
}
}
// Innerhalb des Inhalts, nicht darüber: fährt die Seitenleiste
// aus, soll sie die Kachel verdecken und nicht umgekehrt.
.miniPlayer(player, isVisible: playback.presentation == .mini)
}
}
@ViewBuilder
private var destination: some View {
switch selection {
case .home:
HomeView()
case .library(let key, let title):
if let library = session.libraries.first(where: { $0.id == key }) {
LibraryView(library: library)
} else {
PlaceholderScreen(destination: .library(key: key, title: title))
}
case .liveTV:
LiveTVView()
case .downloads:
DownloadsView()
case .settings:
SettingsView()
private func symbol(for kind: MediaLibrary.Kind) -> String {
switch kind {
case .movie: "film"
case .show: "tv"
default: "rectangle.stack"
}
}
}