@john-osullivan/react-window-dynamic-fork
Version:
Temporary Fork: see https://github.com/bvaughn/react-window
494 lines (423 loc) • 15.2 kB
JavaScript
// @flow
import { createElement } from 'react';
import createListComponent, { defaultItemKey } from './createListComponent';
import ItemMeasurer from './ItemMeasurer';
import type { Props, ScrollToAlign } from './createListComponent';
const DEFAULT_ESTIMATED_ITEM_SIZE = 50;
type DynanmicProps = {|
estimatedItemSize: number,
...Props<any>,
|};
export type HandleNewMeasurements = (
index: number,
newSize: number,
isFirstMeasureAfterMounting: boolean
) => void;
type ItemMetadata = {|
offset: number,
size: number,
|};
type InstanceProps = {|
estimatedItemSize: number,
instance: any,
itemOffsetMap: { [index: number]: number },
itemSizeMap: { [index: number]: number },
lastMeasuredIndex: number,
lastPositionedIndex: number,
totalMeasuredSize: number,
|};
const getItemMetadata = (
props: Props<any>,
index: number,
instanceProps: InstanceProps
): ItemMetadata => {
const {
estimatedItemSize,
instance,
itemOffsetMap,
itemSizeMap,
lastMeasuredIndex,
lastPositionedIndex,
} = instanceProps;
// If the specified item has not yet been measured,
// Just return an estimated size for now.
if (index > lastMeasuredIndex) {
return {
offset: 0,
size: estimatedItemSize,
};
}
// Lazily update positions if they are stale.
if (index > lastPositionedIndex) {
if (lastPositionedIndex < 0) {
itemOffsetMap[0] = 0;
}
for (let i = Math.max(1, lastPositionedIndex + 1); i <= index; i++) {
const prevOffset = itemOffsetMap[i - 1];
// In some browsers (e.g. Firefox) fast scrolling may skip rows.
// In this case, our assumptions about last measured indices may be incorrect.
// Handle this edge case to prevent NaN values from breaking styles.
// Slow scrolling back over these skipped rows will adjust their sizes.
const prevSize = itemSizeMap[i - 1] || 0;
itemOffsetMap[i] = prevOffset + prevSize;
// Reset cached style to clear stale position.
delete instance._itemStyleCache[i];
}
instanceProps.lastPositionedIndex = index;
}
let offset = itemOffsetMap[index];
let size = itemSizeMap[index];
return { offset, size };
};
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 getEstimatedTotalSize = (
{ itemCount }: Props<any>,
{
itemSizeMap,
estimatedItemSize,
lastMeasuredIndex,
totalMeasuredSize,
}: InstanceProps
) =>
totalMeasuredSize + (itemCount - lastMeasuredIndex - 1) * estimatedItemSize;
const DynamicSizeList = createListComponent({
getItemOffset: (
props: Props<any>,
index: number,
instanceProps: InstanceProps
): number => getItemMetadata(props, index, instanceProps).offset,
getItemSize: (
props: Props<any>,
index: number,
instanceProps: InstanceProps
): ?number => {
// Do not hard-code item dimensions.
// We don't know them initially.
// Even once we do, changes in item content or list size should reflow.
return undefined;
},
getEstimatedTotalSize,
getOffsetForIndexAndAlignment: (
props: Props<any>,
index: number,
align: ScrollToAlign,
scrollOffset: number,
instanceProps: InstanceProps
): number => {
const { direction, layout, height, width } = props;
if (process.env.NODE_ENV !== 'production') {
const { lastMeasuredIndex } = instanceProps;
if (index > lastMeasuredIndex) {
console.warn(
`DynamicSizeList does not support scrolling to items that yave not yet measured. ` +
`scrollToItem() was called with index ${index} but the last measured item was ${lastMeasuredIndex}.`
);
}
}
const size = (((direction === 'horizontal' || layout === 'horizontal'
? 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.min(estimatedTotalSize - size, itemMetadata.offset);
const minOffset = Math.max(
0,
itemMetadata.offset - size + itemMetadata.size
);
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 < maxOffset - scrollOffset) {
return minOffset;
} else {
return maxOffset;
}
}
},
getStartIndexForOffset: (
props: Props<any>,
offset: number,
instanceProps: InstanceProps
): number => {
const { lastMeasuredIndex, totalMeasuredSize } = instanceProps;
// If we've already positioned and measured past this point,
// Use a binary search to find the closets cell.
if (offset <= totalMeasuredSize) {
return findNearestItemBinarySearch(
props,
instanceProps,
lastMeasuredIndex,
0,
offset
);
}
// Otherwise render a new batch of items starting from where we left off.
return lastMeasuredIndex + 1;
},
getStopIndexForStartIndex: (
props: Props<any>,
startIndex: number,
scrollOffset: number,
instanceProps: InstanceProps
): number => {
const { direction, layout, height, itemCount, width } = props;
const size = (((direction === 'horizontal' || layout === 'horizontal'
? 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): DynanmicProps);
const instanceProps = {
estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE,
instance,
itemOffsetMap: {},
itemSizeMap: {},
lastMeasuredIndex: -1,
lastPositionedIndex: -1,
totalMeasuredSize: 0,
};
let debounceForceUpdateID = null;
const debounceForceUpdate = () => {
if (debounceForceUpdateID === null) {
debounceForceUpdateID = setTimeout(() => {
debounceForceUpdateID = null;
instance.forceUpdate();
}, 1);
}
};
// This method is called before unmounting.
instance._unmountHook = () => {
if (debounceForceUpdateID !== null) {
clearTimeout(debounceForceUpdateID);
debounceForceUpdateID = null;
}
};
let hasNewMeasurements: boolean = false;
let sizeDeltaTotal = 0;
// This method is called after mount and update.
instance._commitHook = () => {
if (hasNewMeasurements) {
hasNewMeasurements = false;
// Edge case where cell sizes changed, but cancelled each other out.
// We still need to re-render in this case,
// Even though we don't need to adjust scroll offset.
if (sizeDeltaTotal === 0) {
instance.forceUpdate();
return;
}
let shouldForceUpdate;
// In the setState commit hook, we'll decrement sizeDeltaTotal.
// In case the state update is processed synchronously,
// And triggers additional size updates itself,
// We should only drecement by the amount we updated state for originally.
const sizeDeltaForStateUpdate = sizeDeltaTotal;
// If the user is scrolling up, we need to adjust the scroll offset,
// To prevent items from "jumping" as items before them have been resized.
instance.setState(
prevState => {
if (
prevState.scrollDirection === 'backward' &&
!prevState.scrollUpdateWasRequested
) {
// TRICKY
// If item(s) have changed size since they were last displayed, content will appear to jump.
// To avoid this, we need to make small adjustments as a user scrolls to preserve apparent position.
// This also ensures that the first item eventually aligns with scroll offset 0.
return {
scrollOffset: prevState.scrollOffset + sizeDeltaForStateUpdate,
};
} else {
// There's no state to update,
// But we still want to re-render in this case.
shouldForceUpdate = true;
return null;
}
},
() => {
if (shouldForceUpdate) {
instance.forceUpdate();
} else {
const { scrollOffset } = instance.state;
const { direction, layout } = instance.props;
// Adjusting scroll offset directly interrupts smooth scrolling for some browsers (e.g. Firefox).
// The relative scrollBy() method doesn't interrupt (or at least it won't as of Firefox v65).
// Other browsers (e.g. Chrome, Safari) seem to handle both adjustments equally well.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1502059
const element = ((instance._outerRef: any): HTMLDivElement);
// $FlowFixMe Property scrollBy is missing in HTMLDivElement
if (typeof element.scrollBy === 'function') {
element.scrollBy(
direction === 'horizontal' || layout === 'horizontal'
? sizeDeltaForStateUpdate
: 0,
direction === 'horizontal' || layout === 'horizontal'
? 0
: sizeDeltaForStateUpdate
);
} else if (
direction === 'horizontal' ||
layout === 'horizontal'
) {
element.scrollLeft = scrollOffset;
} else {
element.scrollTop = scrollOffset;
}
}
sizeDeltaTotal -= sizeDeltaForStateUpdate;
}
);
}
};
// This function may be called out of order!
// It is not safe to reposition items here.
// Be careful when comparing index and lastMeasuredIndex.
const handleNewMeasurements: HandleNewMeasurements = (
index: number,
newSize: number,
isFirstMeasureAfterMounting: boolean
) => {
const {
itemSizeMap,
lastMeasuredIndex,
lastPositionedIndex,
} = instanceProps;
// In some browsers (e.g. Firefox) fast scrolling may skip rows.
// In this case, our assumptions about last measured indices may be incorrect.
// Handle this edge case to prevent NaN values from breaking styles.
// Slow scrolling back over these skipped rows will adjust their sizes.
const oldSize = itemSizeMap[index] || 0;
// Mark offsets after this as stale so that getItemMetadata() will lazily recalculate it.
if (index < lastPositionedIndex) {
instanceProps.lastPositionedIndex = index;
}
if (index <= lastMeasuredIndex) {
if (oldSize === newSize) {
return;
}
// Adjust total size estimate by the delta in size.
instanceProps.totalMeasuredSize += newSize - oldSize;
// Record the size delta here in case the user is scrolling up.
// In that event, we need to adjust the scroll offset by thie amount,
// To prevent items from "jumping" as items before them are resized.
// We only do this for items that are newly measured (after mounting).
// Ones that change size later do not need to affect scroll offset.
if (isFirstMeasureAfterMounting) {
sizeDeltaTotal += newSize - oldSize;
}
} else {
instanceProps.lastMeasuredIndex = index;
instanceProps.totalMeasuredSize += newSize;
}
itemSizeMap[index] = newSize;
// Even though the size has changed, we don't need to reset the cached style,
// Because dynamic list items don't have constrained sizes.
// This enables them to resize when their content (or container size) changes.
// It also lets us avoid an unnecessary render in this case.
if (isFirstMeasureAfterMounting) {
hasNewMeasurements = true;
} else {
debounceForceUpdate();
}
};
instance._handleNewMeasurements = handleNewMeasurements;
// Override the item-rendering process to wrap items with ItemMeasurer.
// This keep the external API simpler.
instance._renderItems = () => {
const {
children,
direction,
layout,
itemCount,
itemData,
itemKey = defaultItemKey,
useIsScrolling,
} = instance.props;
const { isScrolling } = instance.state;
const [startIndex, stopIndex] = instance._getRangeToRender();
const items = [];
if (itemCount > 0) {
for (let index = startIndex; index <= stopIndex; index++) {
const { size } = getItemMetadata(
instance.props,
index,
instanceProps
);
// It's important to read style after fetching item metadata.
// getItemMetadata() will clear stale styles.
const style = instance._getItemStyle(index);
const item = createElement(children, {
data: itemData,
index,
isScrolling: useIsScrolling ? isScrolling : undefined,
style,
});
// Always wrap children in a ItemMeasurer to detect changes in size.
items.push(
createElement(ItemMeasurer, {
direction,
layout,
handleNewMeasurements,
index,
item,
key: itemKey(index, itemData),
size,
})
);
}
}
return items;
};
return instanceProps;
},
shouldResetStyleCacheOnItemSizeChange: false,
validateProps: ({ itemSize }: Props<any>): void => {
if (process.env.NODE_ENV !== 'production') {
if (itemSize !== undefined) {
throw Error('An unexpected "itemSize" prop has been provided.');
}
}
},
});
export default DynamicSizeList;