Vier Dinge, die zusammengehören: Die Spiegelung ist weg. `backgroundExtensionEffect` füllt die Fläche unter der Leiste, indem er den Bildrand gespiegelt weiterzeichnet — und je größer die zu füllende Fläche wurde, desto mehr übernahm der Spiegel das Bild. Auf dem iPhone legte er sich ohne jede Seitenleiste über den Statusbalken. Jetzt läuft das Bild selbst weiter, über einen negativen Rand. Es endet dabei an der **Kante der Leiste**, nicht am Fensterrand. Der Unterschied sind zwölf Punkte, und die waren deutlich zu sehen: neben der schwebenden Leiste stand ein heller Streifen Bild, den nichts abdeckte. Das Seitenverhältnis im Karussell musste weichen. Es legte den Aufmacher auf die Breite des Inhaltsbereichs fest; weil er nach links unter die Leiste greift, blieb rechts genau so viel schwarz übrig, wie er links gewann. Und die Maße, in denen das Hintergrundbild angefordert wird, richten sich jetzt nach dem Rahmen, in dem es landet. Vorher wurden 900×1200 geholt — hochkant — und in einem 16:9-Rahmen gezeigt: zu sehen war ein Streifen aus der Mitte, oben und unten fehlte je ein Drittel. Dazu: auf schmalen Geräten ist der Aufmacher hochkant. In 16:9 blieben auf einem iPhone keine zweihundertzwanzig Punkte Höhe, und Schriftzug, Zeile, Beschreibung und zwei Knöpfe lagen übereinander. Und ein Schleier unter dem Glas der Leiste. Reines Glas zeigt alles, was dahinter vorbeizieht, und beim Scrollen zieht die ganze Startseite vorbei — aus den ruhigen Beschriftungen wurde ein unruhiger Brei. Der Schleier nimmt der Bewegung die Schärfe, ohne die Fläche zuzumauern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
9.8 KiB
Swift
278 lines
9.8 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> = []
|
|
|
|
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)
|
|
// Ein Schleier unter dem Glas.
|
|
//
|
|
// Reines Glas zeigt alles, was dahinter vorbeizieht — und beim Scrollen
|
|
// zieht die ganze Startseite vorbei. Der Aufmacher, die Plakatreihen,
|
|
// die Überschriften: alles wanderte durch die Leiste hindurch, und
|
|
// neben den ruhigen Beschriftungen wurde daraus ein unruhiger Brei.
|
|
//
|
|
// Der Schleier nimmt der Bewegung die Schärfe, ohne die Fläche
|
|
// zuzumauern: man sieht noch, dass dort etwas ist, aber man liest es
|
|
// nicht mehr mit.
|
|
.background(
|
|
Color.flixyCanvas.opacity(0.72),
|
|
in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)
|
|
)
|
|
.glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.panel))
|
|
.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(Color.flixyCanvas.opacity(0.72), in: .capsule)
|
|
.glassEffect(.regular, in: .capsule)
|
|
.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)
|
|
}
|
|
}
|