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:
co-authored by
Claude Opus 5
parent
5cebfed36b
commit
4a8500fc37
@@ -25,7 +25,6 @@ struct FlixyApp: App {
|
|||||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@State private var homeLayout = HomeLayoutSettings()
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
@State private var navigation = NavigationSettings()
|
|
||||||
@State private var diagnostics = PlaybackDiagnostics()
|
@State private var diagnostics = PlaybackDiagnostics()
|
||||||
@State private var downloads: DownloadCoordinator
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
@@ -67,7 +66,6 @@ struct FlixyApp: App {
|
|||||||
.environment(network)
|
.environment(network)
|
||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
.environment(navigation)
|
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
/// Wo die Navigation sitzt.
|
|
||||||
///
|
|
||||||
/// Eine Einstellung, kein fester Aufbau: auf einem großen Bildschirm ist eine
|
|
||||||
/// Leiste an der Seite richtig, auf einem kleinen nimmt sie Platz weg, den man
|
|
||||||
/// lieber dem Bild gäbe. Wer welche Form will, entscheidet nicht die App.
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
final class NavigationSettings {
|
|
||||||
enum Placement: String, CaseIterable, Identifiable, Sendable {
|
|
||||||
/// Seitlich — auf dem iPhone ausfahrbar, auf dem iPad dauerhaft.
|
|
||||||
case side
|
|
||||||
/// Oben als Reihe über dem Inhalt.
|
|
||||||
case top
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum Key {
|
|
||||||
static let placement = "flixy.navigation.placement"
|
|
||||||
}
|
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
|
||||||
|
|
||||||
var placement: Placement {
|
|
||||||
didSet { defaults.set(placement.rawValue, forKey: Key.placement) }
|
|
||||||
}
|
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
|
||||||
self.defaults = defaults
|
|
||||||
self.placement = defaults.string(forKey: Key.placement)
|
|
||||||
.flatMap(Placement.init(rawValue:)) ?? .side
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import MediaKit
|
||||||
import FlixyDownloads
|
import FlixyDownloads
|
||||||
import FlixyPlayback
|
import FlixyPlayback
|
||||||
import PlexKit
|
import PlexKit
|
||||||
|
|
||||||
/// Die Wurzelansicht.
|
/// Die Wurzelansicht.
|
||||||
///
|
///
|
||||||
/// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu
|
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
|
||||||
/// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint:
|
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
||||||
/// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem
|
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
||||||
/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`.
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(FlixyTheme.self) private var theme
|
@Environment(FlixyTheme.self) private var theme
|
||||||
@@ -18,7 +18,6 @@ struct RootView: View {
|
|||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@Environment(NavigationSettings.self) private var navigation
|
|
||||||
|
|
||||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||||
///
|
///
|
||||||
@@ -29,7 +28,13 @@ struct RootView: View {
|
|||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@State private var selection: SidebarDestination = .home
|
@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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -141,114 +146,86 @@ struct RootView: View {
|
|||||||
await fresh.start()
|
await fresh.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
/// Die Navigation.
|
||||||
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.
|
|
||||||
///
|
///
|
||||||
/// Ohne Seitenleiste und ohne Blatt: die Einträge stehen alle gleichzeitig
|
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
|
||||||
/// da. Auf einem Fernseher und auf einem breiten Fenster ist das die
|
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
|
||||||
/// natürlichere Form — und auf dem Handy für den, der lieber tippt als
|
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
|
||||||
/// wischt.
|
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
|
||||||
private var topLayout: some View {
|
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
|
||||||
NavigationStack {
|
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
|
||||||
destination
|
/// Jahr vorgibt.
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
///
|
||||||
TopNavigationBar(items: allDestinations, selection: $selection)
|
/// 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)
|
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle Einträge in einer Reihe — für die Navigation oben, die keine
|
private func symbol(for kind: MediaLibrary.Kind) -> String {
|
||||||
/// Abschnitte kennt.
|
switch kind {
|
||||||
private var allDestinations: [SidebarDestination] {
|
case .movie: "film"
|
||||||
var items: [SidebarDestination] = [.home]
|
case .show: "tv"
|
||||||
items.append(contentsOf: sidebarDestinations)
|
default: "rectangle.stack"
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Foundation
|
|||||||
enum SidebarDestination: Hashable {
|
enum SidebarDestination: Hashable {
|
||||||
case home
|
case home
|
||||||
case library(key: String, title: String)
|
case library(key: String, title: String)
|
||||||
|
case search
|
||||||
case liveTV
|
case liveTV
|
||||||
case downloads
|
case downloads
|
||||||
case settings
|
case settings
|
||||||
@@ -20,6 +21,7 @@ enum SidebarDestination: Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .home: Branding.appName
|
case .home: Branding.appName
|
||||||
case .library(_, let title): title
|
case .library(_, let title): title
|
||||||
|
case .search: String(localized: "search.title")
|
||||||
case .liveTV: String(localized: "sidebar.liveTV")
|
case .liveTV: String(localized: "sidebar.liveTV")
|
||||||
case .downloads: String(localized: "sidebar.downloads")
|
case .downloads: String(localized: "sidebar.downloads")
|
||||||
case .settings: String(localized: "sidebar.settings")
|
case .settings: String(localized: "sidebar.settings")
|
||||||
@@ -38,6 +40,7 @@ enum SidebarDestination: Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .home: "house"
|
case .home: "house"
|
||||||
case .library: "film"
|
case .library: "film"
|
||||||
|
case .search: "magnifyingglass"
|
||||||
case .liveTV: "dot.radiowaves.left.and.right"
|
case .liveTV: "dot.radiowaves.left.and.right"
|
||||||
case .downloads: "arrow.down.circle"
|
case .downloads: "arrow.down.circle"
|
||||||
case .settings: "gearshape"
|
case .settings: "gearshape"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import FlixyDesign
|
|||||||
/// stillschweigend zu verändern.
|
/// stillschweigend zu verändern.
|
||||||
struct AppearanceView: View {
|
struct AppearanceView: View {
|
||||||
@Environment(FlixyTheme.self) private var theme
|
@Environment(FlixyTheme.self) private var theme
|
||||||
@Environment(NavigationSettings.self) private var navigation
|
|
||||||
@State private var customHex: String = ""
|
@State private var customHex: String = ""
|
||||||
@State private var showsCustomField = false
|
@State private var showsCustomField = false
|
||||||
|
|
||||||
@@ -17,20 +16,6 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
|
||||||
Picker("appearance.navigation", selection: Bindable(navigation).placement) {
|
|
||||||
ForEach(NavigationSettings.Placement.allCases) { placement in
|
|
||||||
Text(placement.label).tag(placement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
|
||||||
} header: {
|
|
||||||
Text("appearance.navigation")
|
|
||||||
} footer: {
|
|
||||||
Text("appearance.navigationExplanation")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
|
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
|
||||||
ForEach(AccentOption.palette) { option in
|
ForEach(AccentOption.palette) { option in
|
||||||
@@ -158,11 +143,3 @@ private struct PreviewRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NavigationSettings.Placement {
|
|
||||||
var label: LocalizedStringKey {
|
|
||||||
switch self {
|
|
||||||
case .side: "appearance.navigationSide"
|
|
||||||
case .top: "appearance.navigationTop"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user