Ein negativer Rand dehnt, was er umschließt, meldet nach außen aber die alte Breite. Er stand ganz oben, also legten sich Verlauf und Schrift nur über den alten Bereich — und die zwölf Punkte daneben blieben ohne Verlauf und damit hell. Gemessen: dort 60 Helligkeit, im Inhaltsbereich 19, bei gleichem Bild. Jetzt wird erst der Verlauf gelegt, dann gedehnt, dann die Schrift gesetzt. Der Verlauf deckt bis zum Rand, die Schrift bleibt auf der gemeldeten Breite und fängt von selbst dort an, wo auch die Reihen darunter anfangen. Der Weg dorthin ging über drei Irrtümer, die alle widerlegt sind: nicht `glassEffect` (der Streifen blieb bei undurchsichtiger Fläche), nicht der Platzhalter (er blieb bei grünem Platzhalter), nicht der Scroll-Anzeiger (er blieb ohne Bildlaufansicht). Die Bildlaufansicht in der Leiste ist trotzdem draußen — sie wurde nicht gebraucht. Dazu: der Aufmacher nimmt die volle Höhe statt achtundachtzig Prozent, der Haarstrich der Leiste ist zurückhaltender, und auf dem iPhone ist der Aufmacher das **Plakat** statt des Hintergrundbilds. Ein 16:9-Bild hochkant zeigt einen Ausschnitt aus der Mitte; ein Plakat ist von vornherein hochkant und passt ohne Beschnitt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
11 KiB
Swift
309 lines
11 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 {
|
|
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<String> = []
|
|
|
|
/// 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)
|
|
}
|
|
}
|