Skip to content

Instantly share code, notes, and snippets.

@sahrens
Created October 15, 2015 17:39
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sahrens/bf41095cb64526d2a56f to your computer and use it in GitHub Desktop.
Save sahrens/bf41095cb64526d2a56f to your computer and use it in GitHub Desktop.
Early RecyclingListView prototype
/**
* Copyright 2004-present Facebook. All Rights Reserved.
*
* @providesModule RecyclingListView
* @flow
*/
'use strict';
var React = require('React');
var ScrollView = require('ScrollView');
var Text = require('Text');
var View = require('View');
var TRAILING_BUFFER = 2; // TODO: adaptive
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 + 1000) {} // 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,
};
/**
* A windowed ListView for memory savings and perf. Only a fixed number of rows are rendered. By default,
* row components are recycled when they go off screen, which breaks useless features like `setState`.
* `disableRecycling` will keep the windowing but create/destroy rows - state will be lost, but at least
* won't be garbled?
*
* I have an idea for a `RecyclableMixin` or wrapper component that would allow `setState` to work
* transparently, but `state` would need to be accessed via `getState`.
*/
class RecyclingListView extends React.Component {
_rowCache: Array<number>;
constructor(props: Object) {
super(props);
this.onScroll = this.onScroll.bind(this);
this.lastScrollEvent = {rowFrames: []};
this.numToRender = this.props.numToRender;
this.computeRowsToRender = this.computeRowsToRender.bind(this);
this.computeRowsToRenderSync = this.computeRowsToRenderSync.bind(this);
this.scrollOffsetY = 0;
this.rowFrames = [];
this.calledOnEndReached = false;
this.state = {
firstRow: 0,
lastRow: Math.min(this.numToRender, this.props.data.length) - 1,
};
}
getScrollResponder(): ?ReactComponent {
return this.scrollRef &&
this.scrollRef.getScrollResponder &&
this.scrollRef.getScrollResponder();
}
onScroll(e: Object) {
var scrollOffsetY = e.nativeEvent.contentOffset.y;
if (this.scrollOffsetY !== scrollOffsetY) {
this.scrollOffsetY = scrollOffsetY;
this.computeRowsToRender();
}
}
componentWillReceiveProps() {
this.computeRowsToRender();
}
_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.props.data.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, numTorender: this.numToRender});
}
var firstRow = Math.max((firstVisible || 0) - TRAILING_BUFFER, 0);
var lastRow = Math.min(firstRow + this.numToRender - 1, totalRows - 1);
if (lastRow - firstRow < this.numToRender - 1) {
// If we're close to the bottom, we still want to render numToRender rows
firstRow = Math.max(0, lastRow - this.numToRender + 1);
}
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.numToRender && firstRow !== 0 && this.numToRender < totalRows) {
console.warn('Should always render same number of rows for optimal perf.');
}
console.log('lastRow: ' + lastRow);
// console.log('computedRows', {count, firstVisible, firstRow, lastRow, 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;
if (this.props.disableRecycling) {
fullRowCount += firstRow; // Still only renders fixed number of rows, but doesn't reuse react keys
}
for (var idx = firstRow; idx <= lastRow; idx++) {
height += rowFrames[idx] ? rowFrames[idx].height : avgHeight;
var data = this.props.data[idx];
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.renderRow.bind(
null, data, 0, idx, key
)}
/>
);
this._rowCache[key] = data;
rowsMeta.push({key, idx, savedFrame: rowFrames[idx], dataId: data.__dataID__, shouldUpdate});
}
if (this.props.renderFooter) {
rows.push(this.props.renderFooter());
}
if (lastRow && height) {
avgHeight = height / lastRow;
}
height = (this.props.data.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, firstVisible: this.firstVisible, countAll: rows.length});
return (
<ScrollView
scrollEventThrottle={50}
removeClippedSubviews={true}
{...this.props}
ref={(ref) => { this.scrollRef = ref; }}
onScroll={this.onScroll}>
{rows}
</ScrollView>
);
}
}
RecyclingListView.propTypes = {
data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
renderRow: React.PropTypes.func.isRequired,
disableRecycling: React.PropTypes.bool, // destroy offscreen rows instead of recycling. Means you can use setState normally.
};
RecyclingListView.defaultProps = {
numToRender: 10,
};
module.exports = RecyclingListView;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment