Eigene Seitenleiste — überall dieselbe
Die System-Seitenleiste sitzt auf dem iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand. `preferredLayout = .overlap` ist die dafür vorgesehene Bitte, und macOS verwirft sie stillschweigend — gemessen, nicht vermutet. Statt deshalb die ganze Navigation neu zu bauen, bleibt der `TabView` stehen und verliert nur sein Kleid: `TabChromeSuppressor` blendet Seiten- und Tableiste aus, gezeichnet wird darüber. Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener Scrollstand beim Wechseln — all das macht weiter das System. Dass das Ausblenden geht, war kein Ratschluss: derselbe Griff hatte die Leiste vorher schon versuchsweise verschwinden lassen. Neu sind `FlixySidebar` (schwebende Fläche mit eigenem Rand, Mediatheken einklappbar, Konto unten) und `FlixyTopBar` (dieselben Einträge als Leiste oben, Mediatheken als Menü, weil oben der Platz waagerecht begrenzt ist). Das iPhone bleibt beim System: dort gibt es keine Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe. Der Platz für die Leiste ist ein **Sicherheitsrand**, kein Abstand, und er hängt je Bildschirm — weder `TabView` noch `NavigationStack` reichen ihn an ihren Inhalt weiter. Damit bleibt eine Reihe fensterbreit und schiebt ihre Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand keine dorthin. Der Aufmacher darf über seinen Bereich hinaus; das `.clipped()` im Karussell musste dafür weichen, es schnitt genau das ab. Noch nicht dabei: „Bearbeiten" mit Ziehen und Ein-/Ausblenden. Die Einträge stehen in fester Reihenfolge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b4c2bd3970
commit
fccec1217b
@@ -0,0 +1,267 @@
|
||||
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".
|
||||
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)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
|
||||
.strokeBorder(Color.flixySeparator.opacity(0.6), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous))
|
||||
.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 {
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
.overlay { Capsule().strokeBorder(Color.flixySeparator.opacity(0.6), lineWidth: 0.5) }
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user