Der Umschalter stand in einem `withAnimation`, und trotzdem sprang die Leiste von einer Stellung in die andere. `@AppStorage` schreibt in die Voreinstellungen und meldet die Änderung erst danach zurück — sie kommt also außerhalb der Animation an, und die Animation hat nichts, woran sie sich festhalten könnte. Jetzt führt ein Zustand dieser Ansicht, und gespeichert wird hinterher. Die Bindung an die Leisten bringt die Animation gleich mit. Nachgesehen, nicht vermutet: mit versuchsweise zwei Sekunden Dauer und einer Serienaufnahme von außen ist der Zwischenzustand fotografiert — Seitenleiste halb nach links draußen, Leiste oben im Einblenden, Inhalt auf dem Weg. Danach zurück auf knappe 0,32 Sekunden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
420 lines
16 KiB
Swift
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 {
|
|
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 {
|
|
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)
|
|
}
|
|
}
|