Der Aufmacher nimmt jetzt achtundachtzig Prozent der sichtbaren Höhe, statt sich seine Höhe aus 16:9 auszurechnen. Ein Aufmacher, der ein Drittel hoch ist, sieht aus wie ein breites Vorschaubild; die zwölf Prozent Rest sind Absicht, dort lugt die erste Reihe herein und sagt, dass es weitergeht. Auf schmalen Geräten bleibt es beim Hochformat. `FlixyArtwork` kann dafür den Rahmen ausfüllen, den es bekommt. Das Seitenverhältnis bleibt trotzdem wichtig — danach werden die Bilder beim Server angefordert. Und `glassEffect` ist raus. Er zeichnet an seiner Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein heller Balken von mehreren Punkten Breite neben der Leiste — kein Rand, sondern ein Streifen. Von einer Kante war gleichzeitig nichts zu erkennen. Von Hand sind es drei Lagen: ein trübes Material, ein Schleier gegen die durchziehende Startseite, und ein Haarstrich, der oben links aufhellt und unten rechts nachlässt. Dazu ein Schatten — was schwebt, wirft einen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
11 KiB
Swift
301 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.55), location: 0),
|
|
.init(color: .white.opacity(0.10), location: 0.5),
|
|
.init(color: .white.opacity(0.30), 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
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
ForEach(groups) { group in
|
|
section(group)
|
|
}
|
|
}
|
|
.padding(.horizontal, FlixySpacing.snug)
|
|
.padding(.bottom, FlixySpacing.regular)
|
|
}
|
|
.scrollIndicators(.never)
|
|
|
|
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)
|
|
}
|
|
}
|