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: 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 = 0...1) -> CGFloat { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } }