import { useCallback, useEffect, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; import { Animated, Easing, PanResponder, Platform } from 'react-native'; import type { GestureResponderHandlers } from 'react-native'; import { calculateAnimatedToValue, calculateSnapPointsData, getOffset, } from './helpers'; import type { SnapPointsData } from './helpers'; const FLICK_VELOCITY_THRESHOLD = 0.3; const SHORT_DRAG_THRESHOLD_PX = 20; const VELOCITY_PROJECTION_FACTOR = 200; interface UseDragablePanOptions { height: number; initialHeightPercentage: number | undefined; minimumHeightPercentage: number; snapPoints: number[]; onExpanded?: () => void; onCollapsed?: () => void; } interface UseDragablePanResult { pan: Animated.Value; isAtMaxHeight: boolean; scrollYRef: MutableRefObject; onScrollY: (y: number) => void; beginPan: () => void; movePan: (dy: number) => void; releasePan: (dy: number, vy: number) => void; panHandlers: GestureResponderHandlers; } const useDragablePan = ({ height, initialHeightPercentage, minimumHeightPercentage, snapPoints, onExpanded, onCollapsed, }: UseDragablePanOptions): UseDragablePanResult => { const baseHeightForMeasure = useRef(0); const snapPointsData = useRef({ list: [], minHeightOffset: 0, maxHeightOffset: 0, }); const pan = useRef(new Animated.Value(0)).current; const offset = useRef(0); const offsetBeforePan = useRef(0); const [animatedToValue, setAnimatedToValue] = useState(-1); const [isAtMaxHeight, setIsAtMaxHeight] = useState(false); const scrollYRef = useRef(0); const minimumHeightPercentageRef = useRef(minimumHeightPercentage); minimumHeightPercentageRef.current = minimumHeightPercentage; const onScrollY = useCallback((y: number) => { scrollYRef.current = y; }, []); useEffect(() => { const id = pan.addListener(({ value }) => { offset.current = value; }); return () => pan.removeListener(id); }, []); useEffect(() => { if (height > 0) { const initialOffset = getOffset( height, initialHeightPercentage || minimumHeightPercentage ); setAnimatedToValue(initialOffset); } }, [height, initialHeightPercentage, minimumHeightPercentage]); useEffect(() => { if (height > 0) { pan.setValue(height); offset.current = height; baseHeightForMeasure.current = height; snapPointsData.current = calculateSnapPointsData( minimumHeightPercentage, height, snapPoints ); } }, [height, minimumHeightPercentage]); useEffect(() => { if (animatedToValue >= 0) { const animation = Animated.timing(pan, { toValue: animatedToValue, useNativeDriver: Platform.OS !== 'web', easing: Easing.inOut(Easing.cubic), }); animation.start(({ finished }) => { if (finished) { if (animatedToValue === 0) { setIsAtMaxHeight(true); onExpanded?.(); } else { setIsAtMaxHeight(false); if ( animatedToValue === getOffset(height, minimumHeightPercentage) ) { onCollapsed?.(); } } } setAnimatedToValue(-1); }); return () => animation.stop(); } }, [ animatedToValue, onExpanded, onCollapsed, height, minimumHeightPercentage, ]); const beginPan = useCallback(() => { pan.stopAnimation(); setIsAtMaxHeight(false); offsetBeforePan.current = offset.current; pan.setOffset(offset.current); pan.setValue(0); }, []); const movePan = useCallback((dy: number) => { // Moving toward top, stop at highest snap point if (offsetBeforePan.current + dy < 0) { pan.setValue(-offsetBeforePan.current); return; } // Moving toward bottom, stop at lowest snap point if ( offsetBeforePan.current + dy > snapPointsData.current?.minHeightOffset ) { pan.setValue( baseHeightForMeasure.current - baseHeightForMeasure.current * (minimumHeightPercentageRef.current / 100) - offsetBeforePan.current ); return; } pan.setValue(dy); }, []); const releasePan = useCallback((dy: number, vy: number) => { pan.flattenOffset(); const offsetAfterPan = offsetBeforePan.current + dy; // Flick or short downward drag: snap to the next lower snap point. if (vy > FLICK_VELOCITY_THRESHOLD || dy > SHORT_DRAG_THRESHOLD_PX) { const lowerPoints = snapPointsData.current.list .filter((p) => p > offsetBeforePan.current) .sort((a, b) => a - b); if (lowerPoints.length > 0) { setAnimatedToValue(lowerPoints[0]); return; } } // Upward flick or drag: project the intended landing position using // velocity, then snap to the nearest higher snap point to that projection. // This allows fast flicks to skip intermediate snap points. if (vy < -FLICK_VELOCITY_THRESHOLD || dy < -SHORT_DRAG_THRESHOLD_PX) { const projected = offsetAfterPan + vy * VELOCITY_PROJECTION_FACTOR; const higherPoints = snapPointsData.current.list .filter((p) => p < offsetBeforePan.current) .sort((a, b) => b - a); if (higherPoints.length > 0) { setAnimatedToValue(calculateAnimatedToValue(projected, higherPoints)); return; } } // Otherwise: snap to nearest. setAnimatedToValue( calculateAnimatedToValue(offsetAfterPan, snapPointsData.current.list) ); }, []); const panResponder = useRef( PanResponder.create({ onMoveShouldSetPanResponder: () => true, onPanResponderGrant: () => beginPan(), onPanResponderMove: (_, gesture) => movePan(gesture.dy), onPanResponderRelease: (_, gesture) => releasePan(gesture.dy, gesture.vy), }) ).current; return { pan, isAtMaxHeight, scrollYRef, onScrollY, beginPan, movePan, releasePan, panHandlers: panResponder.panHandlers, }; }; export default useDragablePan;