From df974880eeebbbbf7ee90fd42d63fb6df4e1cf27 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 10 Oct 2018 18:05:46 +0800 Subject: [PATCH] Iterated on DynamicSizeList --- src/CellMeasurer.js | 78 +++++++ src/DynamicSizeList.js | 198 ++++++++++-------- src/createListComponent.js | 3 - website/package.json | 1 + website/src/code/DynamicSizeListVertical.js | 23 +- website/src/components/ProfiledExample.js | 7 +- .../src/routes/examples/DynamicSizeList.js | 154 ++++++++------ website/src/routes/examples/shared.module.css | 17 ++ website/yarn.lock | 8 +- 9 files changed, 321 insertions(+), 168 deletions(-) create mode 100644 src/CellMeasurer.js diff --git a/src/CellMeasurer.js b/src/CellMeasurer.js new file mode 100644 index 0000000..55d7d52 --- /dev/null +++ b/src/CellMeasurer.js @@ -0,0 +1,78 @@ +// @flow + +import { Component } from 'react'; +import { findDOMNode } from 'react-dom'; + +import type { Direction } from './createListComponent'; +import type { HandleNewMeasurements } from './DynamicSizeList'; + +declare class ResizeObserver { + constructor(callback: Function): void; + observe(target: HTMLElement): void; + unobserve(target: HTMLElement): void; +} + +type CellMeasurerProps = {| + direction: Direction, + handleNewMeasurements: HandleNewMeasurements, + index: number, + item: React$Element, + size: number, +|}; + +export default class CellMeasurer extends Component { + _node: HTMLElement = (null: any); + _resizeObserver: ResizeObserver = (null: any); + + componentDidMount() { + this._node = ((findDOMNode(this): any): HTMLElement); + + // Force sync measure for the initial mount. + // This is necessary to support the DynamicSizeList layout logic. + this._measureItem(true); + + // Watch for resizes due to changed content, + // Or changes in the size of the parent container. + this._resizeObserver = new ResizeObserver(this._onResize); + this._resizeObserver.observe(this._node); + } + + componentWillUnmount() { + this._resizeObserver.unobserve(this._node); + } + + render() { + return this.props.item; + } + + _measureItem = (isCommitPhase: boolean) => { + const { + direction, + handleNewMeasurements, + index, + size: oldSize, + } = this.props; + + const node = this._node; + + if ( + node && + node.ownerDocument && + node.ownerDocument.defaultView && + node instanceof node.ownerDocument.defaultView.HTMLElement + ) { + const newSize = + direction === 'horizontal' + ? Math.ceil(node.offsetWidth) + : Math.ceil(node.offsetHeight); + + if (oldSize !== newSize) { + handleNewMeasurements(index, newSize, isCommitPhase); + } + } + }; + + _onResize = () => { + this._measureItem(false); + }; +} diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 3cc0347..baf59b1 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -1,53 +1,84 @@ // @flow -import React, { createElement, Component } from 'react'; -import { findDOMNode } from 'react-dom'; +import { createElement } from 'react'; import createListComponent, { defaultItemKey } from './createListComponent'; +import CellMeasurer from './CellMeasurer'; -import type { Direction, Props, ScrollToAlign } from './createListComponent'; +import type { Props, ScrollToAlign } from './createListComponent'; const DEFAULT_ESTIMATED_ITEM_SIZE = 50; type DynanmicProps = {| estimatedItemSize: number, - ...Props, + ...Props, |}; +export type HandleNewMeasurements = ( + index: number, + newSize: number, + isCommitPhase: boolean +) => void; + type ItemMetadata = {| offset: number, size: number, |}; type InstanceProps = {| estimatedItemSize: number, - itemMetadataMap: { [index: number]: ItemMetadata }, + itemOffsetMap: { [index: number]: number }, + itemSizeMap: { [index: number]: number }, lastMeasuredIndex: number, + lastPositionedIndex: number, totalMeasuredSize: number, |}; const getItemMetadata = ( - props: Props, + props: Props, index: number, instanceProps: InstanceProps ): ItemMetadata => { - const { estimatedItemSize, itemMetadataMap } = instanceProps; - - let itemMetadata = itemMetadataMap[index]; + const { + estimatedItemSize, + itemOffsetMap, + itemSizeMap, + lastMeasuredIndex, + lastPositionedIndex, + } = instanceProps; // If the specified item has not yet been measured, // Just return an estimated size for now. - if (itemMetadata === undefined) { - itemMetadata = itemMetadataMap[index] = { + if (index > lastMeasuredIndex) { + return { offset: 0, size: estimatedItemSize, }; } - return itemMetadata; + // Lazily update positions if they are stale. + if (index > lastPositionedIndex) { + if (lastPositionedIndex < 0) { + itemOffsetMap[0] = 0; + } + + for (let i = Math.max(1, lastPositionedIndex); i <= index; i++) { + const prevOffset = itemOffsetMap[i - 1]; + const prevSize = itemSizeMap[i - 1]; + + itemOffsetMap[i] = prevOffset + prevSize; + } + + instanceProps.lastPositionedIndex = index; + } + + let offset = itemOffsetMap[index]; + let size = itemSizeMap[index]; + + return { offset, size }; }; const findNearestItemBinarySearch = ( - props: Props, + props: Props, instanceProps: InstanceProps, high: number, low: number, @@ -74,9 +105,9 @@ const findNearestItemBinarySearch = ( }; const getEstimatedTotalSize = ( - { itemCount }: Props, + { itemCount }: Props, { - itemMetadataMap, + itemSizeMap, estimatedItemSize, lastMeasuredIndex, totalMeasuredSize, @@ -84,72 +115,29 @@ const getEstimatedTotalSize = ( ) => totalMeasuredSize + (itemCount - lastMeasuredIndex - 1) * estimatedItemSize; -type CellMeasurerProps = {| - direction: Direction, - index: number, - instance: any, - item: React$Element, - itemMetadata: ItemMetadata, -|}; -class CellMeasurer extends Component { - componentDidMount() { - this._measureItem(); - } - - componentDidUpdate() { - // TODO Check if item needs to be remeasured. - } - - render() { - return this.props.item; - } - - _measureItem() { - const { direction, index, instance, itemMetadata } = this.props; - - const node = findDOMNode(this); - - if ( - node && - node.ownerDocument && - node.ownerDocument.defaultView && - node instanceof node.ownerDocument.defaultView.HTMLElement - ) { - const size = - direction === 'horizontal' - ? Math.ceil(node.offsetWidth) - : Math.ceil(node.offsetHeight); - - if (itemMetadata.size !== size) { - instance._handleNewMeasurements(index, size); - } - } - } -} - const DynamicSizeList = createListComponent({ getItemOffset: ( - props: Props, + props: Props, index: number, instanceProps: InstanceProps ): number => getItemMetadata(props, index, instanceProps).offset, getItemSize: ( - props: Props, + props: Props, index: number, instanceProps: InstanceProps ): number => { if (index > instanceProps.lastMeasuredIndex) { return instanceProps.estimatedItemSize; } else { - return instanceProps.itemMetadataMap[index].size; + return instanceProps.itemSizeMap[index]; } }, getEstimatedTotalSize, getOffsetForIndexAndAlignment: ( - props: Props, + props: Props, index: number, align: ScrollToAlign, scrollOffset: number, @@ -190,7 +178,7 @@ const DynamicSizeList = createListComponent({ }, getStartIndexForOffset: ( - props: Props, + props: Props, offset: number, instanceProps: InstanceProps ): number => { @@ -213,7 +201,7 @@ const DynamicSizeList = createListComponent({ }, getStopIndexForStartIndex: ( - props: Props, + props: Props, startIndex: number, scrollOffset: number, instanceProps: InstanceProps @@ -235,18 +223,31 @@ const DynamicSizeList = createListComponent({ return stopIndex; }, - initInstanceProps(props: Props, instance: any): InstanceProps { + initInstanceProps(props: Props, instance: any): InstanceProps { const { estimatedItemSize } = ((props: any): DynanmicProps); const instanceProps = { estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, - itemMetadataMap: {}, + itemOffsetMap: {}, + itemSizeMap: {}, lastMeasuredIndex: -1, + lastPositionedIndex: -1, totalMeasuredSize: 0, }; let hasNewMeasurements: boolean = false; + // TODO Cancel pending debounce on unmount + let debounceForceUpdateID = null; + const debounceForceUpdate = () => { + if (debounceForceUpdateID === null) { + debounceForceUpdateID = setTimeout(() => { + debounceForceUpdateID = null; + instance.forceUpdate(); + }, 1); + } + }; + // List calls this method automatically after "mount" and "update". instance._commitHook = () => { if (hasNewMeasurements) { @@ -264,34 +265,45 @@ const DynamicSizeList = createListComponent({ // This function may be called out of order! // It is not safe to reposition items here. // Be careful when comparing index and lastMeasuredIndex. - instance._handleNewMeasurements = (index: number, size: number) => { - const { itemMetadataMap, lastMeasuredIndex } = instanceProps; + const handleNewMeasurements: HandleNewMeasurements = ( + index: number, + newSize: number, + isCommitPhase: boolean + ) => { + const { + itemSizeMap, + lastMeasuredIndex, + lastPositionedIndex, + } = instanceProps; - const itemMetadata = itemMetadataMap[index]; + const oldSize = itemSizeMap[index]; - // Adjust item position in case new measurements were recorded. - // This method will always be called in order (lowest to highest index), - // So it is safe to adjust positions here. - if (index > 0) { - const prevItemMetadata = itemMetadataMap[index - 1]; - itemMetadata.offset = prevItemMetadata.offset + prevItemMetadata.size; + // Mark offsets after this as stale so that getItemMetadata() will lazily recalculate it. + if (index < lastPositionedIndex) { + instanceProps.lastPositionedIndex = index; } if (index <= lastMeasuredIndex) { - if (itemMetadata.size === size) { + if (oldSize === newSize) { return; } - instanceProps.totalMeasuredSize += size - itemMetadata.size; + // Adjust total size estimate by the delta in size. + instanceProps.totalMeasuredSize += newSize - oldSize; } else { instanceProps.lastMeasuredIndex = index; - instanceProps.totalMeasuredSize += size; + instanceProps.totalMeasuredSize += newSize; } - itemMetadata.size = size; + itemSizeMap[index] = newSize; - hasNewMeasurements = true; + if (isCommitPhase) { + hasNewMeasurements = true; + } else { + debounceForceUpdate(); + } }; + instance._handleNewMeasurements = handleNewMeasurements; // Override the item-rendering process to wrap items with CellMeasurer. // This keep the external API simpler. @@ -311,10 +323,18 @@ const DynamicSizeList = createListComponent({ const items = []; if (itemCount > 0) { + const { lastMeasuredIndex } = instanceProps; + for (let index = startIndex; index <= stopIndex; index++) { let style = instance._getItemStyle(index); - if (index > instanceProps.lastMeasuredIndex) { + const { offset, size } = getItemMetadata( + instance.props, + index, + instanceProps + ); + + if (index > lastMeasuredIndex) { // Strip hard-coded dimensions from the inline style. // These would interfere with the item laying itself out anyway. // Constrain the item to fill either the width or height of the list, @@ -324,6 +344,14 @@ const DynamicSizeList = createListComponent({ height: direction === 'horizontal' ? height : undefined, width: direction === 'vertical' ? width : undefined, }; + } else { + style = { + ...style, + height: direction === 'horizontal' ? '100%' : undefined, + width: direction === 'vertical' ? '100%' : undefined, + left: direction === 'horizontal' ? offset : 0, + top: direction === 'vertical' ? offset : 0, + }; } const item = createElement(children, { @@ -338,11 +366,11 @@ const DynamicSizeList = createListComponent({ items.push( createElement(CellMeasurer, { direction, + handleNewMeasurements: instance._handleNewMeasurements, index, - instance, item, - itemMetadata: instanceProps.itemMetadataMap[index], key: itemKey(index), + size, }) ); } @@ -357,7 +385,9 @@ const DynamicSizeList = createListComponent({ return instanceProps; }, - validateProps: ({ itemSize }: Props): void => { + shouldResetStyleCacheOnItemSizeChange: false, + + validateProps: ({ itemSize }: Props): void => { if (process.env.NODE_ENV !== 'production') { if (itemSize !== undefined) { throw Error('An unexpected "itemSize" prop has been provided.'); diff --git a/src/createListComponent.js b/src/createListComponent.js index 385ab11..8ffff95 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -231,9 +231,6 @@ export default function createListComponent({ height, innerRef, innerTagName, - itemCount, - itemData, - itemKey = defaultItemKey, outerTagName, style, width, diff --git a/website/package.json b/website/package.json index 5bfa5a2..5ef9acb 100644 --- a/website/package.json +++ b/website/package.json @@ -42,6 +42,7 @@ "fs-extra": "3.0.1", "html-webpack-plugin": "2.29.0", "jest": "20.0.4", + "lorem-ipsum": "^1.0.6", "object-assign": "4.1.1", "postcss-flexbugs-fixes": "3.2.0", "postcss-loader": "2.0.10", diff --git a/website/src/code/DynamicSizeListVertical.js b/website/src/code/DynamicSizeListVertical.js index e29127c..bb66e00 100644 --- a/website/src/code/DynamicSizeListVertical.js +++ b/website/src/code/DynamicSizeListVertical.js @@ -2,15 +2,16 @@ import { DynamicSizeList as List } from 'react-window'; // This example assumes 'items' is an array of strings. // Your application may render a more complex list of items. +const Row = ({ index, style }) => ( +
items[index]
+); - - {({ index, style }) => ( -
- {items[index]} -
- )} -
\ No newline at end of file +const Example = () => ( + + {Row} + +); \ No newline at end of file diff --git a/website/src/components/ProfiledExample.js b/website/src/components/ProfiledExample.js index fcf7881..cf10858 100644 --- a/website/src/components/ProfiledExample.js +++ b/website/src/components/ProfiledExample.js @@ -10,6 +10,7 @@ import styles from './ProfiledExample.module.css'; type Props = {| className?: string, sandbox?: string, + style?: Object, |}; const isProfilingEnabled = window.location.hash.includes('profile=true'); @@ -21,11 +22,11 @@ export default class ProfiledExample extends PureComponent { _totalActualTime = 0; render() { - const { className, children, sandbox } = this.props; + const { className, children, sandbox, style } = this.props; if (isProfilingEnabled) { return ( -
+
{children} @@ -46,7 +47,7 @@ export default class ProfiledExample extends PureComponent { ); } else { return ( -
+
{children}
diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index 28e8c03..c4aa67a 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -1,79 +1,93 @@ -import cn from 'classnames'; -import React, { Component } from 'react'; -import { DynamicSizeList } from 'react-window'; -// import randomWords from 'random-words'; +import React, { PureComponent } from 'react'; +import { DynamicSizeList as List } from 'react-window'; +import loremIpsum from 'lorem-ipsum'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; -import CODE_HORIZONTAL from '../../code/DynamicSizeListHorizontal.js'; +//import CODE_HORIZONTAL from '../../code/DynamicSizeListHorizontal.js'; import CODE_VERTICAL from '../../code/DynamicSizeListVertical.js'; import styles from './shared.module.css'; -const words = ['cat', 'kitten', 'feline', 'kitty']; -const randomWords = ({ min, max }) => { - const target = min + Math.round(Math.random() * (max - min)); - return new Array(target) - .fill(true) - .map(() => words[Math.floor(Math.random() * words.length)]); -}; - -const names = [ - 'Stasia', - 'Shaunta', - 'Lavona', - 'Felica', - 'Glinda', - 'Percy', - 'Irina', - 'Noriko', - 'Evette', - 'Margene', - 'Cordia', - 'Karmen', - 'Kitty', - 'Rima', - 'Dessie', - 'Kory', - 'Oda', - 'Alesia', - 'Loura', - 'Lucius', +var colors = [ + ['#1E88E5', '#90CAF9'], + ['#6D4C41', '#D7CCC8'], + ['#212121', '#BDBDBD'], + ['#388E3C', '#A5D6A7'], + ['#E53935', '#EF9A9A'], + ['#F4511E', '#FFAB91'], + ['#8E24AA', '#E1BEE7'], + ['#FFD600', '#FFF59D'], ]; -const items = new Array(500).fill(true).map(() => ({ - paragraph: randomWords({ min: 3, max: 30 }).join(', '), - name: names[Math.floor(Math.random() * names.length)], -})); +const items = new Array(500).fill(true).map(() => { + const text = loremIpsum({ units: 'paragraphs' }); + return { + colors: colors[Math.floor(Math.random() * colors.length)], + paragraph: text, + sentence: text.substr(0, text.indexOf('.')) + '…', + }; +}); -const itemRowRenderer = ({ index, style }) => ( -
- {index}: {items[index].paragraph} -
-); +class Row extends PureComponent { + state = { + isExpanded: true, + }; -const itemColumnRenderer = ({ index, style }) => ( -
- {index}: {items[index].name} -
-); + toggleExpanded = () => + this.setState(prevState => ({ + isExpanded: !prevState.isExpanded, + })); -export default class ScrollToItem extends Component { + render() { + const { index, style } = this.props; + const { isExpanded } = this.state; + + const item = items[index]; + + return ( +
+
+ {index} +
+
+ {isExpanded ? item.paragraph : item.sentence} +
+
+ ); + } +} + +export default class DynamicSizeList extends PureComponent { horizontalListRef = React.createRef(); verticalListRef = React.createRef(); + state = { + halfSize: false, + }; + + handleToggleResize = () => + this.setState(prevState => ({ + halfSize: !prevState.halfSize, + })); + render() { + const { halfSize } = this.state; + return (

Dynamic Size List

@@ -91,23 +105,30 @@ export default class ScrollToItem extends Component { > Scroll to 15,000px - + Resize list + + - {itemRowRenderer} - + {Row} +
+ {/*
- {itemColumnRenderer} - +
+ */}
); } diff --git a/website/src/routes/examples/shared.module.css b/website/src/routes/examples/shared.module.css index d3e62e9..8db6a3c 100644 --- a/website/src/routes/examples/shared.module.css +++ b/website/src/routes/examples/shared.module.css @@ -89,6 +89,23 @@ align-items: center; } +.DynamicRowAvatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + margin-right: 0.5rem; + flex: 0 0 auto; + line-height: 2.5rem; + text-align: center; +} + +.DynamicRowText { + flex: 1; + display: flex; + align-items: center; + cursor: pointer; +} + @media (max-width: 37.5rem) { .ExampleWrapper { padding: 0; diff --git a/website/yarn.lock b/website/yarn.lock index 5d1eb8c..ad055fa 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6077,6 +6077,12 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0" +lorem-ipsum@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/lorem-ipsum/-/lorem-ipsum-1.0.6.tgz#69e9ab02bbb0991915d71b5559fe016d526f013f" + dependencies: + minimist "~1.2.0" + loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -6294,7 +6300,7 @@ minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"