import { RefObject, useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { I18nManager } from "react-native"; import { ScrollToOffsetParams, ScrollToIndexParams, ScrollToItemParams, ScrollToEdgeParams, FlashListRef, } from "../../FlashListRef"; import { CompatScroller } from "../components/CompatScroller"; import { RecyclerViewManager } from "../RecyclerViewManager"; import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL"; import { RVLayout } from "../layout-managers/LayoutManager"; import { ScrollAnchorRef } from "../components/ScrollAnchor"; import { PlatformConfig } from "../../native/config/PlatformHelper"; import { WarningMessages } from "../../errors/WarningMessages"; import { useUnmountFlag } from "./useUnmountFlag"; import { useUnmountAwareTimeout } from "./useUnmountAwareCallbacks"; /** * Comprehensive hook that manages RecyclerView scrolling behavior and provides * imperative methods for controlling the RecyclerView. * * This hook combines content offset management and scroll handling functionality: * 1. Provides imperative methods for scrolling and measurement * 2. Handles initial scroll position when the list first loads * 3. Maintains visible content position during updates * 4. Manages scroll anchors for chat-like applications * * @param recyclerViewManager - The RecyclerViewManager instance that handles core functionality * @param ref - The ref to expose the imperative methods * @param scrollViewRef - Reference to the scrollable container component * @param scrollAnchorRef - Reference to the scroll anchor component * @param props - The RecyclerViewProps containing configuration */ export function useRecyclerViewController( recyclerViewManager: RecyclerViewManager, ref: React.Ref>, scrollViewRef: RefObject, scrollAnchorRef: React.RefObject ) { const isUnmounted = useUnmountFlag(); const [_, setRenderId] = useState(0); const pauseOffsetCorrection = useRef(false); const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); const { setTimeout } = useUnmountAwareTimeout(); // Track the first visible item for maintaining scroll position const firstVisibleItemKey = useRef(undefined); const firstVisibleItemLayout = useRef(undefined); // Queue to store callbacks that should be executed after scroll offset updates const pendingScrollCallbacks = useRef<(() => void)[]>([]); // Handle initial scroll position when the list first loads // useOnLoad(recyclerViewManager, () => { // }); /** * Updates the scroll offset and calls the provided callback * after the update has been applied and the component has re-rendered. * * @param offset - The new scroll offset to apply * @param callback - Optional callback to execute after the update is applied */ const updateScrollOffsetWithCallback = useCallback( (offset: number, callback: () => void): void => { // Attempt to update the scroll offset in the RecyclerViewManager // This returns undefined if no update is needed if (recyclerViewManager.updateScrollOffset(offset) !== undefined) { // It will be executed after the next render pendingScrollCallbacks.current.push(callback); // Trigger a re-render to apply the scroll offset update setRenderId((prev) => prev + 1); } else { // No update needed, execute callback immediately callback(); } }, [recyclerViewManager] ); const computeFirstVisibleIndexForOffsetCorrection = useCallback(() => { if ( recyclerViewManager.getIsFirstLayoutComplete() && recyclerViewManager.hasStableDataKeys() && recyclerViewManager.getDataLength() > 0 && recyclerViewManager.shouldMaintainVisibleContentPosition() ) { // Update the tracked first visible item const firstVisibleIndex = Math.max( 0, recyclerViewManager.computeVisibleIndices().startIndex ); if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) { firstVisibleItemKey.current = recyclerViewManager.getDataKey(firstVisibleIndex); firstVisibleItemLayout.current = { ...recyclerViewManager.getLayout(firstVisibleIndex), }; } } }, [recyclerViewManager]); /** * Maintains the visible content position when the list updates. * This is particularly useful for chat applications where we want to keep * the user's current view position when new messages are added. */ const applyOffsetCorrection = useCallback(() => { const { horizontal, data } = recyclerViewManager.props; // Execute all pending callbacks from previous scroll offset updates // This ensures any scroll operations that were waiting for render are completed const callbacks = pendingScrollCallbacks.current; pendingScrollCallbacks.current = []; callbacks.forEach((callback) => callback()); const currentDataLength = recyclerViewManager.getDataLength(); if ( recyclerViewManager.getIsFirstLayoutComplete() && recyclerViewManager.hasStableDataKeys() && currentDataLength > 0 && recyclerViewManager.shouldMaintainVisibleContentPosition() ) { const hasDataChanged = currentDataLength !== lastDataLengthRef.current; // If we have a tracked first visible item, maintain its position if (firstVisibleItemKey.current) { // Try engaged indices first (fast O(n) over rendered items), then // fall back to a full data search. The fallback is needed when: // 1. Data just changed (hasDataChanged) — prepended items shift // the anchor to a new index not yet in the engaged window. // 2. A previous correction is still settling (ignoreScrollEvents // is true) — after the first correction pass, unmeasured item // heights may converge and push the anchor item outside the // engaged window. ignoreScrollEvents stays true for 100ms // after each correction, giving subsequent layout passes a // chance to refine the scroll position. const currentIndexOfFirstVisibleItem = recyclerViewManager .getEngagedIndices() .findValue( (index) => recyclerViewManager.getDataKey(index) === firstVisibleItemKey.current ) ?? (hasDataChanged || recyclerViewManager.ignoreScrollEvents ? data?.findIndex( (item, index) => recyclerViewManager.getDataKey(index) === firstVisibleItemKey.current ) : undefined); if ( currentIndexOfFirstVisibleItem !== undefined && currentIndexOfFirstVisibleItem >= 0 ) { // Calculate the difference in position and apply the offset const diff = horizontal ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - firstVisibleItemLayout.current!.x : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - firstVisibleItemLayout.current!.y; firstVisibleItemLayout.current = { ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), }; if ( diff !== 0 && !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled ) { // console.log("diff", diff, firstVisibleItemKey.current); if (PlatformConfig.supportsOffsetCorrection) { // console.log("scrollBy", diff); scrollAnchorRef.current?.scrollBy(diff); } else { const scrollToParams = horizontal ? { x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, animated: false, } : { y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, animated: false, }; scrollViewRef.current?.scrollTo(scrollToParams); } if (hasDataChanged) { updateScrollOffsetWithCallback( recyclerViewManager.getAbsoluteLastScrollOffset() + diff, () => {} ); recyclerViewManager.ignoreScrollEvents = true; setTimeout(() => { recyclerViewManager.ignoreScrollEvents = false; }, 100); } } } } computeFirstVisibleIndexForOffsetCorrection(); } lastDataLengthRef.current = recyclerViewManager.getDataLength(); }, [ recyclerViewManager, scrollAnchorRef, scrollViewRef, setTimeout, updateScrollOffsetWithCallback, computeFirstVisibleIndexForOffsetCorrection, ]); const handlerMethods: FlashListRef = useMemo(() => { return { get props() { return recyclerViewManager.props; }, /** * Scrolls the list to a specific offset position. * Handles RTL layouts and first item offset adjustments. */ scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }: ScrollToOffsetParams) => { const { horizontal } = recyclerViewManager.props; if (scrollViewRef.current) { // Adjust offset for RTL layouts in horizontal mode if (I18nManager.isRTL && horizontal) { // eslint-disable-next-line no-param-reassign offset = adjustOffsetForRTL( offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width ) + (skipFirstItemOffset ? recyclerViewManager.firstItemOffset : -recyclerViewManager.firstItemOffset); } // Calculate the final offset including first item offset if needed const adjustedOffset = offset + (skipFirstItemOffset ? 0 : recyclerViewManager.firstItemOffset); const scrollTo = horizontal ? { x: adjustedOffset, y: 0 } : { x: 0, y: adjustedOffset }; scrollViewRef.current.scrollTo({ ...scrollTo, animated, }); } }, clearLayoutCacheOnUpdate: () => { recyclerViewManager.markLayoutManagerDirty(); }, // Expose native scroll view methods flashScrollIndicators: () => { scrollViewRef.current!.flashScrollIndicators(); }, getNativeScrollRef: () => { return scrollViewRef.current; }, getScrollResponder: () => { return scrollViewRef.current!.getScrollResponder(); }, getScrollableNode: () => { return scrollViewRef.current!.getScrollableNode(); }, /** * Scrolls to the end of the list. */ scrollToEnd: async ({ animated }: ScrollToEdgeParams = {}) => { const { data } = recyclerViewManager.props; if (data && data.length > 0) { const lastIndex = data.length - 1; if (!recyclerViewManager.getEngagedIndices().includes(lastIndex)) { await handlerMethods.scrollToIndex({ index: lastIndex, animated, }); } } setTimeout(() => { scrollViewRef.current?.scrollToEnd({ animated }); }, 0); }, /** * Scrolls to the beginning of the list. */ scrollToTop: ({ animated }: ScrollToEdgeParams = {}) => { handlerMethods.scrollToOffset({ offset: 0, animated, }); }, /** * Scrolls to a specific index in the list. * Supports viewPosition and viewOffset for precise positioning. * Returns a Promise that resolves when the scroll is complete. */ scrollToIndex: ({ index, animated, viewPosition, viewOffset, }: ScrollToIndexParams): Promise => { return new Promise((resolve) => { const { horizontal } = recyclerViewManager.props; if ( scrollViewRef.current && index >= 0 && index < recyclerViewManager.getDataLength() ) { // Pause the scroll offset adjustments pauseOffsetCorrection.current = true; recyclerViewManager.setOffsetProjectionEnabled(false); const getFinalOffset = () => { const layout = recyclerViewManager.getLayout(index); const offset = horizontal ? layout.x : layout.y; let finalOffset = offset; // take viewPosition etc into account if (viewPosition !== undefined || viewOffset !== undefined) { const containerSize = horizontal ? recyclerViewManager.getWindowSize().width : recyclerViewManager.getWindowSize().height; const itemSize = horizontal ? layout.width : layout.height; if (viewPosition !== undefined) { // viewPosition: 0 = top, 0.5 = center, 1 = bottom finalOffset = offset - (containerSize - itemSize) * viewPosition; } if (viewOffset !== undefined) { finalOffset += viewOffset; } } return finalOffset + recyclerViewManager.firstItemOffset; }; const lastAbsoluteScrollOffset = recyclerViewManager.getAbsoluteLastScrollOffset(); const bufferForScroll = horizontal ? recyclerViewManager.getWindowSize().width : recyclerViewManager.getWindowSize().height; const bufferForCompute = bufferForScroll * 2; const getStartScrollOffset = () => { let lastScrollOffset = lastAbsoluteScrollOffset; const finalOffset = getFinalOffset(); if (finalOffset > lastScrollOffset) { lastScrollOffset = Math.max( finalOffset - bufferForCompute, lastScrollOffset ); recyclerViewManager.setScrollDirection("forward"); } else { lastScrollOffset = Math.min( finalOffset + bufferForCompute, lastScrollOffset ); recyclerViewManager.setScrollDirection("backward"); } return lastScrollOffset; }; let initialTargetOffset = getFinalOffset(); let initialStartScrollOffset = getStartScrollOffset(); let finalOffset = initialTargetOffset; let startScrollOffset = initialStartScrollOffset; const steps = 5; /** * Recursively performs the scroll animation steps. * This function replaces the async/await loop with callback-based execution. * * @param currentStep - The current step in the animation (0 to steps-1) */ const performScrollStep = (currentStep: number) => { // Check if component is unmounted or we've completed all steps if (isUnmounted.current) { resolve(); return; } else if (currentStep >= steps) { // All steps completed, perform final scroll finishScrollToIndex(); return; } // Calculate the offset for this step // For animated scrolls: interpolate from finalOffset to startScrollOffset // For non-animated: interpolate from startScrollOffset to finalOffset const nextOffset = animated ? finalOffset + (startScrollOffset - finalOffset) * (currentStep / (steps - 1)) : startScrollOffset + (finalOffset - startScrollOffset) * (currentStep / (steps - 1)); // Update scroll offset with a callback to continue to the next step updateScrollOffsetWithCallback(nextOffset, () => { // Check if the index is still valid after the update if (index >= recyclerViewManager.getDataLength()) { // Index out of bounds, scroll to end instead handlerMethods.scrollToEnd({ animated }); resolve(); // Resolve the promise as we're done return; } // Check if the target position has changed significantly const newFinalOffset = getFinalOffset(); if ( (newFinalOffset < initialTargetOffset && newFinalOffset < initialStartScrollOffset) || (newFinalOffset > initialTargetOffset && newFinalOffset > initialStartScrollOffset) ) { // Target has moved, recalculate and restart from beginning finalOffset = newFinalOffset; startScrollOffset = getStartScrollOffset(); initialTargetOffset = newFinalOffset; initialStartScrollOffset = startScrollOffset; performScrollStep(0); // Restart from step 0 } else { // Continue to next step performScrollStep(currentStep + 1); } }); }; /** * Completes the scroll to index operation by performing the final scroll * and re-enabling offset correction after a delay. */ const finishScrollToIndex = () => { finalOffset = getFinalOffset(); const maxOffset = recyclerViewManager.getMaxScrollOffset(); if (finalOffset > maxOffset) { finalOffset = maxOffset; } if (animated) { // For animated scrolls, first jump to the start position // We don't need to add firstItemOffset here as it's already added handlerMethods.scrollToOffset({ offset: startScrollOffset, animated: false, skipFirstItemOffset: true, }); } // Perform the final scroll to the target position handlerMethods.scrollToOffset({ offset: finalOffset, animated, skipFirstItemOffset: true, }); // Re-enable offset correction after a delay // Longer delay for animated scrolls to allow animation to complete setTimeout( () => { pauseOffsetCorrection.current = false; recyclerViewManager.setOffsetProjectionEnabled(true); resolve(); // Resolve the promise after re-enabling corrections }, animated ? 300 : 200 ); }; // Start the scroll animation process performScrollStep(0); } else { // Invalid parameters, resolve immediately resolve(); } }); }, /** * Scrolls to a specific item in the list. * Finds the item's index and uses scrollToIndex internally. */ scrollToItem: ({ item, animated, viewPosition, viewOffset, }: ScrollToItemParams) => { const { data } = recyclerViewManager.props; if (scrollViewRef.current && data) { // Find the index of the item in the data array const index = data.findIndex((dataItem) => dataItem === item); if (index >= 0) { handlerMethods.scrollToIndex({ index, animated, viewPosition, viewOffset, }); } } }, // Utility methods for measuring header height / top padding getFirstItemOffset: () => { return recyclerViewManager.firstItemOffset; }, getWindowSize: () => { return recyclerViewManager.getWindowSize(); }, getLayout: (index: number) => { return recyclerViewManager.tryGetLayout(index); }, getAbsoluteLastScrollOffset: () => { return recyclerViewManager.getAbsoluteLastScrollOffset(); }, getChildContainerDimensions: () => { return recyclerViewManager.getChildContainerDimensions(); }, recordInteraction: () => { recyclerViewManager.recordInteraction(); }, computeVisibleIndices: () => { return recyclerViewManager.computeVisibleIndices(); }, getFirstVisibleIndex: () => { return recyclerViewManager.computeVisibleIndices().startIndex; }, recomputeViewableItems: () => { recyclerViewManager.recomputeViewableItems(); }, /** * Disables item recycling in preparation for layout animations. */ prepareForLayoutAnimationRender: () => { if (!recyclerViewManager.props.keyExtractor) { console.warn(WarningMessages.keyExtractorNotDefinedForAnimation); } recyclerViewManager.animationOptimizationsEnabled = true; }, }; }, [ recyclerViewManager, scrollViewRef, setTimeout, isUnmounted, updateScrollOffsetWithCallback, ]); const applyInitialScrollIndex = useCallback(() => { const { horizontal, data, initialScrollIndexParams } = recyclerViewManager.props; const initialScrollIndex = recyclerViewManager.getInitialScrollIndex() ?? -1; const dataLength = data?.length ?? 0; if ( initialScrollIndex >= 0 && initialScrollIndex < dataLength && !recyclerViewManager.isInitialScrollComplete && recyclerViewManager.getIsFirstLayoutComplete() ) { // Use setTimeout to ensure that we keep trying to scroll on first few renders setTimeout(() => { recyclerViewManager.isInitialScrollComplete = true; pauseOffsetCorrection.current = false; }, 100); pauseOffsetCorrection.current = true; const additionalOffset = initialScrollIndexParams?.viewOffset ?? 0; const offset = horizontal ? recyclerViewManager.getLayout(initialScrollIndex).x + additionalOffset : recyclerViewManager.getLayout(initialScrollIndex).y + additionalOffset; handlerMethods.scrollToOffset({ offset, animated: false, skipFirstItemOffset: false, }); setTimeout(() => { handlerMethods.scrollToOffset({ offset, animated: false, skipFirstItemOffset: false, }); }, 0); } }, [handlerMethods, recyclerViewManager, setTimeout]); // Expose imperative methods through the ref useImperativeHandle( ref, () => { const imperativeApi = { ...scrollViewRef.current, ...handlerMethods }; // Without this the props getter from handlerMethods is evaluated during spread and // future updates to props are not reflected in the ref Object.defineProperty(imperativeApi, "props", { get() { return recyclerViewManager.props; }, enumerable: true, configurable: true, }); return imperativeApi; }, [handlerMethods, scrollViewRef, recyclerViewManager] ); return { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, }; }