react-window
Version:
React components for efficiently rendering large, scrollable lists and tabular data
318 lines (266 loc) • 8.6 kB
JavaScript
// @flow
import createListComponent from './createListComponent';
import type { Props, ScrollToAlign } from './createListComponent';
const DEFAULT_ESTIMATED_ITEM_SIZE = 50;
type VariableSizeProps = {|
estimatedItemSize: number,
...Props<any>,
|};
type itemSizeGetter = (index: number) => number;
type ItemMetadata = {|
offset: number,
size: number,
|};
type InstanceProps = {|
itemMetadataMap: { [index: number]: ItemMetadata },
estimatedItemSize: number,
lastMeasuredIndex: number,
|};
const getItemMetadata = (
props: Props<any>,
index: number,
instanceProps: InstanceProps
): ItemMetadata => {
const { itemSize } = ((props: any): VariableSizeProps);
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++) {
let size = ((itemSize: any): itemSizeGetter)(i);
itemMetadataMap[i] = {
offset,
size,
};
offset += size;
}
instanceProps.lastMeasuredIndex = index;
}
return itemMetadataMap[index];
};
const findNearestItem = (
props: Props<any>,
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: Props<any>,
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: Props<any>,
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 }: Props<any>,
{ 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;
};
const VariableSizeList = createListComponent({
getItemOffset: (
props: Props<any>,
index: number,
instanceProps: InstanceProps
): number => getItemMetadata(props, index, instanceProps).offset,
getItemSize: (
props: Props<any>,
index: number,
instanceProps: InstanceProps
): number => instanceProps.itemMetadataMap[index].size,
getEstimatedTotalSize,
getOffsetForIndexAndAlignment: (
props: Props<any>,
index: number,
align: ScrollToAlign,
scrollOffset: number,
instanceProps: InstanceProps,
scrollbarSize: number
): number => {
const { direction, height, layout, width } = props;
// TODO Deprecate direction "horizontal"
const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
const size = (((isHorizontal ? width : height): any): number);
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 + scrollbarSize
);
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: Props<any>,
offset: number,
instanceProps: InstanceProps
): number => findNearestItem(props, instanceProps, offset),
getStopIndexForStartIndex: (
props: Props<any>,
startIndex: number,
scrollOffset: number,
instanceProps: InstanceProps
): number => {
const { direction, height, itemCount, layout, width } = props;
// TODO Deprecate direction "horizontal"
const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
const size = (((isHorizontal ? width : height): any): number);
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: Props<any>, instance: any): InstanceProps {
const { estimatedItemSize } = ((props: any): VariableSizeProps);
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 }: Props<any>): void => {
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.`
);
}
}
},
});
export default VariableSizeList;