Skip to content

Instantly share code, notes, and snippets.

@sahrens
Created October 1, 2015 04:16
Show Gist options
  • Save sahrens/d1802e7a96b4c261f0b5 to your computer and use it in GitHub Desktop.
Save sahrens/d1802e7a96b4c261f0b5 to your computer and use it in GitHub Desktop.
MultiRowListView is an experimental ListView with two potential optimizations - incremental row rendering via subrows, and React row recycling.
/**
* Copyright 2004-present Facebook. All Rights Reserved.
*
* @providesModule MultiRowListView
* @flow
*/
'use strict';
var React = require('React');
var ScrollView = require('ScrollView');
var Text = require('Text');
var View = require('View');
var DEBUG = false;
class CellRenderer extends React.Component {
shouldComponentUpdate(newProps) {
return newProps.shouldUpdate;
}
render() {
var debug = DEBUG && <Text style={{backgroundColor: 'lightblue'}}>
React Key: {this.props.reactKey},
Row: {this.props.rowIndex},
Id: {this.props.data.__dataID__}
</Text>;
// var start = Date.now();
// while (Date.now() < start + 50) {} // burn cpu to test perf effect.
return (
<View
ref={(ref) => this.viewRef = ref}
onLayout={(e) => {
this.props.onMeasure(e.nativeEvent.layout);
}}>
{debug}
{this.props.render()}
</View>
);
}
}
CellRenderer.propTypes = {
shouldUpdate: React.PropTypes.bool,
render: React.PropTypes.func,
};
class MultiRowListView extends React.Component {
_rowCache: Array<number>;
constructor(props: Object) {
super(props);
this.onScroll = this.onScroll.bind(this);
this.computeRowReindexes = this.computeRowReindexes.bind(this);
this.computeRowsToRender = this.computeRowsToRender.bind(this);
this.computeRowsToRenderSync = this.computeRowsToRenderSync.bind(this);
this.scrollOffsetY = 0;
this.rowFrames = [];
this.calledOnEndReached = false;
this.computeRowReindexes(props);
this.workingSetSubrowCount = props.workingSetSubrowCount;
this.state = {
firstRow: 0,
lastRow: Math.min(this.workingSetSubrowCount, this.rowReindexes.length) - 1,
};
}
getScrollResponder(): ?ReactComponent {
return this.scrollRef &&
this.scrollRef.getScrollResponder &&
this.scrollRef.getScrollResponder();
}
onScroll(e: Object) {
var scrollOffsetY = e.nativeEvent.contentOffset.y;
if (Math.abs(this.scrollOffsetY - scrollOffsetY) > 50) {
this.scrollOffsetY = scrollOffsetY;
this.computeRowsToRender();
}
}
componentWillReceiveProps(newProps: Object) {
this.computeRowReindexes(newProps);
this.computeRowsToRender();
}
computeRowReindexes(props: Object) {
var dataIdx = 0;
var subrowIdx = 0;
this.rowReindexes = [];
for (var dataIdx = 0; dataIdx < props.data.length; dataIdx++) {
var numSubrows = props.getSubrowCountForRow(props.data[dataIdx]);
for (var subrowIdx = 0; subrowIdx < numSubrows; subrowIdx++) {
this.rowReindexes.push({dataIdx, subrowIdx});
}
}
DEBUG && console.log('computeRowReindexes, length: ' + this.rowReindexes.length);
}
_onMeasure(idx: number, layout: Object) {
if (!this.rowFrames[idx] ||
layout.height !== this.rowFrames[idx].height ||
layout.y !== this.rowFrames[idx].y) {
this.rowFrames[idx] = {...layout};
this.computeRowsToRender();
}
}
computeRowsToRender(): void {
if (!this.willComputeRowsToRender) {
this.willComputeRowsToRender = true; // batch up computations
setImmediate(this.computeRowsToRenderSync);
}
}
computeRowsToRenderSync(): void {
this.willComputeRowsToRender = false;
var rowFrames = this.rowFrames;
var totalRows = this.rowReindexes.length;
var firstVisible;
var top = this.scrollOffsetY;
for (var idx = 0; idx < rowFrames.length; idx++) {
var frame = rowFrames[idx];
if (!frame) {
console.warn('waa? No frame :( Should come soon.');
return;
}
if ((frame.y + frame.height) > top) {
firstVisible = idx;
break;
}
}
if (firstVisible === undefined) {
firstVisible = rowFrames.length;
console.warn('overrun', {firstVisible, num2render: this.workingSetSubrowCount});
}
var firstRow = Math.max((firstVisible || 0) - this.props.topBufferSubrowCount, 0);
var lastRow = Math.min(firstRow + this.workingSetSubrowCount - 1, totalRows - 1);
if (lastRow - firstRow < this.workingSetSubrowCount - 1) {
// If we're close to the bottom, we still want to render numToRender rows
firstRow = Math.max(0, lastRow - this.workingSetSubrowCount + 1);
}
if (!this.props.recycleSubrows) {
firstRow = 0;
}
var rowCountIncrease = (lastRow - firstRow) - (this.state.lastRow - this.state.firstRow);
if (rowCountIncrease > this.props.pageSize) {
lastRow += (this.props.pageSize - rowCountIncrease);
} else if (rowCountIncrease < 0) {
console.warn('negative increase!', {firstRow, lastRow, totalRows, rowCountIncrease});
// lastRow = totalRows - 1; // Sometimes the data will shrink on us.
if (firstRow > lastRow) {
console.warn('first row got ahead of last row!');
firstRow = lastRow;
}
}
if (this.props.onEndReached) {
// This logic is a little tricky to make sure we call onEndReached exactly
// once every time we reach the end.
var willBeAtTheEnd = lastRow === (totalRows - 1);
if (willBeAtTheEnd && !this.calledOnEndReached) {
this.props.onEndReached();
this.calledOnEndReached = true;
} else {
// If the last row is changing, then we haven't called onEndReached for
// the new end.
this.calledOnEndReached = this.state.lastRow === lastRow;
}
}
if (this.state.firstRow !== firstRow || this.state.lastRow !== lastRow) {
var count = lastRow - firstRow + 1;
if (count !== this.workingSetSubrowCount && firstRow !== 0 && this.workingSetSubrowCount < totalRows) {
console.warn('Should always render same number of rows for optimal ' +
'perf. Rendered ' + count + ' with target ' +
this.workingSetSubrowCount);
}
DEBUG && (rowCountIncrease = ((lastRow - firstRow) - (this.state.lastRow - this.state.firstRow)));
DEBUG && console.log('setState new row indexes', {rowCountIncrease, firstRow, lastRow, count, firstVisible, top});
this.setState({firstRow, lastRow});
}
}
render() {
this._rowCache = this._rowCache || [];
var firstRow = this.state.firstRow;
var lastRow = this.state.lastRow;
var rowFrames = this.rowFrames;
var rows = [];
var height = 0;
var avgHeight = 100;
if (rowFrames[firstRow]) {
var firstFrame = rowFrames[firstRow];
height = firstFrame.y;
avgHeight = height / (firstRow + 1); // TODO: use the last row with a frame
// console.log('compute avgHeight1', {avgHeight, height, firstRow});
}
var rowsMeta = [];
var topHeight = height;
rows.push(<View key={'sp-top'} style={{height}} />);
var fullRowCount = lastRow - firstRow + 1;
for (var idx = firstRow; idx <= lastRow; idx++) {
height += rowFrames[idx] ? rowFrames[idx].height : avgHeight;
if (!this.rowReindexes[idx]) {
console.warn(
'Data missing...',
{idx, rowReindexes: this.rowReindexes, firstRow, lastRow}
);
break;
}
var data = this.props.data[this.rowReindexes[idx].dataIdx];
var key = idx % fullRowCount;
var shouldUpdate = data !== this._rowCache[key];
rows.push(
<CellRenderer
key={key}
reactKey={key}
rowIndex={idx}
data={data}
onMeasure={this._onMeasure.bind(this, idx)}
shouldUpdate={shouldUpdate}
render={this.props.renderSubrow.bind(
null, data, this.rowReindexes[idx].subrowIdx
)}
/>
);
this._rowCache[key] = data;
rowsMeta.push({key, idx, savedFrame: rowFrames[idx], shouldUpdate});
}
if (this.props.renderFooter) {
rows.push(this.props.renderFooter());
}
if (lastRow && height) {
avgHeight = height / lastRow;
}
height = (this.rowReindexes.length - lastRow - 1) * avgHeight; // This allows overrun for huge lists - maybe we don't want this?
rows.push(<View key={'sp-bottom'} style={{height}} />);
DEBUG && console.log('rec list render', {/*rowsMeta,*/ avgHeight, topHeight, botHeight: height, /*rowFrames,*/ firstRow, lastRow, countAll: rows.length});
return (
<ScrollView
{...this.props}
ref={(ref) => { this.scrollRef = ref; }}
onScroll={this.onScroll}
scrollEventThrottle={50}>
{rows}
</ScrollView>
);
}
}
MultiRowListView.propTypes = {
/**
* Simple array of data blobs, one per row. renderSubrow will be called with
* the same data blob for all the subrows that belong to the same row.
*/
data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
/**
* `getSubrowCountForRow(rowData: any): number`
*/
getSubrowCountForRow: React.PropTypes.func.isRequired,
/**
* `renderSubrow(rowData: any, subrowIndex: number): ReactElement`
*/
renderSubrow: React.PropTypes.func.isRequired,
/**
* How many subrows to render above the viewport.
*/
topBufferSubrowCount: React.PropTypes.number,
/**
* WARNING: EXPERIMENTAL
*
* Whether react row components should be recycled. Currently breaks any usage
* of setState or instance variables anywhere within the row components or
* their children.
*
* It's recommended that you always return the same number of subrows for
* every row (`getSubrowCountForRow` should return a constant) and provide
* zero-height views if you don't have anything to render for a particular
* subrow index to make recycling more efficient.
*/
recycleSubrows: React.PropTypes.bool,
};
MultiRowListView.defaultProps = {
pageSize: 1,
workingSetSubrowCount: 20,
recycleSubrows: false,
topBufferSubrowCount: 4,
};
module.exports = MultiRowListView;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment