Iterated on DynamicSizeList

This commit is contained in:
Brian Vaughn
2018-10-10 18:05:46 +08:00
parent ec08b3a9fa
commit df974880ee
9 changed files with 321 additions and 168 deletions
+78
View File
@@ -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<any>,
size: number,
|};
export default class CellMeasurer extends Component<CellMeasurerProps, void> {
_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);
};
}
+114 -84
View File
@@ -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<any>,
|};
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<any>,
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<any>,
instanceProps: InstanceProps,
high: number,
low: number,
@@ -74,9 +105,9 @@ const findNearestItemBinarySearch = (
};
const getEstimatedTotalSize = (
{ itemCount }: Props,
{ itemCount }: Props<any>,
{
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<any>,
itemMetadata: ItemMetadata,
|};
class CellMeasurer extends Component<CellMeasurerProps, void> {
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<any>,
index: number,
instanceProps: InstanceProps
): number => getItemMetadata(props, index, instanceProps).offset,
getItemSize: (
props: Props,
props: Props<any>,
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<any>,
index: number,
align: ScrollToAlign,
scrollOffset: number,
@@ -190,7 +178,7 @@ const DynamicSizeList = createListComponent({
},
getStartIndexForOffset: (
props: Props,
props: Props<any>,
offset: number,
instanceProps: InstanceProps
): number => {
@@ -213,7 +201,7 @@ const DynamicSizeList = createListComponent({
},
getStopIndexForStartIndex: (
props: Props,
props: Props<any>,
startIndex: number,
scrollOffset: number,
instanceProps: InstanceProps
@@ -235,18 +223,31 @@ const DynamicSizeList = createListComponent({
return stopIndex;
},
initInstanceProps(props: Props, instance: any): InstanceProps {
initInstanceProps(props: Props<any>, 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<any>): void => {
if (process.env.NODE_ENV !== 'production') {
if (itemSize !== undefined) {
throw Error('An unexpected "itemSize" prop has been provided.');
-3
View File
@@ -231,9 +231,6 @@ export default function createListComponent({
height,
innerRef,
innerTagName,
itemCount,
itemData,
itemKey = defaultItemKey,
outerTagName,
style,
width,
+1
View File
@@ -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",
+12 -11
View File
@@ -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 }) => (
<div style={style}>items[index]</div>
);
<List
height={150}
itemCount={1000}
width={300}
>
{({ index, style }) => (
<div style={style}>
{items[index]}
</div>
)}
</List>
const Example = () => (
<List
height={150}
itemCount={1000}
width={300}
>
{Row}
</List>
);
+4 -3
View File
@@ -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<Props, void> {
_totalActualTime = 0;
render() {
const { className, children, sandbox } = this.props;
const { className, children, sandbox, style } = this.props;
if (isProfilingEnabled) {
return (
<div className={className}>
<div className={className} style={style}>
<Profiler id={sandbox || 'Profiler'} onRender={this._onRender}>
{children}
</Profiler>
@@ -46,7 +47,7 @@ export default class ProfiledExample extends PureComponent<Props, void> {
);
} else {
return (
<div className={className}>
<div className={className} style={style}>
{children}
<div className={styles.Row}>
<CodeSandboxLink sandbox={sandbox} />
+88 -66
View File
@@ -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 }) => (
<div
className={
index % 2 ? styles.DynamicListItemOdd : styles.DynamicListItemEven
}
style={style}
>
{index}: {items[index].paragraph}
</div>
);
class Row extends PureComponent {
state = {
isExpanded: true,
};
const itemColumnRenderer = ({ index, style }) => (
<div
className={cn(
index % 2 ? styles.DynamicListItemOdd : styles.DynamicListItemEven,
styles.ListColumn
)}
style={style}
>
{index}: {items[index].name}
</div>
);
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 (
<div
className={
index % 2 ? styles.DynamicListItemOdd : styles.DynamicListItemEven
}
style={{
display: 'flex',
...style,
}}
>
<div
className={styles.DynamicRowAvatar}
style={{
backgroundColor: item.colors[1],
color: item.colors[0],
}}
>
{index}
</div>
<div className={styles.DynamicRowText} onClick={this.toggleExpanded}>
{isExpanded ? item.paragraph : item.sentence}
</div>
</div>
);
}
}
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 (
<div className={styles.ExampleWrapper}>
<h1 className={styles.ExampleHeader}>Dynamic Size List</h1>
@@ -91,23 +105,30 @@ export default class ScrollToItem extends Component {
>
Scroll to 15,000px
</button>
<DynamicSizeList
<button
className={styles.ExampleButton}
onClick={this.handleToggleResize}
>
Resize list
</button>
<List
className={styles.List}
height={200}
itemCount={items.length}
ref={this.verticalListRef}
width={300}
width={halfSize ? '50%' : '100%'}
>
{itemRowRenderer}
</DynamicSizeList>
{Row}
</List>
</ProfiledExample>
<div className={styles.ExampleCode}>
<CodeBlock value={CODE_VERTICAL} />
</div>
</div>
{/*
<div className={styles.Example}>
<ProfiledExample className={styles.ExampleDemo}>
<DynamicSizeList
<List
className={styles.List}
direction="horizontal"
height={50}
@@ -116,12 +137,13 @@ export default class ScrollToItem extends Component {
width={300}
>
{itemColumnRenderer}
</DynamicSizeList>
</List>
</ProfiledExample>
<div className={styles.ExampleCode}>
<CodeBlock value={CODE_HORIZONTAL} />
</div>
</div>
*/}
</div>
);
}
@@ -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;
+7 -1
View File
@@ -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"