import { useCallback, useMemo, useRef, type RefObject } from 'react' import { getGestureHandlerState, isGestureHandlerEnabled } from './gestureState' import type { ScrollBridge } from './types' // threshold in pixels for considering sheet "at top" position // allows for small measurement variations const AT_TOP_THRESHOLD = 5 interface GesturePanConfig { positions: number[] frameSize: number setPosition: (pos: number) => void animateTo: (pos: number, animationOverride?: any) => void stopSpring: () => void scrollBridge: ScrollBridge setIsDragging: (val: boolean) => void getCurrentPosition: () => number resisted: (val: number, minY: number) => number disableDrag?: boolean isShowingInnerSheet?: boolean // set the animated position directly (for smooth dragging) setAnimatedPosition: (val: number) => void // ref to scroll gesture for simultaneousWithExternalGesture scrollGestureRef?: RefObject | null // ref to pause keyboard hide events during gesture (action-sheet pattern) pauseKeyboardHandler?: RefObject } interface GesturePanResult { panGesture: any | null panGestureRef: RefObject gestureHandlerEnabled: boolean } /** * Hook that creates a Gesture.Pan() handler for use with react-native-gesture-handler. * This provides native-quality gesture coordination between Sheet and ScrollView. * * Uses state-based decision pattern (like gorhom/bottom-sheet and react-native-actions-sheet): * - Both pan and scroll gestures run simultaneously * - In onChange, we decide whether to process pan or let scroll handle it * - scrollBridge.setScrollEnabled toggles scroll on/off as needed * * Decision matrix: * 1. Sheet not fully open + swiping up -> pan handles (disable scroll) * 2. Sheet fully open + swiping up -> scroll handles (blockPan) * 3. Sheet not fully open + swiping down -> pan handles (unless scrolled) * 4. Sheet fully open + swiping down + scrollY=0 -> pan handles * 5. Sheet fully open + swiping down + scrollY>0 -> scroll handles (blockPan) * * Returns null for the gesture if RNGH is not available or drag is disabled. */ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult { const { positions, frameSize, setPosition, animateTo, stopSpring, scrollBridge, setIsDragging, getCurrentPosition, resisted, disableDrag, isShowingInnerSheet, setAnimatedPosition, scrollGestureRef, } = config const gestureHandlerEnabled = isGestureHandlerEnabled() const panGestureRef = useRef(null) // use refs for values that need to persist across gesture lifecycle // (useMemo closure variables get reset when gesture is recreated) const gestureStateRef = useRef({ startY: 0, // track last translation when pan was active (for position calculation after handoff) lastPanTranslationY: 0, // accumulated position offset from all pan movements accumulatedOffset: 0, // track previous translation for direction detection (like actions-sheet) prevTranslationY: 0, // track if scroll was engaged (scrollY > 0) at some point scrollEngaged: false, // positions frozen at gesture start — keyboard may dismiss during drag (input blur), // causing positions to revert. Frozen positions ensure stable snap calculation. frozenPositions: [] as number[], frozenMinY: 0, // whether pan gesture actually started (vs just a tap in onBegin) panStarted: false, }) const onStart = useCallback(() => { stopSpring() }, [stopSpring]) const onEnd = useCallback( (closestPoint: number, animationOverride?: any) => { setIsDragging(false) scrollBridge.setParentDragging(false) // re-enable scroll when gesture ends scrollBridge.setScrollEnabled?.(true) setPosition(closestPoint) animateTo(closestPoint, animationOverride) }, [setIsDragging, scrollBridge, setPosition, animateTo] ) const panGesture = useMemo(() => { // don't create gesture if disabled or RNGH not available if (!gestureHandlerEnabled || disableDrag || isShowingInnerSheet || !frameSize) { return null } const { Gesture } = getGestureHandlerState() if (!Gesture) { return null } const minY = positions[0] const gs = gestureStateRef.current // shorthand // simultaneousHandlers pattern from react-native-actions-sheet // both gestures run simultaneously, we use blockPan to decide who handles // console.warn('[RNGH-Pan] CREATING gesture, minY:', minY, 'frameSize:', frameSize) const gesture = Gesture.Pan() .withRef(panGestureRef) // NO manualActivation - let both gestures run via simultaneousHandlers // activeOffsetY: activate pan when user moves 10px vertically (required on Android) .activeOffsetY([-10, 10]) // failOffsetX: cancel pan if user moves 20px horizontally (horizontal scroll takes over) .failOffsetX([-20, 20]) .shouldCancelWhenOutside(false) .onBegin(() => { // onBegin fires on ANY touch (including taps to focus inputs). // We do NOT set isDragging here — that would block keyboard animation. // Instead, isDragging is set in onStart (actual pan gesture recognized). gs.panStarted = false // lightweight: pause keyboard handler to suppress hide events during gesture. // This prevents keyboard flicker if input blurs before onStart. // If this is just a tap, onFinalize un-pauses without setting isDragging. if (config.pauseKeyboardHandler) { config.pauseKeyboardHandler.current = true } // check position at gesture begin - before direction is known const pos = getCurrentPosition() const atTop = pos <= minY + AT_TOP_THRESHOLD const currentScrollY = scrollBridge.y // console.warn('[RNGH-Pan] onBegin', { pos, minY, atTop, currentScrollY }) gs.startY = pos gs.lastPanTranslationY = 0 gs.accumulatedOffset = 0 gs.prevTranslationY = 0 gs.scrollEngaged = currentScrollY > 0 // track if scroll is already engaged // freeze positions at gesture start so keyboard dismiss during drag // doesn't change snap targets mid-gesture gs.frozenPositions = [...positions] gs.frozenMinY = minY // if sheet not at top, DISABLE SCROLL immediately and lock to 0 // this prevents scroll from firing before pan takes over if (!atTop) { scrollBridge.setScrollEnabled?.(false, 0) } }) .onStart(() => { // onStart fires only when pan gesture is recognized (finger moved enough). // Safe to set isDragging here — this won't fire for taps to focus inputs. gs.panStarted = true setIsDragging(true) // console.warn('[RNGH-Pan] onStart', { startY: gs.startY, minY }) scrollBridge.initialPosition = gs.startY onStart() }) .onChange((event: { translationY: number; velocityY: number }) => { const { translationY } = event // determine direction by comparing translations (like react-native-actions-sheet) // this is more reliable than velocity which can be noisy during direction changes const isSwipingDown = gs.prevTranslationY < translationY const deltaY = translationY - gs.prevTranslationY gs.prevTranslationY = translationY const scrollY = scrollBridge.y // track if scroll has been engaged at some point (needed for handoff detection) if (scrollY > 0) { gs.scrollEngaged = true } // calculate current sheet position based on accumulated offset const currentPos = gs.startY + gs.accumulatedOffset const isCurrentlyAtTop = currentPos <= minY + AT_TOP_THRESHOLD const nodeIsScrolling = scrollY > 0 // console.warn('[RNGH-Pan] onChange', { translationY: translationY.toFixed(1), deltaY: deltaY.toFixed(1), currentPos: currentPos.toFixed(1), minY, isCurrentlyAtTop, isSwipingDown, scrollY, scrollEngaged: gs.scrollEngaged }) // decision matrix (from react-native-actions-sheet pattern) // each frame, decide who handles the movement based on current state // // Key insight: we track accumulated offset separately from translation // This allows seamless handoffs between pan and scroll let panHandles = false // BUG #1 FIX: Check if scroll content is actually scrollable // If content doesn't fill the ScrollView, gestures should pass through to sheet const hasScrollableContent = scrollBridge.hasScrollableContent !== false if (!isCurrentlyAtTop) { // sheet not at top position if (isSwipingDown) { // swiping down while sheet not at top // BUG #1 FIX: if content not scrollable, pan always handles // only scroll if scrollY > 0 AND content is scrollable, otherwise pan handles panHandles = !nodeIsScrolling || !hasScrollableContent } else { // swiping up while sheet not at top -> pan drags sheet up panHandles = true } } else { // sheet is at top position if (isSwipingDown) { // swiping down at top if (nodeIsScrolling && hasScrollableContent) { // scroll > 0 and content scrollable, let scroll handle (scroll back towards 0) panHandles = false } else if (gs.scrollEngaged && hasScrollableContent) { // scroll WAS > 0 but now is 0 -> handoff from scroll to pan // pan takes over to drag sheet down // console.warn('[RNGH-Pan] *** HANDOFF FROM SCROLL TO PAN ***') panHandles = true } else { // scroll never engaged OR content not scrollable, just drag sheet down panHandles = true } } else { // swiping up at top // if there's scrollable content, let scroll handle so user can scroll into content // resistance only applies when there's NO scrollable content if (hasScrollableContent) { // content is scrollable - let scroll handle (user wants to scroll down into content) panHandles = false } else { // no scrollable content -> pan handles for resistance effect panHandles = true } } } // console.warn('[RNGH-Pan] decision', { panHandles, isCurrentlyAtTop, isSwipingDown, nodeIsScrolling, scrollEngaged: gs.scrollEngaged, hasScrollableContent, currentPos: currentPos.toFixed(1), minY }) if (panHandles) { // pan handles - disable scroll and move sheet // when swiping down at top after scroll was engaged: lock at current scroll position // (handoff from scroll to pan — preserve scroll offset) // otherwise: always lock scroll to 0 (prevents scroll from firing during sheet drag) const lockTo = isCurrentlyAtTop && isSwipingDown && gs.scrollEngaged ? undefined : 0 scrollBridge.setScrollEnabled?.(false, lockTo) // accumulate the delta for position calculation gs.accumulatedOffset += deltaY const newPosition = resisted(gs.startY + gs.accumulatedOffset, minY) // update position scrollBridge.paneY = newPosition setAnimatedPosition(newPosition) scrollBridge.setParentDragging(newPosition > minY) } else { // scroll handles - enable scroll so it can move freely scrollBridge.setScrollEnabled?.(true) // don't accumulate offset when scroll is handling } }) .onEnd((event: { velocityY: number }) => { const { velocityY } = event const currentPos = gs.startY + gs.accumulatedOffset // console.warn('[RNGH-Pan] onEnd', { velocityY, currentPos, accumulatedOffset: gs.accumulatedOffset, scrollY: scrollBridge.y }) // clear scroll lock scrollBridge.scrollLockY = undefined // use frozen positions from gesture start — keyboard may have dismissed // during drag (input blur), reverting activePositions. Frozen positions // ensure the snap index reflects the user's intent at drag start. // The onEnd callback's animateTo uses latest activePositions for the // actual animation target, so the sheet ends up at the right place. const snapPositions = gs.frozenPositions.length > 0 ? gs.frozenPositions : positions const snapMinY = gs.frozenPositions.length > 0 ? gs.frozenMinY : minY // if sheet is at top and scroll is engaged, just stay at top if (currentPos <= snapMinY + AT_TOP_THRESHOLD && scrollBridge.y > 0) { onEnd(0) return } // snap calculation using frozen positions const velocity = velocityY / 1000 const projectedEnd = currentPos + frameSize * velocity * 0.2 let closestPoint = 0 let minDist = Number.POSITIVE_INFINITY for (let i = 0; i < snapPositions.length; i++) { const pos = snapPositions[i] const dist = Math.abs(projectedEnd - pos) if (dist < minDist) { minDist = dist closestPoint = i } } onEnd(closestPoint) }) .onFinalize(() => { // console.warn('[RNGH-Pan] onFinalize', { panStarted: gs.panStarted }) // clear scroll lock on finalize too (safety) scrollBridge.scrollLockY = undefined if (gs.panStarted) { // real pan gesture — reset isDragging (also un-pauses keyboard handler + flushes) setIsDragging(false) } else { // just a tap (onBegin fired but onStart never did) — un-pause keyboard handler // without setting isDragging (which would block keyboard animation effect) if (config.pauseKeyboardHandler) { config.pauseKeyboardHandler.current = false } } }) .runOnJS(true) // if we have a scroll gesture ref, make pan simultaneous with it // this allows both gestures to run and we decide in onChange who handles it if (scrollGestureRef?.current) { // console.warn('[RNGH-Pan] adding simultaneousWithExternalGesture for scroll') return gesture.simultaneousWithExternalGesture(scrollGestureRef.current) } return gesture }, [ gestureHandlerEnabled, disableDrag, isShowingInnerSheet, frameSize, positions, scrollBridge, getCurrentPosition, resisted, onStart, onEnd, setIsDragging, setAnimatedPosition, ]) return { panGesture, panGestureRef, gestureHandlerEnabled, } }