UNPKG

@shopify/flash-list

Version:

FlashList is a more performant FlatList replacement

177 lines (148 loc) 6.48 kB
import { useCallback, useEffect, useMemo, useRef } from "react"; import { RecyclerViewManager } from "../RecyclerViewManager"; import { CompatScroller } from "../components/CompatScroller"; import { useUnmountAwareAnimationFrame } from "./useUnmountAwareCallbacks"; /** * Hook to detect when the scroll position reaches near the start or end of the list * and trigger the appropriate callbacks. This hook is responsible for: * 1. Detecting when the user scrolls near the end of the list (onEndReached) * 2. Detecting when the user scrolls near the start of the list (onStartReached) * 3. Managing auto-scrolling to bottom when new content is added * * @param recyclerViewManager - The RecyclerViewManager instance that handles the list's core functionality * @param props - The RecyclerViewProps containing configuration and callbacks * @param scrollViewRef - Reference to the scrollable container component */ export function useBoundDetection<T>( recyclerViewManager: RecyclerViewManager<T>, scrollViewRef: React.RefObject<CompatScroller> ) { // Track whether we've already triggered the end reached callback to prevent duplicate calls const pendingEndReached = useRef(false); // Track whether we've already triggered the start reached callback to prevent duplicate calls const pendingStartReached = useRef(false); // Track whether we should auto-scroll to bottom when new content is added const pendingAutoscrollToBottom = useRef(false); const lastCheckBoundsTime = useRef(Date.now()); const { data } = recyclerViewManager.props; const { requestAnimationFrame } = useUnmountAwareAnimationFrame(); const windowHeight = recyclerViewManager.hasLayout() ? recyclerViewManager.getWindowSize().height : 0; const contentHeight = recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions().height : 0; const windowWidth = recyclerViewManager.hasLayout() ? recyclerViewManager.getWindowSize().width : 0; const contentWidth = recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions().width : 0; /** * Checks if the scroll position is near the start or end of the list * and triggers appropriate callbacks if configured. */ const checkBounds = useCallback(() => { lastCheckBoundsTime.current = Date.now(); const { onEndReached, onStartReached, maintainVisibleContentPosition, horizontal, onEndReachedThreshold: onEndReachedThresholdProp, onStartReachedThreshold: onStartReachedThresholdProp, } = recyclerViewManager.props; // Skip all calculations if neither callback is provided and autoscroll is disabled const autoscrollToBottomThreshold = maintainVisibleContentPosition?.autoscrollToBottomThreshold ?? -1; if (!onEndReached && !onStartReached && autoscrollToBottomThreshold < 0) { return; } if (recyclerViewManager.getIsFirstLayoutComplete()) { const lastScrollOffset = recyclerViewManager.getAbsoluteLastScrollOffset(); const contentSize = recyclerViewManager.getChildContainerDimensions(); const windowSize = recyclerViewManager.getWindowSize(); const isHorizontal = horizontal === true; // Calculate dimensions based on scroll direction const visibleLength = isHorizontal ? windowSize.width : windowSize.height; const contentLength = (isHorizontal ? contentSize.width : contentSize.height) + recyclerViewManager.firstItemOffset; // Check if we're near the end of the list if (onEndReached) { const onEndReachedThreshold = onEndReachedThresholdProp ?? 0.5; const endThresholdDistance = onEndReachedThreshold * visibleLength; const isNearEnd = Math.ceil(lastScrollOffset + visibleLength) >= contentLength - endThresholdDistance; if (isNearEnd && !pendingEndReached.current) { pendingEndReached.current = true; onEndReached(); } pendingEndReached.current = isNearEnd; } // Check if we're near the start of the list if (onStartReached) { const onStartReachedThreshold = onStartReachedThresholdProp ?? 0.2; const startThresholdDistance = onStartReachedThreshold * visibleLength; const isNearStart = lastScrollOffset <= startThresholdDistance; if (isNearStart && !pendingStartReached.current) { pendingStartReached.current = true; onStartReached(); } pendingStartReached.current = isNearStart; } // Handle auto-scrolling to bottom for vertical lists if (!isHorizontal && autoscrollToBottomThreshold >= 0) { const autoscrollToBottomThresholdDistance = autoscrollToBottomThreshold * visibleLength; const isNearBottom = Math.ceil(lastScrollOffset + visibleLength) >= contentLength - autoscrollToBottomThresholdDistance; if (isNearBottom) { pendingAutoscrollToBottom.current = true; } else { pendingAutoscrollToBottom.current = false; } } } }, [recyclerViewManager]); const runAutoScrollToBottomCheck = useCallback(() => { if (pendingAutoscrollToBottom.current) { pendingAutoscrollToBottom.current = false; requestAnimationFrame(() => { const shouldAnimate = recyclerViewManager.props.maintainVisibleContentPosition ?.animateAutoScrollToBottom ?? true; scrollViewRef.current?.scrollToEnd({ animated: shouldAnimate, }); }); } }, [requestAnimationFrame, scrollViewRef, recyclerViewManager]); // Reset end reached state when data changes useMemo(() => { pendingEndReached.current = false; // needs to run only when data changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); // Auto-scroll to bottom when new content is added and we're near the bottom useEffect(() => { runAutoScrollToBottomCheck(); }, [data, runAutoScrollToBottomCheck, windowHeight, windowWidth]); // Since content changes frequently, we try and avoid doing the auto scroll during active scrolls useEffect(() => { if (Date.now() - lastCheckBoundsTime.current >= 100) { runAutoScrollToBottomCheck(); } }, [ contentHeight, contentWidth, recyclerViewManager.firstItemOffset, runAutoScrollToBottomCheck, ]); return { checkBounds, }; }