import createListComponent from './createListComponent'; import { ScrollToAlign, ListProps, InstanceProps, ItemMetadata } from './createListComponent'; const DEFAULT_ESTIMATED_ITEM_SIZE = 50; const getItemMetadata = (props: ListProps, index: number, instanceProps: InstanceProps): ItemMetadata => { const { itemSize } = props; const { itemMetadataMap, lastMeasuredIndex } = instanceProps; if (index > lastMeasuredIndex) { let offset = 0; if (lastMeasuredIndex >= 0) { const itemMetadata = itemMetadataMap[lastMeasuredIndex]; offset = itemMetadata.offset + itemMetadata.size; } for (let i = lastMeasuredIndex + 1; i <= index; i++) { const size = typeof itemSize === 'number' ? itemSize : itemSize(i); itemMetadataMap[i] = { offset, size }; offset += size; } instanceProps.lastMeasuredIndex = index; } return itemMetadataMap[index]; }; const findNearestItem = (props: ListProps, instanceProps: InstanceProps, offset: number) => { const { itemMetadataMap, lastMeasuredIndex } = instanceProps; const lastMeasuredItemOffset = lastMeasuredIndex > 0 ? itemMetadataMap[lastMeasuredIndex].offset : 0; if (lastMeasuredItemOffset >= offset) { // If we've already measured items within this range just use a binary search as it's faster. return findNearestItemBinarySearch(props, instanceProps, lastMeasuredIndex, 0, offset); } else { // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. // The exponential search avoids pre-computing sizes for the full set of items as a binary search would. // The overall complexity for this approach is O(log n). return findNearestItemExponentialSearch(props, instanceProps, Math.max(0, lastMeasuredIndex), offset); } }; const findNearestItemBinarySearch = ( props: ListProps, instanceProps: InstanceProps, high: number, low: number, offset: number ): number => { while (low <= high) { const middle = low + Math.floor((high - low) / 2); const currentOffset = getItemMetadata(props, middle, instanceProps).offset; if (currentOffset === offset) { return middle; } else if (currentOffset < offset) { low = middle + 1; } else if (currentOffset > offset) { high = middle - 1; } } if (low > 0) { return low - 1; } else { return 0; } }; const findNearestItemExponentialSearch = ( props: ListProps, instanceProps: InstanceProps, index: number, offset: number ): number => { const { itemCount } = props; let interval = 1; while (index < itemCount && getItemMetadata(props, index, instanceProps).offset < offset) { index += interval; interval *= 2; } return findNearestItemBinarySearch( props, instanceProps, Math.min(index, itemCount - 1), Math.floor(index / 2), offset ); }; const getEstimatedTotalSize = ( { itemCount }: ListProps, { itemMetadataMap, estimatedItemSize, lastMeasuredIndex }: InstanceProps ) => { let totalSizeOfMeasuredItems = 0; // Edge case check for when the number of items decreases while a scroll is in progress. // https://github.com/bvaughn/react-window/pull/138 if (lastMeasuredIndex >= itemCount) { lastMeasuredIndex = itemCount - 1; } if (lastMeasuredIndex >= 0) { const itemMetadata = itemMetadataMap[lastMeasuredIndex]; totalSizeOfMeasuredItems = itemMetadata.offset + itemMetadata.size; } const numUnmeasuredItems = itemCount - lastMeasuredIndex - 1; const totalSizeOfUnmeasuredItems = numUnmeasuredItems * estimatedItemSize; return totalSizeOfMeasuredItems + totalSizeOfUnmeasuredItems; }; export const VariableSizeList = createListComponent({ getItemOffset: (props: ListProps, index: number, instanceProps: InstanceProps): number => getItemMetadata(props, index, instanceProps).offset, getItemSize: (props: ListProps, index: number, instanceProps: InstanceProps): number => instanceProps.itemMetadataMap[index].size, getEstimatedTotalSize, getOffsetForIndexAndAlignment: ( props: ListProps, index: number, align: ScrollToAlign, scrollOffset: number, instanceProps: InstanceProps ): number => { const { height } = props; const size = height; const itemMetadata = getItemMetadata(props, index, instanceProps); // Get estimated total size after ItemMetadata is computed, // To ensure it reflects actual measurements instead of just estimates. const estimatedTotalSize = getEstimatedTotalSize(props, instanceProps); const maxOffset = Math.max(0, Math.min(estimatedTotalSize - size, itemMetadata.offset)); const minOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size); if (align === 'smart') { if (scrollOffset >= minOffset - size && scrollOffset <= maxOffset + size) { align = 'auto'; } else { align = 'center'; } } switch (align) { case 'start': return maxOffset; case 'end': return minOffset; case 'center': return Math.round(minOffset + (maxOffset - minOffset) / 2); case 'auto': default: if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { return scrollOffset; } else if (scrollOffset < minOffset) { return minOffset; } else { return maxOffset; } } }, getStartIndexForOffset: (props: ListProps, offset: number, instanceProps: InstanceProps): number => findNearestItem(props, instanceProps, offset), getStopIndexForStartIndex: ( props: ListProps, startIndex: number, scrollOffset: number, instanceProps: InstanceProps ): number => { const { height, itemCount } = props; const size = height; const itemMetadata = getItemMetadata(props, startIndex, instanceProps); const maxOffset = scrollOffset + size; let offset = itemMetadata.offset + itemMetadata.size; let stopIndex = startIndex; while (stopIndex < itemCount - 1 && offset < maxOffset) { stopIndex++; offset += getItemMetadata(props, stopIndex, instanceProps).size; } return stopIndex; }, initInstanceProps(props: ListProps, instance: any): InstanceProps { const { estimatedItemSize } = props; const instanceProps = { itemMetadataMap: {}, estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, lastMeasuredIndex: -1 }; instance.resetAfterIndex = (index: number, shouldForceUpdate: boolean = true) => { instanceProps.lastMeasuredIndex = Math.min(instanceProps.lastMeasuredIndex, index - 1); // We could potentially optimize further by only evicting styles after this index, // But since styles are only cached while scrolling is in progress- // It seems an unnecessary optimization. // It's unlikely that resetAfterIndex() will be called while a user is scrolling. instance._getItemStyleCache(-1); if (shouldForceUpdate) { instance.forceUpdate(); } }; return instanceProps; }, shouldResetStyleCacheOnItemSizeChange: false, validateProps: ({ itemSize }: ListProps) => { if (process.env.NODE_ENV !== 'production') { if (typeof itemSize !== 'function') { throw Error( 'An invalid "itemSize" prop has been specified. ' + 'Value should be a function. ' + `"${itemSize === null ? 'null' : typeof itemSize}" was specified.` ); } } } });