Files
Flaro/Flixy/Views/Navigation/DrawerContainer.swift
T
GuidoandClaude Opus 5 1ed64bf166 Phase 0: Projektgerüst, Design-Tokens und Navigation
Legt das Fundament für den Plex-Client an:

- XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices,
  strict concurrency, Projektdatei nicht eingecheckt)
- FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback,
  FlixyDownloads. macOS steht als Plattform mit drin, damit die
  Logik-Tests ohne Simulator laufen.
- FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und
  Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die
  Umgebung und wird per WCAG-Rechnung lesbar gehalten.
- PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und
  die Medienmodelle samt nachsichtigem Decoder.
- FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die
  eine Funktion, an der Phase 2 das Transcoding andockt.
- Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad.
- 45 Tests, alle grün.

VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit
strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig,
deshalb bekommt sie in Phase 1 ein eigenes Target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:40 +02:00

90 lines
3.4 KiB
Swift

import SwiftUI
import FlixyDesign
/// Die ausfahrbare Seitenleiste auf dem iPhone.
///
/// Sie legt sich über den Inhalt und dunkelt ihn ab, statt ihn zur Seite zu
/// schieben — dadurch bleibt die Position im Inhalt erhalten und das Zumachen
/// fühlt sich an wie das Wegwischen eines Blattes.
struct DrawerContainer<Sidebar: View, Content: View>: View {
@Binding var isOpen: Bool
@ViewBuilder let sidebar: Sidebar
@ViewBuilder let content: Content
/// Breit genug für lange Mediatheksnamen, schmal genug, dass vom Inhalt
/// dahinter noch etwas zu sehen bleibt.
private let width: CGFloat = 280
/// Ab wie weit gezogen die Leiste offen bleibt, statt zurückzuschnappen.
private let commitThreshold: CGFloat = 0.4
@GestureState private var drag: CGFloat = 0
@Environment(\.layoutDirection) private var layoutDirection
/// 0 = zu, 1 = offen. Fasst Zustand und laufende Geste zusammen, damit
/// Abdunklung und Position immer zueinander passen.
private var openness: CGFloat {
let base = isOpen ? width : 0
return ((base + drag) / width).clamped()
}
var body: some View {
ZStack(alignment: .leading) {
content
.accessibilityHidden(isOpen)
if openness > 0 {
Color.flixyScrim
.opacity(openness)
.ignoresSafeArea()
.onTapGesture { setOpen(false) }
// Ohne diese Beschriftung ist die Fläche für VoiceOver ein
// stummer Block, der den Inhalt verdeckt.
.accessibilityLabel(Text("sidebar.close"))
.accessibilityAddTraits(.isButton)
.accessibilityAction { setOpen(false) }
}
sidebar
.frame(width: width)
.frame(maxHeight: .infinity)
.background(Color.flixySurface)
.offset(x: (openness - 1) * width)
.accessibilityHidden(!isOpen)
}
.gesture(dragGesture)
.animation(.snappy(duration: 0.28), value: isOpen)
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 12, coordinateSpace: .global)
.updating($drag) { value, state, _ in
// Bei geschlossener Leiste zählt nur ein Zug, der am linken
// Rand beginnt. Sonst würde jedes waagerechte Wischen in einer
// Reihe die Seitenleiste hervorziehen.
if !isOpen {
guard value.startLocation.x < 24 else { return }
state = max(0, value.translation.width)
} else {
state = min(0, value.translation.width)
}
}
.onEnded { value in
guard isOpen || value.startLocation.x < 24 else { return }
let projected = openness + (value.predictedEndTranslation.width - value.translation.width) / width
setOpen(projected > commitThreshold)
}
}
private func setOpen(_ open: Bool) {
guard open != isOpen else { return }
withAnimation(.snappy(duration: 0.28)) { isOpen = open }
}
}
extension CGFloat {
fileprivate func clamped(to range: ClosedRange<CGFloat> = 0...1) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}