Last active
March 24, 2017 03:35
-
-
Save brentvatne/cea4e6ab9a9f04103b19 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @providesModule IncrementalListView | |
*/ | |
'use strict'; | |
var InteractionManager = require('InteractionManager'); | |
var LayoutAnimation = require('LayoutAnimation'); | |
var ListViewDataSource = require('ListViewDataSource'); | |
var RCTScrollViewManager = require('NativeModules').ScrollViewManager; | |
var React = require('React'); | |
var ScrollResponder = require('ScrollResponder'); | |
var ScrollView = require('ScrollView'); | |
var StaticRenderer = require('StaticRenderer'); | |
var TimerMixin = require('react-timer-mixin'); | |
var View = require('View'); | |
var isEmpty = require('isEmpty'); | |
var logError = require('logError'); | |
var merge = require('merge'); | |
var PropTypes = React.PropTypes; | |
var DEFAULT_PAGE_SIZE = 1; | |
var DEFAULT_INITIAL_ROWS = 6; | |
var DEFAULT_SCROLL_RENDER_AHEAD = 1000; | |
var DEFAULT_END_REACHED_THRESHOLD = 1000; | |
var DEFAULT_SCROLL_CALLBACK_THROTTLE = 50; | |
var SCROLLVIEW_REF = 'listviewscroll'; | |
var DEBUG = false; | |
InteractionManager.setDeadline(100); | |
var IncrementalListView = React.createClass({ | |
mixins: [ScrollResponder.Mixin, TimerMixin], | |
statics: { | |
DataSource: ListViewDataSource, | |
}, | |
propTypes: { | |
...ScrollView.propTypes, | |
dataSource: PropTypes.instanceOf(ListViewDataSource).isRequired, | |
renderSeparator: PropTypes.func, | |
renderRow: PropTypes.func.isRequired, | |
pageSize: PropTypes.number, | |
initialListSize: PropTypes.number, | |
onEndReached: PropTypes.func, | |
onEndReachedThreshold: PropTypes.number, | |
renderFooter: PropTypes.func, | |
renderHeader: PropTypes.func, | |
renderSectionHeader: PropTypes.func, | |
renderScrollComponent: React.PropTypes.func.isRequired, | |
scrollRenderAheadDistance: React.PropTypes.number, | |
onChangeVisibleRows: React.PropTypes.func, | |
removeClippedSubviews: React.PropTypes.bool, | |
stickyHeaderIndices: PropTypes.arrayOf(PropTypes.number), | |
}, | |
/** | |
* Exports some data, e.g. for perf investigations or analytics. | |
*/ | |
getMetrics: function() { | |
return { | |
contentLength: this.scrollProperties.contentLength, | |
totalRows: this.props.dataSource.getRowCount(), | |
renderedRows: this.state.curRenderedRowsCount, | |
visibleRows: Object.keys(this._visibleRows).length, | |
}; | |
}, | |
/** | |
* Provides a handle to the underlying scroll responder to support operations | |
* such as scrollTo. | |
*/ | |
getScrollResponder: function() { | |
return this.refs[SCROLLVIEW_REF] && | |
this.refs[SCROLLVIEW_REF].getScrollResponder && | |
this.refs[SCROLLVIEW_REF].getScrollResponder(); | |
}, | |
scrollTo: function(destY, destX) { | |
this.getScrollResponder().scrollResponderScrollTo({ x: destX || 0, y: destY || 0, animated: true }); | |
}, | |
setNativeProps: function(props) { | |
this.refs[SCROLLVIEW_REF].setNativeProps(props); | |
}, | |
/** | |
* React life cycle hooks. | |
*/ | |
getDefaultProps: function() { | |
return { | |
initialListSize: DEFAULT_INITIAL_ROWS, | |
pageSize: DEFAULT_PAGE_SIZE, | |
renderScrollComponent: props => <ScrollView {...props} />, | |
scrollRenderAheadDistance: DEFAULT_SCROLL_RENDER_AHEAD, | |
onEndReachedThreshold: DEFAULT_END_REACHED_THRESHOLD, | |
stickyHeaderIndices: [], | |
}; | |
}, | |
getInitialState: function() { | |
let initialListSize = Math.min(this.props.dataSource.getRowCount(), this.props.initialListSize); | |
return { | |
curRenderedRowsCount: initialListSize, | |
currentBatch: { | |
firstRow: 0, | |
lastRow: initialListSize > 0 ? initialListSize - 1 : 0, | |
numRows: initialListSize, | |
isComplete: false, | |
}, | |
highlightedRow: {}, | |
}; | |
}, | |
getInnerViewNode: function() { | |
return this.refs[SCROLLVIEW_REF].getInnerViewNode(); | |
}, | |
componentWillMount: function() { | |
if (this.props.initialListSize <= 0) { | |
throw new Error('Initial list size must be greater than 0'); | |
} | |
// this data should never trigger a render pass, so don't put in state | |
this.scrollProperties = { | |
visibleLength: null, | |
contentLength: null, | |
offset: 0 | |
}; | |
this._childFrames = []; | |
this._visibleRows = {}; | |
this._prevRenderedRowsCount = 0; | |
this._sentEndForContentLength = null; | |
}, | |
componentDidMount: function() { | |
// do this in animation frame until componentDidMount actually runs after | |
// the component is laid out | |
this.requestAnimationFrame(() => { | |
this._measureAndUpdateScrollProps(); | |
}); | |
}, | |
componentWillReceiveProps: function(nextProps) { | |
if (this.props.dataSource !== nextProps.dataSource) { | |
this._prevRenderedRowsCount = 0; | |
this._pageInNewRows(); | |
} | |
}, | |
shouldComponentUpdate(nextProps, nextState) { | |
if (this.__hackyDoNotUpdate) { | |
this.__hackyDoNotUpdate = false; | |
return false; | |
} | |
return true; | |
}, | |
hasRenderedAllRows() { | |
return this.state.curRenderedRowsCount === this.props.dataSource.getRowCount(); | |
}, | |
componentDidUpdate: function() { | |
this.requestAnimationFrame(() => { | |
this._measureAndUpdateScrollProps(); | |
}); | |
}, | |
onRowHighlighted: function(sectionID, rowID) { | |
this.setState({highlightedRow: {sectionID, rowID}}); | |
}, | |
render: function() { | |
var bodyComponents = []; | |
var dataSource = this.props.dataSource; | |
var allRowIDs = dataSource.rowIdentities; | |
var rowCount = 0; | |
var sectionHeaderIndices = []; | |
var header = this.props.renderHeader && this.props.renderHeader(); | |
var footer = this.props.renderFooter && this.props.renderFooter(); | |
var totalIndex = header ? 1 : 0; | |
for (var sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { | |
var sectionID = dataSource.sectionIdentities[sectionIdx]; | |
var rowIDs = allRowIDs[sectionIdx]; | |
if (rowIDs.length === 0) { | |
continue; | |
} | |
if (this.props.renderSectionHeader) { | |
var shouldUpdateHeader = rowCount >= this._prevRenderedRowsCount && | |
dataSource.sectionHeaderShouldUpdate(sectionIdx); | |
bodyComponents.push( | |
<StaticRenderer | |
key={'s_' + sectionID} | |
shouldUpdate={!!shouldUpdateHeader} | |
render={this.props.renderSectionHeader.bind( | |
null, | |
dataSource.getSectionHeaderData(sectionIdx), | |
sectionID | |
)} | |
/> | |
); | |
sectionHeaderIndices.push(totalIndex++); | |
} | |
for (var rowIdx = 0; rowIdx < rowIDs.length; rowIdx++) { | |
var rowID = rowIDs[rowIdx]; | |
var comboID = sectionID + '_' + rowID; | |
var key = 'r_' + comboID; | |
var shouldUpdateRow = rowCount >= this._prevRenderedRowsCount && | |
dataSource.rowShouldUpdate(sectionIdx, rowIdx); | |
var row = | |
<StaticRenderer | |
key={key} | |
shouldUpdate={!!shouldUpdateRow} | |
render={this._renderRow.bind( | |
this, | |
key, | |
rowIdx, | |
sectionIdx, | |
sectionID, | |
rowID, | |
this.onRowHighlighted | |
)} | |
/>; | |
bodyComponents.push(row); | |
totalIndex++; | |
if (this.props.renderSeparator && | |
(rowIdx !== rowIDs.length - 1 || sectionIdx === allRowIDs.length - 1)) { | |
var adjacentRowHighlighted = | |
this.state.highlightedRow.sectionID === sectionID && ( | |
this.state.highlightedRow.rowID === rowID || | |
this.state.highlightedRow.rowID === rowIDs[rowIdx + 1] | |
); | |
var separator = this._renderSeparator( | |
rowIdx, | |
sectionIdx, | |
sectionID, | |
rowID, | |
adjacentRowHighlighted | |
); | |
bodyComponents.push(separator); | |
totalIndex++; | |
} | |
if (++rowCount === this.state.curRenderedRowsCount) { | |
break; | |
} | |
} | |
if (rowCount >= this.state.curRenderedRowsCount) { | |
break; | |
} | |
} | |
var { | |
renderScrollComponent, | |
...props, | |
} = this.props; | |
if (!props.scrollEventThrottle) { | |
props.scrollEventThrottle = DEFAULT_SCROLL_CALLBACK_THROTTLE; | |
} | |
if (props.removeClippedSubviews === undefined) { | |
props.removeClippedSubviews = true; | |
} | |
Object.assign(props, { | |
onScroll: this._onScroll, | |
stickyHeaderIndices: this.props.stickyHeaderIndices.concat(sectionHeaderIndices), | |
// Do not pass these events downstream to ScrollView since they will be | |
// registered in ListView's own ScrollResponder.Mixin | |
onKeyboardWillShow: undefined, | |
onKeyboardWillHide: undefined, | |
onKeyboardDidShow: undefined, | |
onKeyboardDidHide: undefined, | |
}); | |
// TODO(ide): Use function refs so we can compose with the scroll | |
// component's original ref instead of clobbering it | |
return React.cloneElement(renderScrollComponent(props), { | |
ref: SCROLLVIEW_REF, | |
onContentSizeChange: this._onContentSizeChange, | |
onLayout: this._onLayout, | |
}, header, bodyComponents, footer); | |
}, | |
/** | |
* Private methods | |
*/ | |
_isBatchComplete(rowIdx) { | |
if (rowIdx < this.state.currentBatch.firstRow || rowIdx > this.state.currentBatch.lastRow) { | |
return true; | |
} else { | |
return this.state.currentBatch.isComplete; | |
} | |
}, | |
_onRenderRow(rowIdx, isLastRow) { | |
if (isLastRow) { | |
InteractionManager.runAfterInteractions({ | |
name: 'Present batch', | |
gen: () => new Promise(resolve => { | |
this.__hackyDoNotUpdate = true; | |
this.setState((state) => ({ | |
currentBatch: { | |
...state.currentBatch, | |
isComplete: true, | |
}, | |
})); | |
// Use setNativeProps to present the batch rather than a full | |
// re-render, less jank | |
this._presentCurrentBatch(); | |
this.props.onPresentBatch && this.props.onPresentBatch(); | |
resolve(); | |
}), | |
}); | |
} | |
}, | |
_presentCurrentBatch() { | |
let { | |
firstRow, | |
lastRow, | |
isPrepending, | |
} = this.state.currentBatch; | |
var dataSource = this.props.dataSource; | |
var sectionIdx = 0; | |
if (dataSource.sectionIdentities.length > 1) { | |
console.warn('You cannot have more than one section with IncrementalListView right now!'); | |
} | |
// Fade the batch in | |
LayoutAnimation.linear(); | |
var allRowIDs = this.props.dataSource.rowIdentities[0]; | |
for (var i = firstRow; i <= lastRow; i++) { | |
let rowID = allRowIDs[i]; | |
this._separatorRefs && this._separatorRefs[rowID] && this._separatorRefs[rowID].batchIsComplete(); | |
this._rowRefs && this._rowRefs[rowID] && this._rowRefs[rowID].batchIsComplete(); | |
} | |
// Fire onPrependRows or onAppendRows callback | |
let callbackOpts = { | |
numRows: this.state.currentBatch.numRows, | |
scrollProperties: this.scrollProperties, | |
lastScrollTimeStamp: this._lastScrollTimeStamp, | |
} | |
if (isPrepending) { | |
requestAnimationFrame(() => { | |
this.props.onPrependRows && this.props.onPrependRows(callbackOpts); | |
}); | |
} else { | |
requestAnimationFrame(() => { | |
this.props.onAppendRows && this.props.onAppendRows(callbackOpts); | |
}); | |
} | |
}, | |
_renderSeparator(rowIdx, sectionIdx, sectionID, rowID, adjacentRowHighlighted) { | |
this._separatorRefs = this._separatorRefs || {}; | |
return ( | |
<IncrementalSeparatorRenderer | |
isBatchComplete={this._isBatchComplete(rowIdx)} | |
key={'sp_' + rowID} | |
ref={view => { this._separatorRefs[rowID] = view; }} | |
render={this.props.renderSeparator.bind(null, sectionID, rowID, adjacentRowHighlighted)} | |
synchronous={this.state.currentBatch.firstRow === 0} | |
/> | |
); | |
}, | |
_renderRow(key, rowIdx, sectionIdx, sectionID, rowID, onRowHighlighted) { | |
this._rowRefs = this._rowRefs || {}; | |
let rowData = this.props.dataSource.getRowData(sectionIdx, rowIdx); | |
let sectionLength = this.props.dataSource.getSectionLengths()[sectionIdx]; | |
let isLastRow = rowIdx === this.state.currentBatch.lastRow; | |
return ( | |
<IncrementalRowRenderer | |
isBatchComplete={this._isBatchComplete(rowIdx)} | |
onRender={() => { this._onRenderRow(rowIdx, isLastRow) }} | |
ref={view => { this._rowRefs[rowID] = view; }} | |
render={this.props.renderRow.bind(null, rowData, sectionID, rowID, onRowHighlighted)} | |
rowID={rowID} | |
synchronous={this.state.currentBatch.firstRow === 0} | |
/> | |
); | |
}, | |
_measureAndUpdateScrollProps: function() { | |
var scrollComponent = this.getScrollResponder(); | |
if (!scrollComponent || !scrollComponent.getInnerViewNode) { | |
return; | |
} | |
}, | |
_onContentSizeChange: function(width, height) { | |
var contentLength = !this.props.horizontal ? height : width; | |
if (contentLength !== this.scrollProperties.contentLength) { | |
this.scrollProperties.contentLength = contentLength; | |
this._renderMoreRowsIfNeeded(); | |
} | |
this.props.onContentSizeChange && this.props.onContentSizeChange(width, height); | |
}, | |
_onLayout: function(event) { | |
var {width, height} = event.nativeEvent.layout; | |
var visibleLength = !this.props.horizontal ? height : width; | |
if (visibleLength !== this.scrollProperties.visibleLength) { | |
this.scrollProperties.visibleLength = visibleLength; | |
this._renderMoreRowsIfNeeded(); | |
} | |
this.props.onLayout && this.props.onLayout(event); | |
}, | |
_maybeCallOnEndReached: function(event) { | |
if (this.props.onEndReached && | |
this.scrollProperties.contentLength !== this._sentEndForContentLength && | |
this._getDistanceFromEnd(this.scrollProperties) < this.props.onEndReachedThreshold && | |
this.state.curRenderedRowsCount === this.props.dataSource.getRowCount()) { | |
this._sentEndForContentLength = this.scrollProperties.contentLength; | |
this.props.onEndReached(event); | |
return true; | |
} | |
return false; | |
}, | |
_renderMoreRowsIfNeeded: function() { | |
if (this.scrollProperties.contentLength === null || | |
this.scrollProperties.visibleLength === null || | |
this.state.curRenderedRowsCount === this.props.dataSource.getRowCount()) { | |
this._maybeCallOnEndReached(); | |
return; | |
} | |
var distanceFromEnd = this._getDistanceFromEnd(this.scrollProperties); | |
if (distanceFromEnd < this.props.scrollRenderAheadDistance) { | |
this._pageInNewRows(); | |
} | |
}, | |
_pageInNewRows: function() { | |
if (!this.state.currentBatch.isComplete) { | |
return false; | |
} | |
this.setState((state, props) => { | |
// If our curRenderedRowsCount is greater than | |
// dataSource count, we need to limit it | |
var currentRows = Math.min( | |
props.dataSource.getRowCount(), | |
this.state.curRenderedRowsCount, | |
); | |
var rowsToRender = Math.min( | |
currentRows + props.pageSize, | |
props.dataSource.getRowCount() | |
); | |
var numRowsAdded = Math.max( | |
0, | |
rowsToRender - state.curRenderedRowsCount | |
); | |
this._prevRenderedRowsCount = state.curRenderedRowsCount; | |
this._currentBatchCount = 0; | |
// The problem here comes from the fact that firstRow and | |
// lastRow are not necessarily reflecting where they are in the | |
// ListView: firstRow can be row 0 and lastRow row 5 when adding | |
// top the top of a list, or it can be 50 and 60 respectively | |
// when adding to the bottom. | |
// | |
// The current way to get around this is to check if we have | |
// already rendered the first row in the dataSource, if not | |
// then we are prepending; if we have already rendered | |
// the first row then we are appending. | |
// | |
let firstRow = 0; | |
let lastRow = 0; | |
let firstRowID = props.dataSource.rowIdentities[0][0]; | |
let isPrepending = false; | |
if (this._rowRefs[firstRowID]) { | |
firstRow = Math.max(0, currentRows); | |
lastRow = Math.max(0, currentRows - 1 + numRowsAdded); | |
} else { | |
for (var i = 1; i < props.dataSource.rowIdentities[0].length; i++) { | |
let currRowID = props.dataSource.rowIdentities[0][i]; | |
if (!this._rowRefs[currRowID]) { | |
lastRow = lastRow + 1; | |
} else { | |
break; | |
} | |
} | |
isPrepending = true; | |
numRowsAdded = lastRow - firstRow + 1; | |
} | |
let nextState = { | |
currentBatch: { | |
firstRow, | |
lastRow, | |
numRows: numRowsAdded, | |
isComplete: firstRow === lastRow, | |
isPrepending, | |
}, | |
curRenderedRowsCount: rowsToRender | |
}; | |
// console.log({nextState}); | |
return nextState; | |
}, () => { | |
this._measureAndUpdateScrollProps(); | |
this._prevRenderedRowsCount = this.state.curRenderedRowsCount; | |
}); | |
}, | |
_getDistanceFromEnd: function(scrollProperties) { | |
var maxLength = Math.max( | |
scrollProperties.contentLength, | |
scrollProperties.visibleLength | |
); | |
return maxLength - scrollProperties.visibleLength - scrollProperties.offset; | |
}, | |
_onScroll: function(e) { | |
// RecyclerViewBackedScrollView doesn't support all scroll events so we | |
// will just onScroll timestamps to determine this when necessary | |
this._lastScrollTimeStamp = e.timeStamp; | |
var isVertical = !this.props.horizontal; | |
this.scrollProperties.visibleLength = e.nativeEvent.layoutMeasurement[ | |
isVertical ? 'height' : 'width' | |
]; | |
this.scrollProperties.contentLength = e.nativeEvent.contentSize[ | |
isVertical ? 'height' : 'width' | |
]; | |
this.scrollProperties.offset = e.nativeEvent.contentOffset[ | |
isVertical ? 'y' : 'x' | |
]; | |
if (!this._maybeCallOnEndReached(e)) { | |
this._renderMoreRowsIfNeeded(); | |
} | |
if (this.props.onEndReached && | |
this._getDistanceFromEnd(this.scrollProperties) > this.props.onEndReachedThreshold) { | |
// Scrolled out of the end zone, so it should be able to trigger again. | |
this._sentEndForContentLength = null; | |
} | |
this.props.onScroll && this.props.onScroll(e); | |
}, | |
}); | |
class IncrementalSeparatorRenderer extends React.Component { | |
render() { | |
let { | |
isBatchComplete, | |
synchronous, | |
} = this.props; | |
return ( | |
<View | |
ref={view => { this._view = view; }} | |
style={isBatchComplete || synchronous ? {} : {position: 'absolute', left: 0, right: 0, opacity: 0}}> | |
{this.props.render()} | |
</View> | |
); | |
} | |
batchIsComplete() { | |
if (this._view) { | |
this._view.setNativeProps({style: {position: 'relative', opacity: 1}}); | |
} | |
} | |
} | |
class IncrementalRowRenderer extends React.Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
shouldRender: this.props.synchronous, | |
}; | |
} | |
componentWillReceiveProps(nextProps) { | |
if (nextProps.batchIsComplete && !this.state.shouldRender) { | |
this.setState({shouldRender: true}); | |
} | |
} | |
batchIsComplete() { | |
if (this._view) { | |
this._view.setNativeProps({style: {position: 'relative', opacity: 1}}); | |
} | |
} | |
componentDidMount() { | |
this._scheduleRender(); | |
} | |
render() { | |
if (this.state.shouldRender) { | |
return ( | |
<View | |
removeClippedSubviews | |
ref={view => { this._view = view; }} | |
style={(!this.props.isBatchComplete && !this.props.synchronous) && {position: 'absolute', left: 0, right: 0, opacity: 0}}> | |
{this.props.render()} | |
</View> | |
); | |
} else { | |
return null; | |
} | |
} | |
_scheduleRender() { | |
InteractionManager.runAfterInteractions({ | |
name: 'IncrementalListView row: ' + this.props.rowID, | |
gen: () => new Promise(resolve => { | |
this.setState({shouldRender: true}, resolve); | |
}), | |
}).then(() => { | |
this.props.onRender && this.props.onRender(); | |
}); | |
} | |
} | |
module.exports = IncrementalListView; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment