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
@@ -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 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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user