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
@@ -1,91 +0,0 @@
import SwiftUI
import FlixyDesign
/// Die ausfahrbare Seitenleiste auf dem iPhone.
///
/// Sie legt sich über den Inhalt und dunkelt ihn ab, statt ihn zur Seite zu
/// schieben — dadurch bleibt die Position im Inhalt erhalten und das Zumachen
/// fühlt sich an wie das Wegwischen eines Blattes.
struct DrawerContainer<Sidebar: View, Content: View>: View {
@Binding var isOpen: Bool
@ViewBuilder let sidebar: Sidebar
@ViewBuilder let content: Content
/// Breit genug für lange Mediatheksnamen, schmal genug, dass vom Inhalt
/// dahinter noch etwas zu sehen bleibt.
private let width: CGFloat = 280
/// Ab wie weit gezogen die Leiste offen bleibt, statt zurückzuschnappen.
private let commitThreshold: CGFloat = 0.4
@GestureState private var drag: CGFloat = 0
@Environment(\.layoutDirection) private var layoutDirection
/// 0 = zu, 1 = offen. Fasst Zustand und laufende Geste zusammen, damit
/// Abdunklung und Position immer zueinander passen.
private var openness: CGFloat {
let base = isOpen ? width : 0
return ((base + drag) / width).clamped()
}
var body: some View {
ZStack(alignment: .leading) {
content
.accessibilityHidden(isOpen)
if openness > 0 {
Color.flixyScrim
// Schwächer als früher: die Leiste darüber ist jetzt
// durchscheinend, und ein voller Schleier machte aus dem
// Milchglas wieder eine Wand.
.opacity(openness * 0.7)
.ignoresSafeArea()
.onTapGesture { setOpen(false) }
// Ohne diese Beschriftung ist die Fläche für VoiceOver ein
// stummer Block, der den Inhalt verdeckt.
.accessibilityLabel(Text("sidebar.close"))
.accessibilityAddTraits(.isButton)
.accessibilityAction { setOpen(false) }
}
sidebar
.frame(width: width)
.frame(maxHeight: .infinity)
.offset(x: (openness - 1) * width)
.accessibilityHidden(!isOpen)
}
.gesture(dragGesture)
.animation(.snappy(duration: 0.28), value: isOpen)
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 12, coordinateSpace: .global)
.updating($drag) { value, state, _ in
// Bei geschlossener Leiste zählt nur ein Zug, der am linken
// Rand beginnt. Sonst würde jedes waagerechte Wischen in einer
// Reihe die Seitenleiste hervorziehen.
if !isOpen {
guard value.startLocation.x < 24 else { return }
state = max(0, value.translation.width)
} else {
state = min(0, value.translation.width)
}
}
.onEnded { value in
guard isOpen || value.startLocation.x < 24 else { return }
let projected = openness + (value.predictedEndTranslation.width - value.translation.width) / width
setOpen(projected > commitThreshold)
}
}
private func setOpen(_ open: Bool) {
guard open != isOpen else { return }
withAnimation(.snappy(duration: 0.28)) { isOpen = open }
}
}
extension CGFloat {
fileprivate func clamped(to range: ClosedRange<CGFloat> = 0...1) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
+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"
}
}
}
@@ -7,6 +7,7 @@ import Foundation
enum SidebarDestination: Hashable {
case home
case library(key: String, title: String)
case search
case liveTV
case downloads
case settings
@@ -20,6 +21,7 @@ enum SidebarDestination: Hashable {
switch self {
case .home: Branding.appName
case .library(_, let title): title
case .search: String(localized: "search.title")
case .liveTV: String(localized: "sidebar.liveTV")
case .downloads: String(localized: "sidebar.downloads")
case .settings: String(localized: "sidebar.settings")
@@ -38,6 +40,7 @@ enum SidebarDestination: Hashable {
switch self {
case .home: "house"
case .library: "film"
case .search: "magnifyingglass"
case .liveTV: "dot.radiowaves.left.and.right"
case .downloads: "arrow.down.circle"
case .settings: "gearshape"
-141
View File
@@ -1,141 +0,0 @@
import SwiftUI
import FlixyDesign
/// Der Inhalt der Seitenleiste.
///
/// Kennt keine Navigation — sie meldet nur, was ausgewählt wurde. Dadurch
/// lässt sie sich auf dem iPhone in den Drawer und auf dem iPad in die
/// `NavigationSplitView` stecken, ohne etwas zu ändern.
struct SidebarView: View {
/// Wo diese Leiste steckt.
///
/// In der geteilten Ansicht bringt das System die Glasfläche selbst mit —
/// eine eigene läge darüber und machte sie stumpf. Im ausfahrbaren Blatt
/// gibt es keine, also kommt sie von hier.
enum Presentation { case drawer, column }
var presentation: Presentation = .drawer
let libraries: [SidebarDestination]
/// Ob der aktive Server Live-TV anbietet.
///
/// Ein Eintrag, der zu einem leeren Bildschirm führt, ist schlimmer als
/// keiner — und bei Jellyfin führt er dorthin, bis der Programmführer
/// umgestellt ist.
var showsLiveTV: Bool = true
@Binding var selection: SidebarDestination
/// Auf dem iPad trägt die Navigationsleiste den Namen bereits — dort wäre
/// eine eigene Überschrift die dritte Nennung auf demselben Bildschirm.
var showsHeader: Bool = true
let onSelect: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
if showsHeader { header }
section(title: nil, items: [.home])
if !libraries.isEmpty {
section(title: String(localized: "sidebar.libraries"), items: libraries)
}
section(
title: String(localized: "sidebar.more"),
items: showsLiveTV ? [.liveTV, .downloads, .settings] : [.downloads, .settings]
)
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.section)
}
.scrollBounceBehavior(.basedOnSize)
.modifier(SidebarBackground(presentation: presentation))
}
private var header: some View {
Text(Branding.appName)
.font(.flixyTitle)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.snug)
}
@ViewBuilder
private func section(title: String?, items: [SidebarDestination]) -> some View {
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
if let title {
Text(title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.textCase(.uppercase)
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.tight)
}
ForEach(items, id: \.self) { item in
SidebarRow(destination: item, isSelected: item == selection) {
selection = item
onSelect()
}
}
}
}
}
private struct SidebarRow: View {
let destination: SidebarDestination
let isSelected: Bool
let action: () -> Void
@Environment(\.flixyAccent) private var accent
var body: some View {
Button(action: action) {
Label {
Text(destination.sidebarLabel).font(.flixyBody)
} icon: {
Image(systemName: destination.symbol)
// Feste Breite, damit die Beschriftungen auf einer Linie
// stehen — SF-Symbole sind unterschiedlich breit.
.frame(width: 22)
}
.foregroundStyle(isSelected ? accent : Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.regular)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
if isSelected {
RoundedRectangle(cornerRadius: FlixyRadius.control)
.fill(accent.opacity(0.14))
}
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}
/// Die Fläche hinter der Seitenleiste.
///
/// Ein eigener Modifikator, weil die beiden Fälle verschiedene Ergebnistypen
/// haben und ein `if` im Ansichtskörper sie nicht zusammenbringt.
private struct SidebarBackground: ViewModifier {
let presentation: SidebarView.Presentation
func body(content: Content) -> some View {
switch presentation {
case .drawer:
// Die zum Inhalt zeigende Kante ist gerundet: so liegt das Blatt
// sichtbar **über** der Seite, statt eine zweite Seite daneben zu
// sein. Ein Vollton stand vorher wie eine Wand im Bild.
content.glassEffect(
.regular,
in: .rect(
topLeadingRadius: 0,
bottomLeadingRadius: 0,
bottomTrailingRadius: FlixyRadius.sheet,
topTrailingRadius: FlixyRadius.sheet
)
)
case .column:
content
}
}
}
@@ -1,57 +0,0 @@
import SwiftUI
import FlixyDesign
/// Die Navigation als Reihe über dem Inhalt.
///
/// Dieselben Einträge wie in der Seitenleiste, nur nebeneinander statt
/// untereinander. Sie scrollt waagerecht, weil die Zahl der Mediatheken vom
/// Server kommt und niemand weiß, wie viele es sind — zusammengequetschte
/// Beschriftungen wären die schlechtere Antwort als eine Reihe, die weiterläuft.
struct TopNavigationBar: View {
let items: [SidebarDestination]
@Binding var selection: SidebarDestination
@Environment(\.flixyAccent) private var accent
@Namespace private var glass
var body: some View {
ScrollView(.horizontal) {
// Ein gemeinsamer Behälter: nebeneinanderliegende Glasflächen
// verschmelzen darin, statt als einzelne Scheiben übereinander zu
// liegen.
GlassEffectContainer(spacing: FlixySpacing.snug) {
HStack(spacing: FlixySpacing.snug) {
ForEach(items, id: \.self) { item in
chip(for: item)
}
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.snug)
}
.scrollIndicators(.hidden)
.scrollClipDisabled()
}
private func chip(for item: SidebarDestination) -> some View {
let isSelected = item == selection
return Button {
selection = item
} label: {
Label(item.sidebarLabel, systemImage: item.symbol)
.font(.flixyCardTitle)
.lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.plain)
.glassEffect(
isSelected ? .regular.tint(accent.opacity(0.28)).interactive() : .regular.interactive(),
in: .capsule
)
.glassEffectID(item, in: glass)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}