diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 26149e65..f737aa38 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -27,11 +27,10 @@ type ItemMetadata = {| type InstanceProps = {| estimatedItemSize: number, instance: any, - itemOffsetMap: { [index: number]: number }, itemSizeMap: { [index: number]: number }, - lastMeasuredIndex: number, - lastPositionedIndex: number, - totalMeasuredSize: number, + anchorIndex: number, + anchorSizeDelta: number, + renderedItemOffsetMap: { [index: number]: number }, |}; const getItemMetadata = ( @@ -39,78 +38,18 @@ const getItemMetadata = ( 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; - } + const { estimatedItemSize, itemSizeMap, anchorIndex } = instanceProps; - for (let i = Math.max(1, lastPositionedIndex + 1); i <= index; i++) { - const prevOffset = itemOffsetMap[i - 1]; + const size = itemSizeMap[index] || estimatedItemSize; + let offset = anchorIndex * estimatedItemSize; - // 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]; + if (index > anchorIndex) { + for (let i = anchorIndex; i < index; i++) { + offset += itemSizeMap[i] || estimatedItemSize; } - - instanceProps.lastPositionedIndex = index; - } - - let offset = itemOffsetMap[index]; - let size = itemSizeMap[index]; - - return { offset, size }; -}; - -const findNearestItemBinarySearch = ( - props: Props, - 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; + return { offset, size }; } else { - return 0; + return { offset, size }; } }; @@ -119,11 +58,23 @@ const getEstimatedTotalSize = ( { itemSizeMap, estimatedItemSize, - lastMeasuredIndex, - totalMeasuredSize, + anchorIndex, + renderedItemOffsetMap, }: InstanceProps -) => - totalMeasuredSize + (itemCount - lastMeasuredIndex - 1) * estimatedItemSize; +) => { + const renderedIndexes = Object.keys(renderedItemOffsetMap); + + let totalMeasuredSize = 0; + renderedIndexes.forEach(i => { + // $FlowFixMe + totalMeasuredSize += itemSizeMap[i] || estimatedItemSize; + }); + + const restSize = (itemCount - renderedIndexes.length) * estimatedItemSize; + const nextSize = restSize + totalMeasuredSize; + + return nextSize; +}; const DynamicSizeList = createListComponent({ getItemOffset: ( @@ -152,50 +103,54 @@ const DynamicSizeList = createListComponent({ 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; - } - } + // TODO: align start is only supported ? + instanceProps.anchorIndex = index; + return index * instanceProps.estimatedItemSize; + + // 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: ( @@ -203,22 +158,35 @@ const DynamicSizeList = createListComponent({ 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 - ); + const { itemCount } = props; + const { estimatedItemSize, itemSizeMap, instance } = instanceProps; + let { anchorIndex: index, anchorSizeDelta: sizeDelta } = instanceProps; + + const offsetIndex = index * estimatedItemSize; + let delta = offset - offsetIndex; + + const getSize = (i: number) => itemSizeMap[i] || estimatedItemSize; + + if (instance.state.scrollDirection === 'backward') { + while (delta < 0) { + index = Math.max(0, index - 1); + const nextSize = getSize(index); + sizeDelta -= estimatedItemSize - nextSize; + delta += nextSize; + } + } else { + while (delta > getSize(index)) { + const nextSize = getSize(index); + sizeDelta += estimatedItemSize - nextSize; + delta -= nextSize; + index = Math.min(itemCount - 1, index + 1); + } } - // Otherwise render a new batch of items starting from where we left off. - return lastMeasuredIndex + 1; + instanceProps.anchorIndex = index; + instanceProps.anchorSizeDelta = sizeDelta; + + return instanceProps.anchorIndex; }, getStopIndexForStartIndex: ( @@ -228,10 +196,12 @@ const DynamicSizeList = createListComponent({ instanceProps: InstanceProps ): number => { const { direction, layout, height, itemCount, width } = props; + const { itemSizeMap, estimatedItemSize } = instanceProps; const size = (((direction === 'horizontal' || layout === 'horizontal' ? width : height): any): number); + const itemMetadata = getItemMetadata(props, startIndex, instanceProps); const maxOffset = scrollOffset + size; @@ -240,7 +210,7 @@ const DynamicSizeList = createListComponent({ while (stopIndex < itemCount - 1 && offset < maxOffset) { stopIndex++; - offset += getItemMetadata(props, stopIndex, instanceProps).size; + offset += itemSizeMap[stopIndex] || estimatedItemSize; } return stopIndex; @@ -252,11 +222,10 @@ const DynamicSizeList = createListComponent({ const instanceProps = { estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, instance, - itemOffsetMap: {}, itemSizeMap: {}, - lastMeasuredIndex: -1, - lastPositionedIndex: -1, - totalMeasuredSize: 0, + anchorIndex: 0, + anchorSizeDelta: 0, + renderedItemOffsetMap: {}, }; let debounceForceUpdateID = null; @@ -278,88 +247,57 @@ const DynamicSizeList = createListComponent({ }; 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; - } + const anchorSizeDeltaForStateUpdate = instanceProps.anchorSizeDelta; - let shouldForceUpdate; + if (anchorSizeDeltaForStateUpdate !== 0) { + instanceProps.anchorSizeDelta -= anchorSizeDeltaForStateUpdate; - // 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; - } + return { + scrollOffset: + prevState.scrollOffset + anchorSizeDeltaForStateUpdate, + }; }, () => { - if (shouldForceUpdate) { - instance.forceUpdate(); + const { scrollOffset } = instance.state; + const { direction, layout } = instance.props; + const isHorizontal = + direction === 'horizontal' || layout === 'horizontal'; + // 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( + isHorizontal ? anchorSizeDeltaForStateUpdate : 0, + isHorizontal ? 0 : anchorSizeDeltaForStateUpdate + ); + } else if (isHorizontal) { + element.scrollLeft = scrollOffset; } 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; - } + element.scrollTop = scrollOffset; } - - sizeDeltaTotal -= sizeDeltaForStateUpdate; } ); } + + 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 (anchorSizeDeltaForStateUpdate === 0) { + instance.forceUpdate(); + return; + } + } }; // This function may be called out of order! @@ -370,51 +308,10 @@ const DynamicSizeList = createListComponent({ 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; - } + const { itemSizeMap } = instanceProps; 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 { @@ -437,17 +334,31 @@ const DynamicSizeList = createListComponent({ } = instance.props; const { isScrolling } = instance.state; - const [startIndex, stopIndex] = instance._getRangeToRender(); + const [, , startIndex, stopIndex] = instance._getRangeToRender(); const items = []; + const renderedItemOffsetMap = {}; + let resetCache = false; + if (itemCount > 0) { for (let index = startIndex; index <= stopIndex; index++) { - const { size } = getItemMetadata( + const { size, offset } = getItemMetadata( instance.props, index, instanceProps ); + // TODO: maybe something like this? reset cache from index that changed + renderedItemOffsetMap[index] = offset; + resetCache = + resetCache || + renderedItemOffsetMap[index] !== + instanceProps.renderedItemOffsetMap[index]; + + if (resetCache && instance._itemStyleCache) { + delete instance._itemStyleCache[index]; + } + // It's important to read style after fetching item metadata. // getItemMetadata() will clear stale styles. const style = instance._getItemStyle(index); @@ -473,6 +384,8 @@ const DynamicSizeList = createListComponent({ ); } } + + instanceProps.renderedItemOffsetMap = renderedItemOffsetMap; return items; }; diff --git a/src/__tests__/DynamicSizeList.js b/src/__tests__/DynamicSizeList.js index 98bc0bb1..669032b3 100644 --- a/src/__tests__/DynamicSizeList.js +++ b/src/__tests__/DynamicSizeList.js @@ -64,7 +64,7 @@ describe('DynamicSizeList', () => { innerRef, itemCount: 20, onItemsRendered, - overscanCount: 1, + overscanCount: 0, width: 50, }; }); @@ -79,12 +79,12 @@ describe('DynamicSizeList', () => { // Initial render uses estimatedItemSize for scrollHeight. expect(innerRef.current.style.height).toBe('500px'); - // Given estimatedItemSize and overscanCount, we expect to render 5 items. - expect(innerRef.current.children).toHaveLength(5); + // Given estimatedItemSize, we expect to render 4 items. + expect(innerRef.current.children).toHaveLength(4); break; case 2: // Second render should adjust scrollHeight for newly measured items. - expect(innerRef.current.style.height).toBe('525px'); + expect(innerRef.current.style.height).toBe('520px'); // Newly measured items should be positioned correctly. Array.from(innerRef.current.children).forEach((node, index) => {