Files
Flaro/Flixy/Views/Navigation/FlixyNavigationBar.swift
T
ScarriffleandClaude Opus 5 e2779f2519 Bearbeiten, Suche auf dem Handy, und zwei Knöpfe, die man treffen kann
Die Seitenleiste lässt sich wieder bearbeiten: Ziehen zum Umsortieren, Haken
zum Ein- und Ausblenden. Das Anordnen macht `Arrangement` aus MediaKit — dieselbe
Rechnung samt Tests, die schon die Reihen der Startseite ordnet, mit ihren
kniffligen Fällen: eine Mediathek, die neu auftaucht, und eine, die aus der
gespeicherten Reihenfolge verschwindet. Gespeichert wird über eine feste
Kennung **ohne** den Titel, damit eine umbenannte Mediathek ihren Platz behält.

Die Suche ist auf dem iPhone kein eigener Reiter mehr. Die Leiste unten hat
fünf Plätze, und einer davon für etwas, das man in einer Mediathek sucht, ist
einer zu viel — sie sitzt jetzt über der Mediathek, neben Filter und Ansicht.

Der Umschalter zwischen Seite und oben ließ sich auf dem Mac kaum treffen: ein
Knopf aus einem Zeichen mit Rand hat ohne `contentShape` nur das Zeichen als
Ziel. Beide Umschalter haben jetzt eine Fläche.

Die Leiste oben trägt dieselbe Schriftgröße wie die Seitenleiste. In
Beschriftungsgröße war sie auf einem großen Bildschirm winzig.

Und eine Maske: der Aufmacher greift nach links unter die Seitenleiste, scrollt
dabei aber, während die Leiste steht — über und unter ihr blieb je ein Streifen
Bild stehen. Die Maske sitzt im festen Teil und lässt genau zwei Flächen übrig:
alles ab der Inhaltskante, und die Fläche der Leiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:33:33 +02:00

420 lines
16 KiB
Swift

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<String> = []
@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)
}
}