/** * StickyHeaders component manages the sticky header behavior in a FlashList. * It handles the animation and positioning of headers that should remain fixed * at the top of the list while scrolling. */ import React, { useRef, useState, useMemo, useImperativeHandle, useCallback, useEffect, } from "react"; import { Animated, NativeScrollEvent } from "react-native"; import { FlashListProps } from "../.."; import { PlatformConfig } from "../../native/config/PlatformHelper"; import { RecyclerViewManager } from "../RecyclerViewManager"; import { ViewHolder } from "../ViewHolder"; import { CompatAnimatedView } from "./CompatView"; /** * Props for the StickyHeaders component * @template TItem - The type of items in the list */ export interface StickyHeaderProps { /** Array of indices that should have sticky headers */ stickyHeaderIndices: number[]; /** Offset from the top where sticky headers should stick (in pixels) */ stickyHeaderOffset: number; /** Sticky header change handler */ onChangeStickyIndex: (index: number) => void; /** The data array being rendered */ data: ReadonlyArray; /** Animated value tracking scroll position */ scrollY: Animated.Value; /** Function to render each item */ renderItem: FlashListProps["renderItem"]; /** Ref to access sticky header methods */ stickyHeaderRef: React.RefObject; /** Manager for recycler view operations */ recyclerViewManager: RecyclerViewManager; /** Additional data to trigger re-renders */ extraData: FlashListProps["extraData"]; /** Whether the list is inverted */ inverted: FlashListProps["inverted"]; } /** * Ref interface for StickyHeaders component */ export interface StickyHeaderRef { /** Reports scroll events to update sticky header positions */ reportScrollEvent: (event: NativeScrollEvent) => void; } interface StickyHeaderState { currentStickyIndex: number; pushStartsAt: number; } export const StickyHeaders = ({ stickyHeaderIndices, stickyHeaderOffset, renderItem, stickyHeaderRef, recyclerViewManager, scrollY, data, extraData, onChangeStickyIndex, inverted, }: StickyHeaderProps) => { const [stickyHeaderState, setStickyHeaderState] = useState( { currentStickyIndex: -1, pushStartsAt: Number.MAX_SAFE_INTEGER, } ); const { currentStickyIndex, pushStartsAt } = stickyHeaderState; // sort indices and memoize compute const sortedIndices = useMemo(() => { return [...stickyHeaderIndices].sort((first, second) => first - second); }, [stickyHeaderIndices]); const legthInvalid = sortedIndices.length === 0 || recyclerViewManager.getDataLength() <= sortedIndices[sortedIndices.length - 1]; const compute = useCallback(() => { if (legthInvalid) { return; } const adjustedScrollOffset = recyclerViewManager.getLastScrollOffset(); // Binary search for current sticky index const currentIndexInArray = findCurrentStickyIndex( sortedIndices, adjustedScrollOffset + stickyHeaderOffset, (index) => recyclerViewManager.getLayout(index).y ); const newStickyIndex = sortedIndices[currentIndexInArray] ?? -1; let newNextStickyIndex = sortedIndices[currentIndexInArray + 1] ?? -1; if (newNextStickyIndex > recyclerViewManager.getEngagedIndices().endIndex) { newNextStickyIndex = -1; } // Calculate when the next sticky header should start pushing the current one // The next header starts pushing when it reaches the bottom of the current sticky header const newNextStickyY = newNextStickyIndex === -1 ? Number.MAX_SAFE_INTEGER : (recyclerViewManager.tryGetLayout(newNextStickyIndex)?.y ?? 0) + recyclerViewManager.firstItemOffset; const newCurrentStickyHeight = recyclerViewManager.tryGetLayout(newStickyIndex)?.height ?? 0; // Push should start when the next header reaches the bottom of the current sticky header const newPushStartsAt = newNextStickyY - newCurrentStickyHeight; if ( newStickyIndex !== currentStickyIndex || newPushStartsAt !== pushStartsAt ) { setStickyHeaderState({ currentStickyIndex: newStickyIndex, pushStartsAt: newPushStartsAt - stickyHeaderOffset, }); } if (newStickyIndex !== currentStickyIndex) { onChangeStickyIndex?.(newStickyIndex); } }, [ legthInvalid, recyclerViewManager, sortedIndices, currentStickyIndex, pushStartsAt, onChangeStickyIndex, stickyHeaderOffset, ]); useEffect(() => { compute(); }, [compute]); // Optimized scroll handler using binary search pattern useImperativeHandle( stickyHeaderRef, () => ({ reportScrollEvent: () => { compute(); }, }), [compute] ); const refHolder = useRef(new Map()).current; const { translateY, opacity } = useMemo(() => { const currentStickyHeight = recyclerViewManager.tryGetLayout(currentStickyIndex)?.height ?? 0; return { translateY: scrollY.interpolate({ inputRange: [pushStartsAt, pushStartsAt + currentStickyHeight], outputRange: [0, -currentStickyHeight], extrapolate: "clamp", }), opacity: stickyHeaderOffset > 0 ? scrollY.interpolate({ inputRange: [pushStartsAt, pushStartsAt + currentStickyHeight], outputRange: [1, 0], extrapolate: "clamp", }) : undefined, }; }, [ recyclerViewManager, currentStickyIndex, scrollY, pushStartsAt, stickyHeaderOffset, ]); // Memoize header content const headerContent = useMemo(() => { return ( {currentStickyIndex !== -1 && currentStickyIndex < data.length ? ( ); }, [ translateY, opacity, currentStickyIndex, data, renderItem, refHolder, extraData, stickyHeaderOffset, inverted, ]); if (PlatformConfig.isRN083OrAbove && currentStickyIndex === -1) { return null; } return headerContent; }; /** * Binary search utility to find the current sticky header index based on scroll position * @param sortedIndices - Array of indices sorted by Y position * @param adjustedValue - Current scroll position * @param getY - Function to get Y position for an index * @returns Index of the current sticky header */ function findCurrentStickyIndex( sortedIndices: number[], adjustedValue: number, getY: (index: number) => number ): number { let left = 0; let right = sortedIndices.length - 1; let result = -1; while (left <= right) { const mid = Math.floor((left + right) / 2); const currentY = getY(sortedIndices[mid]); if (currentY <= adjustedValue) { result = mid; left = mid + 1; } else { right = mid - 1; } } return result; }