/** * RecyclerView is a high-performance list component that efficiently renders and recycles list items. * It's designed to handle large lists with optimal memory usage and smooth scrolling. */ import React, { RefObject, useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; import { Animated, I18nManager, NativeScrollEvent, NativeSyntheticEvent, } from "react-native"; import { FlashListRef } from "../FlashListRef"; import { ErrorMessages } from "../errors/ErrorMessages"; import { WarningMessages } from "../errors/WarningMessages"; import { RVDimension } from "./layout-managers/LayoutManager"; import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; import { RecyclerViewContext, RecyclerViewContextProvider, useRecyclerViewContext, } from "./RecyclerViewContextProvider"; import { useLayoutState } from "./hooks/useLayoutState"; import { useRecyclerViewManager } from "./hooks/useRecyclerViewManager"; import { RecyclerViewProps } from "./RecyclerViewProps"; import { useOnListLoad } from "./hooks/useOnLoad"; import { ViewHolderCollection, ViewHolderCollectionRef, } from "./ViewHolderCollection"; import { CompatView } from "./components/CompatView"; import { CompatScroller } from "./components/CompatScroller"; import { useBoundDetection } from "./hooks/useBoundDetection"; import { adjustOffsetForRTL } from "./utils/adjustOffsetForRTL"; import { useSecondaryProps } from "./hooks/useSecondaryProps"; import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle"; import { StickyHeaders, StickyHeaderRef } from "./components/StickyHeaders"; import { ScrollAnchor, ScrollAnchorRef } from "./components/ScrollAnchor"; import { useRecyclerViewController } from "./hooks/useRecyclerViewController"; import { RenderTimeTracker } from "./helpers/RenderTimeTracker"; /** * Main RecyclerView component that handles list rendering, scrolling, and item recycling. * @template T - The type of items in the list */ const RecyclerViewComponent = ( props: RecyclerViewProps, ref: React.Ref> ) => { // Destructure props and initialize refs const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; const [renderTimeTracker] = useState(() => new RenderTimeTracker()); renderTimeTracker.startTracking(); // Sticky header config const stickyHeaderOffset = stickyHeaderConfig?.offset ?? 0; const stickyHeaderUseNativeDriver = stickyHeaderConfig?.useNativeDriver ?? true; const stickyHeaderHideRelatedCell = stickyHeaderConfig?.hideRelatedCell ?? false; // Compute the inverted transform style based on platform and orientation const invertedTransformStyle = inverted ? getInvertedTransformStyle(horizontal) : undefined; // Core refs for managing scroll view, internal view, and child container const scrollViewRef = useRef(null); const internalViewRef = useRef(null); const firstChildViewRef = useRef(null); const containerViewSizeRef = useRef(undefined); const pendingChildIds = useRef>(new Set()).current; // Track scroll position const scrollY = useRef(new Animated.Value(0)).current; // Refs for sticky headers and scroll anchoring const stickyHeaderRef = useRef(null); const scrollAnchorRef = useRef(null); // State for managing layout and render updates const [_, setLayoutTreeId] = useLayoutState(0); const [__, setRenderId] = useState(0); const [currentStickyIndex, setCurrentStickyIndex] = useState(-1); // Map to store refs for each item in the list const refHolder = useMemo( () => new Map>(), [] ); // Initialize core RecyclerView manager and content offset management const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController( recyclerViewManager, ref, scrollViewRef, scrollAnchorRef ); // Initialize view holder collection ref const viewHolderCollectionRef = useRef(null); // Hook to handle list loading useOnListLoad(recyclerViewManager, onLoad); // Hook to detect when scrolling reaches list bounds const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); const isHorizontalRTL = I18nManager.isRTL && horizontal; /** * Initialize the RecyclerView by measuring and setting up the window size * This effect runs when the component mounts or when layout changes */ useLayoutEffect(() => { if (internalViewRef.current && firstChildViewRef.current) { // Measure the outer container size and inner container layout const outerViewSize = measureParentSize(internalViewRef.current); const firstChildViewLayout = measureFirstChildLayout( firstChildViewRef.current, internalViewRef.current ); containerViewSizeRef.current = outerViewSize; // firstChildViewLayout is already relative to the outer container, // so its x/y directly gives the first item offset. const firstItemOffset = horizontal ? firstChildViewLayout.x : firstChildViewLayout.y; // Update the RecyclerView manager with window dimensions recyclerViewManager.updateLayoutParams( { width: horizontal ? outerViewSize.width : firstChildViewLayout.width, height: horizontal ? firstChildViewLayout.height : outerViewSize.height, }, isHorizontalRTL && recyclerViewManager.hasLayout() ? firstItemOffset - recyclerViewManager.getChildContainerDimensions().width : firstItemOffset ); } }); /** * Effect to handle layout updates for list items * This ensures proper positioning and recycling of items */ // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (pendingChildIds.size > 0) { return; } const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { const layout = measureItemLayout( viewHolderRef.current!, recyclerViewManager.tryGetLayout(index) ); // comapre height with stored layout // const storedLayout = recyclerViewManager.getLayout(index); // if ( // storedLayout.height !== layout.height && // storedLayout.isHeightMeasured // ) { // console.log( // "height changed", // index, // layout.height, // storedLayout.height // ); // } return { index, dimensions: layout }; }); const hasExceededMaxRendersWithoutCommit = renderTimeTracker.hasExceededMaxRendersWithoutCommit(); if (hasExceededMaxRendersWithoutCommit) { console.warn(WarningMessages.exceededMaxRendersWithoutCommit); } if ( recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && !hasExceededMaxRendersWithoutCommit ) { // Trigger re-render if layout modifications were made setRenderId((prev) => prev + 1); } else { viewHolderCollectionRef.current?.commitLayout(); applyOffsetCorrection(); } if ( horizontal && recyclerViewManager.hasLayout() && recyclerViewManager.getWindowSize().height > 0 ) { // We want the parent FlashList to continue rendering the next batch of items as soon as height is available. // Waiting for each horizontal list to finish might cause too many setState calls. // This will help avoid "Maximum update depth exceeded" error. parentRecyclerViewContext?.unmarkChildLayoutAsPending(recyclerViewId); } }); /** * Scroll event handler that manages scroll position, velocity, and RTL support */ const onScrollHandler = useCallback( (event: NativeSyntheticEvent) => { if (recyclerViewManager.ignoreScrollEvents) { return; } let scrollOffset = horizontal ? event.nativeEvent.contentOffset.x : event.nativeEvent.contentOffset.y; // Handle RTL (Right-to-Left) layout adjustments if (isHorizontalRTL) { scrollOffset = adjustOffsetForRTL( scrollOffset, event.nativeEvent.contentSize.width, event.nativeEvent.layoutMeasurement.width ); } velocityTracker.computeVelocity( scrollOffset, recyclerViewManager.getAbsoluteLastScrollOffset(), Boolean(horizontal), (velocity, isMomentumEnd) => { if (recyclerViewManager.ignoreScrollEvents) { return; } if (isMomentumEnd) { computeFirstVisibleIndexForOffsetCorrection(); if (!recyclerViewManager.isOffsetProjectionEnabled) { return; } recyclerViewManager.resetVelocityCompute(); } // Update scroll position and trigger re-render if needed if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { setRenderId((prev) => prev + 1); } } ); // Update sticky headers and check bounds stickyHeaderRef.current?.reportScrollEvent(event.nativeEvent); checkBounds(); // Record interaction and compute item visibility // Skip recording interaction during programmatic initial scroll // to respect waitForInteraction in viewability config if (recyclerViewManager.isInitialScrollComplete) { recyclerViewManager.recordInteraction(); } recyclerViewManager.computeItemViewability(); // Call user-provided onScroll handler recyclerViewManager.props.onScroll?.(event); }, [ checkBounds, computeFirstVisibleIndexForOffsetCorrection, horizontal, isHorizontalRTL, recyclerViewManager, velocityTracker, ] ); const parentRecyclerViewContext = useRecyclerViewContext(); const recyclerViewId = useId(); // Create context for child components const recyclerViewContext: RecyclerViewContext = useMemo(() => { return { layout: () => { setLayoutTreeId((prev) => prev + 1); }, getRef: () => { if (recyclerViewManager.isDisposed) { return null; } return handlerMethods; }, getParentRef: () => { return parentRecyclerViewContext?.getRef() ?? null; }, getParentScrollViewRef: () => { return parentRecyclerViewContext?.getScrollViewRef() ?? null; }, getScrollViewRef: () => { return scrollViewRef.current; }, markChildLayoutAsPending: (id: string) => { pendingChildIds.add(id); }, unmarkChildLayoutAsPending: (id: string) => { if (pendingChildIds.has(id)) { pendingChildIds.delete(id); recyclerViewContext.layout(); } }, }; }, [ handlerMethods, parentRecyclerViewContext, pendingChildIds, recyclerViewManager.isDisposed, setLayoutTreeId, ]); /** * Validates that item dimensions match the expected layout */ const validateItemSize = useCallback( (index: number, size: RVDimension) => { const layout = recyclerViewManager.getLayout(index); const width = Math.max( Math.min(layout.width, layout.maxWidth ?? Infinity), layout.minWidth ?? 0 ); const height = Math.max( Math.min(layout.height, layout.maxHeight ?? Infinity), layout.minHeight ?? 0 ); if ( areDimensionsNotEqual(width, size.width) || areDimensionsNotEqual(height, size.height) ) { // console.log( // "invalid size", // index, // width, // size.width, // height, // size.height // ); // TODO: Add a warning for missing useLayoutState recyclerViewContext.layout(); } }, [recyclerViewContext, recyclerViewManager] ); // Get secondary props and components const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props); if ( !recyclerViewManager.getIsFirstLayoutComplete() && recyclerViewManager.getDataLength() > 0 ) { parentRecyclerViewContext?.markChildLayoutAsPending(recyclerViewId); } // Render sticky headers if configured const stickyHeaders = useMemo(() => { if ( data && data.length > 0 && stickyHeaderIndices && stickyHeaderIndices.length > 0 ) { if (horizontal) { throw new Error(ErrorMessages.stickyHeadersNotSupportedForHorizontal); } return ( { if (stickyHeaderHideRelatedCell) { setCurrentStickyIndex(newStickyHeaderIndex); } onChangeStickyIndex?.(newStickyHeaderIndex, currentStickyIndex); }} /> ); } return null; }, [ data, stickyHeaderIndices, stickyHeaderOffset, renderItem, scrollY, horizontal, recyclerViewManager, extraData, currentStickyIndex, onChangeStickyIndex, stickyHeaderHideRelatedCell, inverted, ]); // Set up scroll event handling with animation support for sticky headers const animatedEvent = useMemo(() => { if (stickyHeaders) { return Animated.event( [{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: stickyHeaderUseNativeDriver, listener: onScrollHandler, } ); } return onScrollHandler; }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); const shouldMaintainVisibleContentPosition = recyclerViewManager.shouldMaintainVisibleContentPosition(); const maintainVisibleContentPositionInternal = useMemo(() => { if (shouldMaintainVisibleContentPosition) { return { ...maintainVisibleContentPosition, minIndexForVisible: 0, }; } return undefined; }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]); const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 && (maintainVisibleContentPosition?.startRenderingFromBottom ?? false); // Create view for measuring bounded size const viewToMeasureBoundedSize = useMemo(() => { return ( ); }, [horizontal, stickyHeaderOffset]); const scrollAnchor = useMemo(() => { if (shouldMaintainVisibleContentPosition) { return ( ); } return null; }, [horizontal, shouldMaintainVisibleContentPosition]); // console.log("render", recyclerViewManager.getRenderStack()); // Render the main RecyclerView structure return ( { if ( areDimensionsNotEqual( event.nativeEvent.layout.width, containerViewSizeRef.current?.width ?? 0 ) || areDimensionsNotEqual( event.nativeEvent.layout.height, containerViewSizeRef.current?.height ?? 0 ) ) { // console.log( // "onLayout", // recyclerViewManager.getWindowSize(), // event.nativeEvent.layout // ); recyclerViewContext.layout(); } }} > {/* Scroll anchor for maintaining content position */} {scrollAnchor} {isHorizontalRTL && viewToMeasureBoundedSize} {renderHeader} {!isHorizontalRTL && viewToMeasureBoundedSize} {/* Main list content */} recyclerViewManager.getLayout(index)} getAdjustmentMargin={() => { if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) { return 0; } const windowSize = horizontal ? recyclerViewManager.getWindowSize().width : recyclerViewManager.getWindowSize().height; const childContainerSize = horizontal ? recyclerViewManager.getChildContainerDimensions().width : recyclerViewManager.getChildContainerDimensions().height; return Math.max( 0, windowSize - childContainerSize - recyclerViewManager.firstItemOffset ); }} refHolder={refHolder} onSizeChanged={validateItemSize} renderItem={renderItem} extraData={extraData} onCommitLayoutEffect={() => { applyInitialScrollIndex(); parentRecyclerViewContext?.unmarkChildLayoutAsPending( recyclerViewId ); onCommitLayoutEffect?.(); }} onCommitEffect={() => { renderTimeTracker.markRenderComplete(); recyclerViewManager.updateAverageRenderTime( renderTimeTracker.getAverageRenderTime() ); applyInitialScrollIndex(); checkBounds(); recyclerViewManager.computeItemViewability(); recyclerViewManager.animationOptimizationsEnabled = false; }} CellRendererComponent={CellRendererComponent} ItemSeparatorComponent={ItemSeparatorComponent} isInLastRow={(index) => recyclerViewManager.isInLastRow(index)} getChildContainerLayout={() => recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions() : undefined } currentStickyIndex={currentStickyIndex} hideStickyHeaderRelatedCell={stickyHeaderHideRelatedCell} inverted={inverted} /> {renderEmpty} {renderFooter} {stickyHeaderIndices && stickyHeaderIndices.length > 0 ? renderStickyHeaderBackdrop : null} {stickyHeaders} ); }; // Set displayName for the inner component RecyclerViewComponent.displayName = "FlashList"; // Type definition for the RecyclerView component type RecyclerViewType = ( props: RecyclerViewProps & { ref?: React.Ref> } ) => React.JSX.Element; // Create and export the memoized, forwarded ref component const RecyclerView = React.memo( forwardRef(RecyclerViewComponent) ) as RecyclerViewType; export { RecyclerView };