@shopify/flash-list
Version:
FlashList is a more performant FlatList replacement
389 lines • 21.5 kB
JavaScript
/**
* 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, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react";
import { Animated, I18nManager, } from "react-native";
import { ErrorMessages } from "../errors/ErrorMessages";
import { WarningMessages } from "../errors/WarningMessages";
import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout";
import { RecyclerViewContextProvider, useRecyclerViewContext, } from "./RecyclerViewContextProvider";
import { useLayoutState } from "./hooks/useLayoutState";
import { useRecyclerViewManager } from "./hooks/useRecyclerViewManager";
import { useOnListLoad } from "./hooks/useOnLoad";
import { ViewHolderCollection, } from "./ViewHolderCollection";
import { CompatView } from "./components/CompatView";
import { useBoundDetection } from "./hooks/useBoundDetection";
import { adjustOffsetForRTL } from "./utils/adjustOffsetForRTL";
import { useSecondaryProps } from "./hooks/useSecondaryProps";
import { StickyHeaders } from "./components/StickyHeaders";
import { ScrollAnchor } 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, ref) => {
var _a, _b, _c, _d;
// 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, ...rest } = props;
const [renderTimeTracker] = useState(() => new RenderTimeTracker());
renderTimeTracker.startTracking();
// Sticky header config
const stickyHeaderOffset = (_a = stickyHeaderConfig === null || stickyHeaderConfig === void 0 ? void 0 : stickyHeaderConfig.offset) !== null && _a !== void 0 ? _a : 0;
const stickyHeaderUseNativeDriver = (_b = stickyHeaderConfig === null || stickyHeaderConfig === void 0 ? void 0 : stickyHeaderConfig.useNativeDriver) !== null && _b !== void 0 ? _b : true;
const stickyHeaderHideRelatedCell = (_c = stickyHeaderConfig === null || stickyHeaderConfig === void 0 ? void 0 : stickyHeaderConfig.hideRelatedCell) !== null && _c !== void 0 ? _c : false;
// 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 and inner container layouts
const outerViewLayout = measureParentSize(internalViewRef.current);
const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current);
containerViewSizeRef.current = outerViewLayout;
// Calculate offset of first item
const firstItemOffset = horizontal
? firstChildViewLayout.x - outerViewLayout.x
: firstChildViewLayout.y - outerViewLayout.y;
// Update the RecyclerView manager with window dimensions
recyclerViewManager.updateLayoutParams({
width: horizontal
? outerViewLayout.width
: firstChildViewLayout.width,
height: horizontal
? firstChildViewLayout.height
: outerViewLayout.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(() => {
var _a, _b;
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, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) &&
!hasExceededMaxRendersWithoutCommit) {
// Trigger re-render if layout modifications were made
setRenderId((prev) => prev + 1);
}
else {
(_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.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 === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.unmarkChildLayoutAsPending(recyclerViewId);
}
});
/**
* Scroll event handler that manages scroll position, velocity, and RTL support
*/
const onScrollHandler = useCallback((event) => {
var _a, _b, _c;
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
(_a = stickyHeaderRef.current) === null || _a === void 0 ? void 0 : _a.reportScrollEvent(event.nativeEvent);
checkBounds();
// Record interaction and compute item visibility
recyclerViewManager.recordInteraction();
recyclerViewManager.computeItemViewability();
// Call user-provided onScroll handler
(_c = (_b = recyclerViewManager.props).onScroll) === null || _c === void 0 ? void 0 : _c.call(_b, event);
}, [
checkBounds,
computeFirstVisibleIndexForOffsetCorrection,
horizontal,
isHorizontalRTL,
recyclerViewManager,
velocityTracker,
]);
const parentRecyclerViewContext = useRecyclerViewContext();
const recyclerViewId = useId();
// Create context for child components
const recyclerViewContext = useMemo(() => {
return {
layout: () => {
setLayoutTreeId((prev) => prev + 1);
},
getRef: () => {
if (recyclerViewManager.isDisposed) {
return null;
}
return handlerMethods;
},
getParentRef: () => {
var _a;
return (_a = parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.getRef()) !== null && _a !== void 0 ? _a : null;
},
getParentScrollViewRef: () => {
var _a;
return (_a = parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.getScrollViewRef()) !== null && _a !== void 0 ? _a : null;
},
getScrollViewRef: () => {
return scrollViewRef.current;
},
markChildLayoutAsPending: (id) => {
pendingChildIds.add(id);
},
unmarkChildLayoutAsPending: (id) => {
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, size) => {
var _a, _b, _c, _d;
const layout = recyclerViewManager.getLayout(index);
const width = Math.max(Math.min(layout.width, (_a = layout.maxWidth) !== null && _a !== void 0 ? _a : Infinity), (_b = layout.minWidth) !== null && _b !== void 0 ? _b : 0);
const height = Math.max(Math.min(layout.height, (_c = layout.maxHeight) !== null && _c !== void 0 ? _c : Infinity), (_d = layout.minHeight) !== null && _d !== void 0 ? _d : 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 === null || parentRecyclerViewContext === void 0 ? void 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 (React.createElement(StickyHeaders, { stickyHeaderIndices: stickyHeaderIndices, stickyHeaderOffset: stickyHeaderOffset, data: data, renderItem: renderItem, scrollY: scrollY, stickyHeaderRef: stickyHeaderRef, recyclerViewManager: recyclerViewManager, extraData: extraData, onChangeStickyIndex: (newStickyHeaderIndex) => {
if (stickyHeaderHideRelatedCell) {
setCurrentStickyIndex(newStickyHeaderIndex);
}
onChangeStickyIndex === null || onChangeStickyIndex === void 0 ? void 0 : onChangeStickyIndex(newStickyHeaderIndex, currentStickyIndex);
} }));
}
return null;
}, [
data,
stickyHeaderIndices,
stickyHeaderOffset,
renderItem,
scrollY,
horizontal,
recyclerViewManager,
extraData,
currentStickyIndex,
onChangeStickyIndex,
stickyHeaderHideRelatedCell,
]);
// 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 &&
((_d = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _d !== void 0 ? _d : false);
// Create view for measuring bounded size
const viewToMeasureBoundedSize = useMemo(() => {
return (React.createElement(CompatView, { style: {
marginTop: horizontal ? undefined : stickyHeaderOffset,
height: horizontal ? undefined : 0,
width: horizontal ? 0 : undefined,
}, ref: firstChildViewRef }));
}, [horizontal, stickyHeaderOffset]);
const scrollAnchor = useMemo(() => {
if (shouldMaintainVisibleContentPosition) {
return (React.createElement(ScrollAnchor, { horizontal: Boolean(horizontal), scrollAnchorRef: scrollAnchorRef }));
}
return null;
}, [horizontal, shouldMaintainVisibleContentPosition]);
// console.log("render", recyclerViewManager.getRenderStack());
// Render the main RecyclerView structure
return (React.createElement(RecyclerViewContextProvider, { value: recyclerViewContext },
React.createElement(CompatView, { style: [
{
flex: horizontal ? undefined : 1,
overflow: "hidden",
},
style,
], ref: internalViewRef, collapsable: false, onLayout: (event) => {
var _a, _b, _c, _d;
if (areDimensionsNotEqual(event.nativeEvent.layout.width, (_b = (_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) !== null && _b !== void 0 ? _b : 0) ||
areDimensionsNotEqual(event.nativeEvent.layout.height, (_d = (_c = containerViewSizeRef.current) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : 0)) {
// console.log(
// "onLayout",
// recyclerViewManager.getWindowSize(),
// event.nativeEvent.layout
// );
recyclerViewContext.layout();
}
} },
React.createElement(CompatScrollView, { ...rest, horizontal: horizontal, ref: scrollViewRef, onScroll: animatedEvent, maintainVisibleContentPosition: maintainVisibleContentPositionInternal, refreshControl: refreshControl, ...overrideProps },
scrollAnchor,
isHorizontalRTL && viewToMeasureBoundedSize,
renderHeader,
!isHorizontalRTL && viewToMeasureBoundedSize,
React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => 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 === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.unmarkChildLayoutAsPending(recyclerViewId);
onCommitLayoutEffect === null || onCommitLayoutEffect === void 0 ? void 0 : onCommitLayoutEffect();
}, onCommitEffect: () => {
renderTimeTracker.markRenderComplete();
recyclerViewManager.updateAverageRenderTime(renderTimeTracker.getAverageRenderTime());
applyInitialScrollIndex();
checkBounds();
recyclerViewManager.computeItemViewability();
recyclerViewManager.animationOptimizationsEnabled = false;
}, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, getChildContainerLayout: () => recyclerViewManager.hasLayout()
? recyclerViewManager.getChildContainerDimensions()
: undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell }),
renderEmpty,
renderFooter),
stickyHeaderIndices && stickyHeaderIndices.length > 0
? renderStickyHeaderBackdrop
: null,
stickyHeaders)));
};
// Set displayName for the inner component
RecyclerViewComponent.displayName = "FlashList";
// Create and export the memoized, forwarded ref component
const RecyclerView = React.memo(forwardRef(RecyclerViewComponent));
export { RecyclerView };
//# sourceMappingURL=RecyclerView.js.map