Skip to content

Compute totalSize and itemSizeAndPositionData at the start when using a fixed or array as itemSize #61

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ render(
| itemCount | Number | ✓ | The number of items you want to render |
| renderItem | Function | ✓ | Responsible for rendering an item given it's index: `({index: number, style: Object}): React.PropTypes.node`. The returned element must handle key and style. |
| itemSize | | ✓ | Either a fixed height/width (depending on the scrollDirection), an array containing the heights of all the items in your list, or a function that returns the height of an item given its index: `(index: number): number` |
| preCalculateTotalHeight | Bool (false) | ✓ | When true, pre calculate the total height of the container instead of estimating the heigh. Useful for wildly varying row heights. |
| scrollDirection | String | | Whether the list should scroll vertically or horizontally. One of `'vertical'` (default) or `'horizontal'`. |
| scrollOffset | Number | | Can be used to control the scroll offset; Also useful for setting an initial scroll offset |
| scrollToIndex | Number | | Item index to scroll to (by forcefully scrolling if necessary) x |
Expand Down
6 changes: 5 additions & 1 deletion demo/src/demo.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ header img {
margin-right: 15px;
}

.Root {
padding: 20px;
}

.VirtualList {
margin: 20px;
margin: 40px 0;
background: #FFF;
border-radius: 2px;
box-shadow:
Expand Down
116 changes: 104 additions & 12 deletions demo/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,121 @@ import * as ReactDOM from 'react-dom';
import VirtualList, {ItemStyle} from '../../src';
import './demo.css';

class Demo extends React.Component {
const range = N => Array.from({length: N}, (_, k) => k + 1);

class FixedItemSize extends React.Component {
renderItem = ({style, index}: {style: ItemStyle; index: number}) => {
return (
<div
className="Row"
style={{...style, backgroundColor: index % 2 ? '#eee' : 'white'}}
key={index}
>
Row #{index + 1}
</div>
);
};

render() {
return (
<VirtualList
width="auto"
height={400}
itemCount={10000}
renderItem={this.renderItem}
itemSize={50}
className="VirtualList"
/>
);
}
}

class ArrayItemSize extends React.Component<any, any> {
constructor(props: any) {
super(props);
this.state = {
items: range(10000).map(() => {
return Math.max(Math.ceil(Math.random() * 200), 50);
}),
};
}

renderItem = ({style, index}: {style: ItemStyle; index: number}) => {
return (
<div className="Row" style={style} key={index}>
Row #{index}
<div
className="Row"
style={{...style, backgroundColor: index % 2 ? '#eee' : 'white'}}
key={index}
>
Row #{index + 1}. Height: {this.state.items[index]}
</div>
);
};

render() {
return (
<VirtualList
width="auto"
height={400}
itemCount={this.state.items.length}
renderItem={this.renderItem}
itemSize={this.state.items}
className="VirtualList"
/>
);
}
}

class FunctionItemSize extends React.Component<any, any> {
constructor(props: any) {
super(props);
this.state = {
items: range(10000).map(() => {
return Math.max(Math.ceil(Math.random() * 100), 50);
}),
};
}

renderItem = ({style, index}: {style: ItemStyle; index: number}) => {
return (
<div
className="Row"
style={{...style, backgroundColor: index % 2 ? '#eee' : 'white'}}
key={index}
>
Row #{index + 1}. Height: {this.state.items[index]}
</div>
);
};

render() {
return (
<VirtualList
width="auto"
height={400}
itemCount={this.state.items.length}
renderItem={this.renderItem}
estimatedItemSize={75}
itemSize={index => this.state.items[index]}
className="VirtualList"
/>
);
}
}

class Demos extends React.Component {
render() {
return (
<div className="Root">
<VirtualList
width="auto"
height={400}
itemCount={1000}
renderItem={this.renderItem}
itemSize={50}
className="VirtualList"
/>
<h2>Fixed itemSize</h2>
<FixedItemSize />
<h2>Array itemSize (mixed heights)</h2>
<ArrayItemSize />
<h2>Function itemSize (just-in-time calculation)</h2>
<FunctionItemSize />
</div>
);
}
}

ReactDOM.render(<Demo />, document.querySelector('#app'));
ReactDOM.render(<Demos />, document.querySelector('#app'));
102 changes: 86 additions & 16 deletions src/SizeAndPositionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,22 @@ interface SizeAndPositionData {
}

export interface Options {
itemSize: ItemSize;
itemCount: number;
itemSizeGetter: ItemSizeGetter;
estimatedItemSize: number;
}

export default class SizeAndPositionManager {
private itemSizeGetter: ItemSizeGetter;
private itemCount: number;
private estimatedItemSize: number;
private itemSize: ItemSize;
private lastMeasuredIndex: number;
private justInTime: boolean;
private estimatedItemSize: number;
private totalSize?: number;
private itemSizeAndPositionData: SizeAndPositionData;

constructor({itemCount, itemSizeGetter, estimatedItemSize}: Options) {
this.itemSizeGetter = itemSizeGetter;
constructor({itemSize, itemCount, estimatedItemSize}: Options) {
this.itemSize = itemSize;
this.itemCount = itemCount;
this.estimatedItemSize = estimatedItemSize;

Expand All @@ -36,13 +38,11 @@ export default class SizeAndPositionManager {

// Measurements for items up to this index can be trusted; items afterward should be estimated.
this.lastMeasuredIndex = -1;

this.processConfig();
}

updateConfig({
itemCount,
itemSizeGetter,
estimatedItemSize,
}: Partial<Options>) {
updateConfig({itemSize, itemCount, estimatedItemSize}: Partial<Options>) {
if (itemCount != null) {
this.itemCount = itemCount;
}
Expand All @@ -51,9 +51,61 @@ export default class SizeAndPositionManager {
this.estimatedItemSize = estimatedItemSize;
}

if (itemSizeGetter != null) {
this.itemSizeGetter = itemSizeGetter;
if (itemSize != null) {
this.itemSize = itemSize;
}

this.processConfig();
}

/**
* This is called when the SizeAndPositionManager is created and updated.
*/
processConfig() {
const {itemSize} = this;

if (typeof itemSize === 'function') {
this.totalSize = undefined;
this.justInTime = true;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered making justInTime a getter? (since justInTime is derived based on the type of itemSize)

Assuming you did so, you'd probably be able to remove this method. I think the processConfig method is trying to handle too much currently, and the name isn't really indicative of what it does. Instead, I'd privilege explicitly calling computeTotalSizeAndPositionData and resetting this.totalSize where necessary.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah processConfig is a bit weird right now. I will take a look how to move this back in the constructor and updateConfig

} else {
this.justInTime = false;
this.computeTotalSizeAndPositionData();
}
}

/**
* Compute the totalSize and itemSizeAndPositionData at the start,
* when itemSize is a number or array.
*/
computeTotalSizeAndPositionData() {
const {itemSize, itemCount} = this;
const itemSizeIsArray = Array.isArray(itemSize);
const itemSizeIsNumber = typeof itemSize === 'number';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having a private itemSizeGetter method in SizeAndPositionManager would still be useful, as it would allow you to simplify the number of code paths you have below by normalizing the way you get the size of an item no matter the type of itemSize

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I will bring this back in!


let totalSize = 0;
for (let i = 0; i < itemCount; i++) {
let size;
if (itemSizeIsNumber) {
size = itemSize;
} else if (itemSizeIsArray) {
size = itemSize[i];

// Break when you are not supplying the same itemCount as available itemSizes.
if (typeof size === 'undefined') {
break;
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like SizeAndPositionManager should throw right away when initializing/updating the config if itemSize is an array and itemSize.length is different from itemCount

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call!

}

const offset = totalSize;
totalSize += size;

this.itemSizeAndPositionData[i] = {
offset,
size,
};
}

this.totalSize = totalSize;
}

getLastMeasuredIndex() {
Expand All @@ -62,7 +114,6 @@ export default class SizeAndPositionManager {

/**
* This method returns the size and position for the item at the specified index.
* It just-in-time calculates (or used cached values) for items leading up to the index.
*/
getSizeAndPositionForIndex(index: number) {
if (index < 0 || index >= this.itemCount) {
Expand All @@ -71,13 +122,26 @@ export default class SizeAndPositionManager {
);
}

if (this.justInTime) {
return this.getJustInTimeSizeAndPositionForIndex(index);
}

return this.itemSizeAndPositionData[index];
}

This comment was marked as resolved.


/**
* This is used when itemSize is a function.
* just-in-time calculates (or used cached values) for items leading up to the index.
*/
getJustInTimeSizeAndPositionForIndex(index: number) {
if (index > this.lastMeasuredIndex) {
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
const itemSizeGetter = this.itemSize as ItemSizeGetter;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a rule of thumb, you should avoid casting in TypeScript unless absolutely necessary. In this case, TypeScript should be able to infer the type of itemSizeGetter if you scoped it in an if statement block that checked if this.itemSize === function.

Assuming you choose to follow my suggestion above to have an itemSizeGetter method in SizeAndPositionManager, this wouldn't really be an issue since you'd have a normalized interface to get the size of any given item index.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will try and implement your comment above with always having the itemSizeGetter

let offset =
lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;

for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
const size = this.itemSizeGetter(i);
const size = itemSizeGetter(i);

if (size == null || isNaN(size)) {
throw Error(`Invalid size returned for index ${i} of value ${size}`);
Expand Down Expand Up @@ -105,10 +169,16 @@ export default class SizeAndPositionManager {

/**
* Total size of all items being measured.
* This value will be completedly estimated initially.
* As items as measured the estimate will be updated.
*/
getTotalSize(): number {
// Return the pre computed totalSize when itemSize is number or array.
if (this.totalSize) return this.totalSize;

/**
* When itemSize is a function,
* This value will be completedly estimated initially.
* As items as measured the estimate will be updated.
*/
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();

return (
Expand Down
18 changes: 3 additions & 15 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,9 @@ export default class VirtualList extends React.PureComponent<Props, State> {
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
};

itemSizeGetter = (itemSize: Props['itemSize']) => {
return index => this.getSize(index, itemSize);
};

sizeAndPositionManager = new SizeAndPositionManager({
itemCount: this.props.itemCount,
itemSizeGetter: this.itemSizeGetter(this.props.itemSize),
itemSize: this.props.itemSize,
estimatedItemSize: this.getEstimatedItemSize(),
});

Expand Down Expand Up @@ -189,7 +185,7 @@ export default class VirtualList extends React.PureComponent<Props, State> {

if (nextProps.itemSize !== itemSize) {
this.sizeAndPositionManager.updateConfig({
itemSizeGetter: this.itemSizeGetter(nextProps.itemSize),
itemSize: nextProps.itemSize,
});
}

Expand All @@ -199,7 +195,7 @@ export default class VirtualList extends React.PureComponent<Props, State> {
) {
this.sizeAndPositionManager.updateConfig({
itemCount: nextProps.itemCount,
estimatedItemSize: this.getEstimatedItemSize(nextProps),
// estimatedItemSize: this.getEstimatedItemSize(nextProps),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brainfart.

});
}

Expand Down Expand Up @@ -388,14 +384,6 @@ export default class VirtualList extends React.PureComponent<Props, State> {
);
}

private getSize(index: number, itemSize) {
if (typeof itemSize === 'function') {
return itemSize(index);
}

return Array.isArray(itemSize) ? itemSize[index] : itemSize;
}

private getStyle(index: number, sticky: boolean) {
const style = this.styleCache[index];

Expand Down
Loading