Auf dem iPad beschneidet der TabView seinen Inhalt, auf dem Mac nicht. Gemessen: unter der Leiste war dort gar kein Bild — die Fläche blieb gleichmäßig dunkel, während der Aufmacher daneben bei 60 lag. Der negative Rand, mit dem der Aufmacher unter die Leiste greifen sollte, wurde schlicht abgeschnitten, und übrig blieb der schwarze Spalt dazwischen. Also weg damit. Die Leiste steht in einem HStack neben dem Inhalt: kein negativer Rand, keine Maske, kein Sicherheitsrand je Bildschirm, keine Sonderfälle für geschobene Bildschirme. Die Leiste hat ihren Abstand zu drei Seiten und zum Inhalt keinen — dort schließt er unmittelbar an. Die Startseite hat keine Navigationsleiste mehr. Sie war leer und belegte trotzdem ihre Höhe; der Aufmacher fing dadurch tiefer an als die Leiste daneben, und genau diese Kante sah man. Auf den geschobenen Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg. Und Wischen auf dem Aufmacher schluckt das Scrollen nicht mehr. `minimumDistance: 0` ließ die Geste sofort greifen — wer nach oben wischte, bei dem passierte nichts. Jetzt vierundzwanzig Punkte und nur, wenn es waagerecht gemeint ist; das Anhalten des Wechsels übernimmt eine eigene Tippgeste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
16 KiB
Swift
425 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)
|
|
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
|
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
|
// und Leiste. Der schwebende Eindruck kommt von den übrigen drei
|
|
// Seiten und vom Schatten.
|
|
.padding(.leading, Self.margin)
|
|
.padding(.vertical, 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)
|
|
}
|
|
}
|