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, Arrangeable { let destination: SidebarDestination let title: Text let symbol: String /// Der Titel als reiner Text — für die Sortierlogik und für die /// Bedienungshilfen. `Text` selbst lässt sich nicht auslesen. let plainTitle: String var id: SidebarDestination { destination } var arrangementID: String { destination.storageKey } var arrangementTitle: String { plainTitle } } /// 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 { /// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet /// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen. let groups: [NavigationGroup] @Binding var selection: SidebarDestination @Binding var placement: NavigationPlacement let layout: NavigationLayoutSettings @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 = [] @State private var isEditing = false /// 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 im Ruhezustand: die Einträge sind wenige // und passen. Beim Bearbeiten braucht es eine Liste — nur die // bringt das Ziehen zum Umsortieren mit. if isEditing { Text("sidebar.editHint") .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) .padding(.horizontal, FlixySpacing.regular) .padding(.bottom, FlixySpacing.snug) List { ForEach(groups) { group in Section { ForEach(editable(group)) { entry in editRow(entry) } .onMove { move(in: group, from: $0, to: $1) } } header: { if let title = group.title { Text(title) .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) } } .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } .listStyle(.plain) .scrollContentBackground(.hidden) .environment(\.editMode, .constant(.active)) } else { VStack(alignment: .leading, spacing: FlixySpacing.regular) { ForEach(groups) { group in section(visible(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(spacing: FlixySpacing.snug) { Button { withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() } } label: { Text(isEditing ? "action.done" : "sidebar.edit") .font(.flixyCaption) .padding(.horizontal, FlixySpacing.snug) .padding(.vertical, FlixySpacing.tight) .contentShape(Capsule()) } .buttonStyle(.plain) .foregroundStyle(isEditing ? accent : Color.flixyTextSecondary) Spacer(minLength: 0) if !isEditing { Button { withAnimation(.snappy(duration: 0.25)) { placement = .top } } label: { Image(systemName: "rectangle.topthird.inset.filled") .font(.flixyRowHeader) .padding(FlixySpacing.tight) // Ohne eigene Klickfläche reicht das Ziel nur so weit // wie das Zeichen selbst — auf dem Mac trifft man das // mit dem Zeiger kaum. .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(Color.flixyTextSecondary) .accessibilityLabel(Text("sidebar.moveToTop")) } } .padding(.horizontal, FlixySpacing.regular) .padding(.top, FlixySpacing.regular) .padding(.bottom, FlixySpacing.snug) } /// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten /// Reihenfolge, auch die ausgeblendeten. private func editable(_ group: NavigationGroup) -> [NavigationEntry] { layout.ordered(group.entries) } /// Verschiebt einen Eintrag innerhalb seiner Gruppe. /// /// Gespeichert wird die Reihenfolge **aller** Gruppen hintereinander. /// Innerhalb einer Gruppe zu sortieren genügt für die Anzeige, aber eine /// Liste je Gruppe zu speichern hieße, beim Hinzukommen einer Gruppe /// wieder eine Sonderregel zu haben. private func move(in group: NavigationGroup, from source: IndexSet, to destination: Int) { var entries = editable(group) entries.move(fromOffsets: source, toOffset: destination) var order: [String] = [] for other in groups { let ids = other.id == group.id ? entries.map(\.arrangementID) : editable(other).map(\.arrangementID) order.append(contentsOf: ids) } layout.setOrder(order) } @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) } } } /// Eine Gruppe, wie sie im Ruhezustand dasteht: ausgeblendete raus, /// Reihenfolge angewandt. private func visible(_ group: NavigationGroup) -> NavigationGroup { NavigationGroup(id: group.id, title: group.title, entries: layout.apply(to: group.entries)) } private func editRow(_ entry: NavigationEntry) -> some View { let isHidden = layout.isHidden(entry.arrangementID) return HStack(spacing: FlixySpacing.snug) { Button { layout.setHidden(!isHidden, for: entry.arrangementID) } label: { Image(systemName: isHidden ? "circle" : "checkmark.circle.fill") .foregroundStyle(isHidden ? Color.flixyTextMuted : accent) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(Text(isHidden ? "sidebar.show" : "sidebar.hide")) Image(systemName: entry.symbol) .frame(width: 22) entry.title .lineLimit(1) Spacer(minLength: 0) } .font(.flixyBody) .foregroundStyle(isHidden ? Color.flixyTextMuted : Color.flixyTextPrimary) } 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(.flixyBody) .padding(.horizontal, FlixySpacing.regular) .padding(.vertical, FlixySpacing.snug) // Ohne eigene Klickfläche reicht das Ziel nur so weit wie // das Zeichen; auf dem Mac war der Umschalter damit // praktisch nicht zu treffen. .contentShape(Capsule()) } .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.snug) .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(.flixyBody) .lineLimit(1) .foregroundStyle(isSelected ? accent : Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.loose) .padding(.vertical, FlixySpacing.snug) .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(.flixyBody) .foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.loose) .padding(.vertical, FlixySpacing.snug) .background { if containsSelection { Capsule().fill(Color.flixyElevated) } } .contentShape(Capsule()) } .menuStyle(.button) .buttonStyle(.plain) } }