All checks were successful
Publish To Prod / deploy_and_publish (push) Successful in 35s
318 lines
8.4 KiB
JavaScript
318 lines
8.4 KiB
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;
|