import { ReactNode, useEffect, useRef, useState } from 'react'; import { Animated, Dimensions, Keyboard, Modal, PanResponder, ScrollView, Text, TouchableOpacity, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useKeyboardHandler } from 'react-native-keyboard-controller'; import { runOnJS } from 'react-native-reanimated'; import { useColors } from '../lib/theme'; /** * App-weites Bottom-Sheet — DAS eine Pattern für alle Custom-Modals. * * - **Default = Auto-Fit**: Sheet misst seinen Inhalt (interner ScrollView via * `onContentSizeChange`) und wird genau so hoch wie nötig. Reicht der Cap * nicht, scrollt der Inhalt intern. * - **Cap**: SCREEN_H − statusBar − `navHeaderOffset` (default 56dp). So * überschreitet das Sheet niemals den App-Nav-Header. * - **Legacy-Mode**: Wer `initialHeightPct` setzt, bekommt das alte * Fixed-Pct-Layout mit `` children-wrapper (backwards-compat). * - **Keyboard (iOS + Android)**: `useKeyboardHandler` aus * `react-native-keyboard-controller` liefert den Modal-aware nativen * Keyboard-Frame. Sheet wächst um `keyboardHeight` (gedeckelt), * `paddingBottom: keyboardHeight` schiebt Inhalt exakt über die Tastatur — * kein Doppel-Compensation auf Android (das war der Bug mit manuellem * `Keyboard.addListener`-Pattern, da RN-Modal `adjustResize` ignoriert). * - **Resize per Drag**: Grabber/Header sind drag-area → User kann größer * ziehen (bis Max) oder zum dismissen runterswipen. * * Driver-Trennung (sonst „Style property 'height' is not supported by native * animated module"-Crash): äußere View animiert `height` im JS-Driver, innere * View animiert `transform: translateY` (Slide/Dismiss) im Native-Driver. */ const DRAG_FLICK_VELOCITY = 1.5; const DEFAULT_NAV_HEADER_OFFSET = 56; // Grabber (8 + 5 + 6) + Header (4 + ~20 + 12 + 1) = ~56. Etwas Puffer. const CHROME_HEIGHT = 60; export interface FormSheetProps { visible: boolean; onClose: () => void; /** Titel links im Header. */ title: string; children: ReactNode; /** * Wenn gesetzt → Legacy-Fixed-Pct-Mode (alter `` children-wrap). * Ohne diesen Prop läuft Auto-Fit: Sheet wächst genau auf Content-Höhe. */ initialHeightPct?: number; /** Drag-down unter diesen Anteil (oder Flick) → dismiss. Default 0.25. */ minHeightPct?: number; /** * Pixel-Offset unter dem Status-Bar, bei dem das Sheet aufhört zu wachsen. * Default 56 — Standard-App-Nav-Header (Material/iOS). 0 = darf bis zur * Status-Bar gehen. */ navHeaderOffset?: number; /** Backdrop-Deckkraft (0 = kein Dim). Default 0.12. */ backdropOpacity?: number; /** Default true — Tap auf Backdrop schließt das Sheet. */ dismissOnBackdrop?: boolean; /** Default true — fügt unten einen Safe-Area-Spacer ein wenn die Tastatur zu ist. */ safeAreaBottom?: boolean; /** Default true — Sheet wächst mit der Tastatur (Inputs bleiben sichtbar). */ growWithKeyboard?: boolean; /** Border-Radius oben. Default 24. */ topRadius?: number; } export function FormSheet({ visible, onClose, title, children, initialHeightPct, minHeightPct = 0.25, navHeaderOffset = DEFAULT_NAV_HEADER_OFFSET, backdropOpacity = 0.12, dismissOnBackdrop = true, safeAreaBottom = true, growWithKeyboard = true, topRadius = 24, }: FormSheetProps) { const colors = useColors(); const insets = useSafeAreaInsets(); // Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert // Keyboard-Resize auf Android. useWindowDimensions würde live schrumpfen // wenn Keyboard auf und Activity adjustResize macht → maxHeight kollabiert → // Sheet kann nicht über den Keyboard-Bereich wachsen. const SCREEN_H = Dimensions.get('screen').height; const autoMode = initialHeightPct === undefined; // Cap: nicht über den App-Header. 200px Mindest-Cap als Fallback. const maxHeight = Math.max(200, SCREEN_H - insets.top - navHeaderOffset); // Startwert: Auto → kleiner Platzhalter bis onContentSizeChange misst. // Legacy → der vom Caller gesetzte Pct-Wert. const fallbackInitial = autoMode ? Math.min(SCREEN_H * 0.35, maxHeight) : Math.min(SCREEN_H * (initialHeightPct ?? 0.5), maxHeight); const dismissHeight = SCREEN_H * minHeightPct; const sheetHeight = useRef(new Animated.Value(fallbackInitial)).current; // JS driver const dismissY = useRef(new Animated.Value(0)).current; // native driver const currentHeight = useRef(fallbackInitial); // letzte „Ruhe"-Höhe const keyboardHeightRef = useRef(0); const userDraggedRef = useRef(false); // sobald user manuell zieht, kein Auto-Re-Fit mehr const [keyboardHeight, setKeyboardHeight] = useState(0); // Reset bei (Wieder-)Öffnen useEffect(() => { if (visible) { // keyboardHeight reset: applyKeyboardHeight hat `if (!visible) return`, // also kommt das `h=0`-Hide-Event beim Schließen NIE durch → ohne reset // öffnet das Sheet beim 2. Mal mit altem paddingBottom. setKeyboardHeight(0); keyboardHeightRef.current = 0; sheetHeight.setValue(fallbackInitial); dismissY.setValue(0); currentHeight.current = fallbackInitial; userDraggedRef.current = false; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); const handleClose = () => { Keyboard.dismiss(); sheetHeight.setValue(fallbackInitial); dismissY.setValue(0); currentHeight.current = fallbackInitial; userDraggedRef.current = false; onClose(); }; // Auto-Fit: ScrollView meldet seine natürliche Content-Höhe. const onContentSize = (_w: number, h: number) => { if (!autoMode || userDraggedRef.current) return; const safeArea = safeAreaBottom ? insets.bottom : 0; const target = Math.min(h + CHROME_HEIGHT + safeArea, maxHeight); if (Math.abs(target - currentHeight.current) < 4) return; currentHeight.current = target; Animated.timing(sheetHeight, { toValue: Math.min(target + keyboardHeightRef.current, maxHeight), duration: 180, useNativeDriver: false, }).start(); }; // Keyboard: react-native-keyboard-controller liefert reliable native frame // (Modal-aware auf Android — kein adjustResize-Doppel-Compensation-Bug). // Wir setzen state → Animated.Value für Sheet-Höhe + paddingBottom-Anker. const applyKeyboardHeight = (h: number) => { // Hook feuert global — nur reagieren wenn dieses Sheet sichtbar ist, // sonst rumpelt's mit Keyboard-Events anderer Screens. if (!visible) return; keyboardHeightRef.current = h; setKeyboardHeight(h); if (!growWithKeyboard) return; Animated.timing(sheetHeight, { toValue: Math.min(currentHeight.current + h, maxHeight), duration: 220, useNativeDriver: false, }).start(); }; useKeyboardHandler({ onStart: (e) => { 'worklet'; runOnJS(applyKeyboardHeight)(e.height); }, onEnd: (e) => { 'worklet'; runOnJS(applyKeyboardHeight)(e.height); }, }); const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, onPanResponderMove: (_, g) => { const base = currentHeight.current + keyboardHeightRef.current; const next = base - g.dy; sheetHeight.setValue(Math.max(dismissHeight - 60, Math.min(maxHeight + 16, next))); }, onPanResponderRelease: (_, g) => { const base = currentHeight.current + keyboardHeightRef.current; const finalH = base - g.dy; const v = g.vy; if (finalH < dismissHeight || v > DRAG_FLICK_VELOCITY) { Animated.timing(dismissY, { toValue: SCREEN_H, duration: 200, useNativeDriver: true, }).start(() => handleClose()); return; } let target = finalH; if (v < -DRAG_FLICK_VELOCITY) target = maxHeight; const clamped = Math.max(SCREEN_H * minHeightPct, Math.min(maxHeight, target)); Animated.spring(sheetHeight, { toValue: clamped, useNativeDriver: false, friction: 9, tension: 70, }).start(); currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current); userDraggedRef.current = true; // ab jetzt Auto-Re-Fit ignorieren }, }), ).current; const dragHandlers = panResponder.panHandlers; return ( {/* Backdrop */} {/* Outer: animated height (JS driver) */} {/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */} {/* Grabber-Bar (mittig, drag-area) — paddingY für 44pt-Hit-Area */} {/* Header: Titel links — keine Buttons. Auch drag-area. */} {title} {/* Inhalt */} {autoMode ? ( {children} ) : ( {children} )} {/* Safe-Area-Spacer (nur wenn Tastatur zu) */} {safeAreaBottom && 0 ? 0 : insets.bottom }} />} ); }