import { forwardRef, memo, useCallback, useRef } from 'react'; import { makeStyles } from '@cleartrip/ct-design-style-manager'; import useMergeRefs from '@cleartrip/ct-design-use-merge-refs'; import { ScrollContextProvider, ScrollableObservableChild } from '@cleartrip/ct-design-scroll-container'; import { FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, Pressable, Text, View, ViewToken, } from 'react-native'; import Animated, { Extrapolation, interpolate, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; const AnimatedPressable = Animated.createAnimatedComponent(Pressable); const BACK_TO_TOP_ANIM_DURATION = 200; import { IListItemRendererProps, IVariableSizedListProps, ListRef } from './type'; import { ListItemProvider } from './ListItemContext'; import { areSetsEqual } from './utils'; const BACK_TO_TOP_SHOW_THRESHOLD = 200; const SCROLL_DELTA_TOLERANCE = 4; const staticStyles = makeStyles(() => ({ itemSeparator: { height: 24, }, backToTopChip: { position: 'absolute', top: 16, alignSelf: 'center', backgroundColor: '#1A1A1A', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999, flexDirection: 'row', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4, }, backToTopText: { color: '#FFFFFF', fontSize: 14, fontWeight: '500', }, })); /** * Memoised row shell — keeps individual row renders isolated from the * outer FlatList re-render cycle. */ const ItemRenderer = memo( ({ item, index, RenderItem, itemData, isVisible, }: { item: T; index: number; RenderItem: React.FC>; itemData: U; isVisible: boolean; }) => { return ; }, ); ItemRenderer.displayName = 'ItemRenderer'; /** * Native `List` * * Mapping from web (react-window) → native (FlatList): * - `minimumBatchSize` → `initialNumToRender` (first-page size) * - `threshold` → `onEndReachedThreshold` * - `itemSize` → used at row layout time * - `outerElementType` → `ListFooterComponent` * - `overScanCount` → contributes to `maxToRenderPerBatch` * - `onItemsRendered` → split into two `viewabilityConfigCallbackPairs`: * one for visible-card impression accounting, one for 80% impression gating. */ const List = memo( forwardRef( ( { loadMoreItems, data, minimumBatchSize, threshold, itemData, RenderItem, outerElementType, itemKey, overScanCount, windowSize = 21, onScroll, style, ItemSeparatorComponent = () => , keyboardShouldPersistTaps = 'handled', }: IVariableSizedListProps, forwardedRef: React.Ref, ) => { const _ref = useRef>(null); const mergedRef = useMergeRefs(_ref, forwardedRef); const scrollMaintainingRef = useRef(false); const observableChildRefs = useRef>([]); const isScrollToScrollingRef = useRef(false); const prevScrollYRef = useRef(0); const showTargetRef = useRef(0); const showProgress = useSharedValue(0); const chipAnimatedStyle = useAnimatedStyle(() => { const v = showProgress.value; return { opacity: v, transform: [{ translateY: interpolate(v, [0, 1], [-12, 0], Extrapolation.CLAMP) }], pointerEvents: v > 0.5 ? 'auto' : 'none', }; }); const setShowTarget = (next: 0 | 1) => { if (showTargetRef.current === next) return; showTargetRef.current = next; showProgress.value = withTiming(next, { duration: BACK_TO_TOP_ANIM_DURATION }); }; const handleBackToTopPress = useCallback(() => { _ref.current?.scrollToOffset({ offset: 0, animated: true }); if (showTargetRef.current !== 0) { showTargetRef.current = 0; showProgress.value = withTiming(0, { duration: BACK_TO_TOP_ANIM_DURATION }); } // showProgress / showTargetRef are stable refs; safe with [] deps // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { const currentY = e.nativeEvent.contentOffset.y; const prevY = prevScrollYRef.current; if (currentY <= BACK_TO_TOP_SHOW_THRESHOLD) { setShowTarget(0); } else if (prevY - currentY > SCROLL_DELTA_TOLERANCE) { setShowTarget(1); } else if (currentY - prevY > SCROLL_DELTA_TOLERANCE) { setShowTarget(0); } prevScrollYRef.current = currentY; onScroll?.(e); scrollMaintainingRef.current = true; }, [onScroll], ); const viewableIndicesRef = useRef>(new Set()); const viewableItemsRef = useRef>>({}); const initialRenderRef = useRef(false); /** * @description Collect viewable indices whose impression has not yet been * fired for the current visibility pass. */ const getNonTriggeredViewableItems = useCallback(() => { return new Set( Object.keys(viewableItemsRef.current) .filter((key) => { return !viewableItemsRef.current[key].triggered; }) .map((key) => { return parseInt(key); }), ); }, []); /** * @description Flip the `triggered` flag to true across every currently * tracked viewable item so we don't re-fire impressions until visibility * changes again. */ const handleViewableItemsUpdate = useCallback(() => { const updatedViewableItems = Object.keys(viewableItemsRef.current).reduce< Record> >((acc, curr) => { acc[curr] = { triggered: true, }; return acc; }, {}); viewableItemsRef.current = updatedViewableItems; }, []); const renderItem = useCallback( ({ item: row, index: rowIndex }: ListRenderItemInfo) => { return ( ); }, // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const getVisibleCards = (prevIndicesMap: Set) => { if (!areSetsEqual(prevIndicesMap, viewableIndicesRef.current)) { return viewableIndicesRef.current; } return prevIndicesMap; }; const onViewableItemsChanged = useCallback((props: { changed: ViewToken[]; viewableItems: ViewToken[] }) => { let { viewableItems } = props; const { changed } = props; // Only keep the first item if multiple are viewable (rare with the // 80% threshold used below for impressions, but common for the // 60% gating threshold). if (viewableItems.length > 1) { const firstViewable = viewableItems.find((item) => item.isViewable); viewableItems = firstViewable ? [firstViewable] : []; } const viewableItemsMap = new Set( viewableItems.map((item) => item.index).filter((index): index is number => index !== null), ); let changedList = false; changed.forEach(({ index, isViewable }) => { if (index !== null) { if (!isViewable) { viewableItemsMap.delete(index); changedList = true; } } }); if (changedList) { observableChildRefs.current.forEach((child) => { child.observe(getVisibleCards(viewableItemsMap), true); }); } viewableIndicesRef.current = viewableItemsMap; }, []); const impressionsOnViewableItemsChanged = useCallback( (props: { changed: ViewToken[]; viewableItems: ViewToken[] }) => { const { viewableItems } = props; const { changed } = props; viewableItems.forEach((item) => { if (item.isViewable && item.index !== null && !viewableItemsRef.current.hasOwnProperty(item.index)) { viewableItemsRef.current[item.index] = { triggered: false, }; } }); changed.forEach((item) => { if (!item.isViewable && item.index !== null && viewableItemsRef.current.hasOwnProperty(item.index)) { delete viewableItemsRef.current[item.index]; } }); if (!initialRenderRef.current) { observableChildRefs.current.forEach((child) => { child.observe(getNonTriggeredViewableItems()); }); handleViewableItemsUpdate(); initialRenderRef.current = true; } }, // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const localViewabilityConfig = { waitForInteraction: false, minimumViewTime: 0, itemVisiblePercentThreshold: 60, }; const impressionsViewabilityConfig = { waitForInteraction: false, minimumViewTime: 100, itemVisiblePercentThreshold: 80, }; const viewabilityConfigCallbackPairs = useRef([ { viewabilityConfig: localViewabilityConfig, onViewableItemsChanged }, { viewabilityConfig: impressionsViewabilityConfig, onViewableItemsChanged: impressionsOnViewableItemsChanged, }, ]); return ( { /** * !NOTE: * Drives impression events. Known caveat: video items at the 2nd * carousel index are not yet handled. Revisit if the * impression-on-scroll-end approach here causes issues. */ if (scrollMaintainingRef.current) { setTimeout(() => { observableChildRefs.current.forEach((child) => { child.observe(getNonTriggeredViewableItems()); }); handleViewableItemsUpdate(); }, 200); } scrollMaintainingRef.current = false; }} onMomentumScrollEnd={() => { if (scrollMaintainingRef.current) { scrollMaintainingRef.current = false; setTimeout(() => { observableChildRefs.current.forEach((child) => { child.observe(getNonTriggeredViewableItems()); }); handleViewableItemsUpdate(); }, 200); } }} bounces={false} scrollEventThrottle={1} /> ↑ Back to top ); }, ), ) as unknown as ( props: IVariableSizedListProps & { ref?: React.Ref }, ) => React.JSX.Element; (List as unknown as { displayName: string }).displayName = 'List'; export default List;