import SwiftUI import FlixyDesign import MediaKit /// Wo die Navigation sitzt. /// /// Die Wahl gehört dem Nutzer und muss den Neustart überleben — deshalb eine /// eigene, gespeicherte Größe statt eines Zustands, der mit der Ansicht stirbt. enum NavigationPlacement: String { case sidebar case top } /// Ein Eintrag in der Navigation. struct NavigationEntry: Identifiable { let destination: SidebarDestination let title: Text let symbol: String var id: SidebarDestination { destination } } /// Eine Gruppe von Einträgen, mit oder ohne Überschrift. struct NavigationGroup: Identifiable { let id: String let title: LocalizedStringKey? let entries: [NavigationEntry] } // MARK: - Seitenleiste /// Die Seitenleiste, wie sie auf dem iPad aussieht — überall. /// /// Selbst gezeichnet, weil das System sie auf dem Mac angeklebt an den /// Fensterrand setzt und sich das nicht umstellen lässt. Siehe /// `TabChromeSuppressor`: das Verhalten kommt weiter vom `TabView`, nur das /// Kleid ist unseres. /// /// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine /// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist". /// /// Der Rand kommt von `glassEffect` und nicht von einem gezeichneten Strich. /// Ein Material mit einer halben Linie Kontur darum sah aus wie eine Fläche /// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht /// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen /// „umrandet" und „aus Glas". struct FlixySidebar: View { let groups: [NavigationGroup] @Binding var selection: SidebarDestination @Binding var placement: NavigationPlacement @Environment(\.flixyAccent) private var accent /// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung: /// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen. @State private var collapsed: Set = [] /// Die Fläche, auf der die Leiste liegt. /// /// Von Hand gebaut und nicht mit `glassEffect`. Der zeichnet an seiner /// Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein /// heller Balken von mehreren Punkten Breite, der neben der Leiste stand — /// kein Rand, sondern ein Streifen. Gleichzeitig war von einer Kante nichts /// zu erkennen. /// /// Drei Lagen: ein trübes Material, damit die Leiste eine eigene Fläche /// ist; ein Schleier, damit die durchziehende Startseite nicht mitgelesen /// wird; und ein Haarstrich, der oben links aufhellt und unten rechts /// nachlässt — so fängt Glas das Licht. Dazu ein Schatten, denn was /// schwebt, wirft einen. @ViewBuilder static func surface(in shape: some InsettableShape) -> some View { shape .fill(.ultraThinMaterial) .overlay { shape.fill(Color.flixyCanvas.opacity(0.55)) } .overlay { shape.strokeBorder( LinearGradient( stops: [ .init(color: .white.opacity(0.30), location: 0), .init(color: .white.opacity(0.05), location: 0.5), .init(color: .white.opacity(0.16), location: 1), ], startPoint: .topLeading, endPoint: .bottomTrailing ), lineWidth: 1 ) } } static let width: CGFloat = 235 /// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache. static let margin: CGFloat = 12 var body: some View { VStack(alignment: .leading, spacing: 0) { header // Ohne Bildlaufansicht. // // Sie war der helle Streifen an der rechten Kante: ihr Anzeiger, // zwölf Punkte breit, in gleichbleibender Helligkeit, genau so // hoch wie die Liste. `.scrollIndicators(.never)` half nicht — auf // dem Mac zeichnet das System ihn trotzdem. // // Gebraucht wird sie ohnehin nicht: die Einträge sind wenige und // passen. Sollten es einmal so viele Mediatheken werden, dass sie // nicht passen, ist das eine eigene Aufgabe — und dann mit // abgeschaltetem Anzeiger von Hand. VStack(alignment: .leading, spacing: FlixySpacing.regular) { ForEach(groups) { group in section(group) } } .padding(.horizontal, FlixySpacing.snug) .padding(.bottom, FlixySpacing.regular) Spacer(minLength: 0) Divider().overlay(Color.flixySeparator) AccountBar(selection: $selection) .padding(.horizontal, FlixySpacing.snug) .padding(.vertical, FlixySpacing.snug) } .frame(width: Self.width) .background { Self.surface(in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)) } .clipShape(.rect(cornerRadius: FlixyRadius.panel, style: .continuous)) .shadow(color: .black.opacity(0.45), radius: 22, y: 8) .padding(Self.margin) } private var header: some View { HStack { Spacer() Button { withAnimation(.snappy(duration: 0.25)) { placement = .top } } label: { Image(systemName: "rectangle.topthird.inset.filled") .font(.flixyRowHeader) } .buttonStyle(.plain) .foregroundStyle(Color.flixyTextSecondary) .accessibilityLabel(Text("sidebar.moveToTop")) } .padding(.horizontal, FlixySpacing.regular) .padding(.top, FlixySpacing.regular) .padding(.bottom, FlixySpacing.snug) } @ViewBuilder private func section(_ group: NavigationGroup) -> some View { if let title = group.title { Button { withAnimation(.snappy(duration: 0.25)) { if collapsed.contains(group.id) { collapsed.remove(group.id) } else { collapsed.insert(group.id) } } } label: { HStack { Text(title) .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) Spacer() Image(systemName: "chevron.down") .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) .rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0)) } .padding(.horizontal, FlixySpacing.regular) .padding(.top, FlixySpacing.snug) } .buttonStyle(.plain) } if !collapsed.contains(group.id) { ForEach(group.entries) { entry in row(entry) } } } private func row(_ entry: NavigationEntry) -> some View { let isSelected = selection == entry.destination return Button { selection = entry.destination } label: { HStack(spacing: FlixySpacing.snug) { Image(systemName: entry.symbol) .font(.flixyBody) .frame(width: 22, alignment: .center) entry.title .font(.flixyBody) // Ein langer Mediathekenname darf die Leiste nicht // breiter machen; sie hat eine feste Breite. .lineLimit(1) Spacer(minLength: 0) } .foregroundStyle(isSelected ? accent : Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.regular) .padding(.vertical, FlixySpacing.snug) .background { if isSelected { Capsule().fill(Color.flixyElevated) } } .contentShape(Capsule()) } .buttonStyle(.plain) } } // MARK: - Leiste oben /// Dieselben Einträge als schwebende Leiste oben. /// /// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz /// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand /// hinauswächst, wäre schlechter als ein Menü. struct FlixyTopBar: View { let groups: [NavigationGroup] @Binding var selection: SidebarDestination @Binding var placement: NavigationPlacement @Environment(\.flixyAccent) private var accent var body: some View { HStack(spacing: FlixySpacing.tight) { Button { withAnimation(.snappy(duration: 0.25)) { placement = .sidebar } } label: { Image(systemName: "sidebar.leading") .font(.flixyCaption) .padding(.horizontal, FlixySpacing.snug) .padding(.vertical, FlixySpacing.tight) } .buttonStyle(.plain) .foregroundStyle(Color.flixyTextSecondary) .accessibilityLabel(Text("sidebar.moveToSide")) ForEach(groups) { group in if group.title == nil { ForEach(group.entries) { entry in pill(entry) } } else { menu(group) } } } .padding(.horizontal, FlixySpacing.snug) .padding(.vertical, FlixySpacing.tight) .background { FlixySidebar.surface(in: .capsule) } .clipShape(.capsule) .shadow(color: .black.opacity(0.45), radius: 18, y: 6) .padding(.top, FlixySpacing.snug) } private func pill(_ entry: NavigationEntry) -> some View { let isSelected = selection == entry.destination return Button { selection = entry.destination } label: { entry.title .font(.flixyCaption) .lineLimit(1) .foregroundStyle(isSelected ? accent : Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.regular) .padding(.vertical, FlixySpacing.tight) .background { if isSelected { Capsule().fill(Color.flixyElevated) } } .contentShape(Capsule()) } .buttonStyle(.plain) } private func menu(_ group: NavigationGroup) -> some View { let containsSelection = group.entries.contains { $0.destination == selection } return Menu { ForEach(group.entries) { entry in Button { selection = entry.destination } label: { Label { entry.title } icon: { Image(systemName: entry.symbol) } } } } label: { HStack(spacing: FlixySpacing.tight) { if let title = group.title { Text(title) } Image(systemName: "chevron.down").font(.flixyCaption) } .font(.flixyCaption) .foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.regular) .padding(.vertical, FlixySpacing.tight) .background { if containsSelection { Capsule().fill(Color.flixyElevated) } } .contentShape(Capsule()) } .menuStyle(.button) .buttonStyle(.plain) } }