diff --git a/Libraries/CustomComponents/ListView/THFeedListView.js b/Libraries/CustomComponents/ListView/THFeedListView.js new file mode 100644 index 000000000..e473a2e8e --- /dev/null +++ b/Libraries/CustomComponents/ListView/THFeedListView.js @@ -0,0 +1,443 @@ +/** + * Copyright (c) 2015, Facebook, Inc. All rights reserved. + * + * Facebook, Inc. ("Facebook") owns all right, title and interest, including + * all intellectual property and other proprietary rights, in and to the React + * Native CustomComponents software (the "Software"). Subject to your + * compliance with these terms, you are hereby granted a non-exclusive, + * worldwide, royalty-free copyright license to (1) use and copy the Software; + * and (2) reproduce and distribute the Software as part of your own software + * ("Your Software"). Facebook reserves all rights not expressly granted to + * you in this license agreement. + * + * THE SOFTWARE AND DOCUMENTATION, IF ANY, ARE PROVIDED "AS IS" AND ANY EXPRESS + * OR IMPLIED WARRANTIES (INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE) ARE DISCLAIMED. + * IN NO EVENT SHALL FACEBOOK OR ITS AFFILIATES, OFFICERS, DIRECTORS OR + * EMPLOYEES BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * @providesModule THFeedListView + */ +'use strict'; + +var ListViewDataSource = require('ListViewDataSource'); +var React = require('React'); +var RCTScrollViewManager = require('NativeModules').ScrollViewManager; +var ScrollView = require('ScrollView'); +var ScrollResponder = require('ScrollResponder'); +var StaticRenderer = require('StaticRenderer'); +var TimerMixin = require('TimerMixin'); + +var isEmpty = require('isEmpty'); +var logError = require('logError'); +var merge = require('merge'); +var performanceNow = require('performanceNow'); + +var PropTypes = React.PropTypes; + +var DEFAULT_PAGE_SIZE = 1; +var DEFAULT_INITIAL_ROWS = 10; +var DEFAULT_END_REACHED_THRESHOLD = 1000; +var DEFAULT_SCROLL_CALLBACK_THROTTLE = 50; +var DEFAULT_TICK_FREQUENCY = 200; +var DEFAULT_ROWS_YIELD_TIME = 100; +var DEFAULT_END_SCROLL_TIMEOUT = 100; +var SCROLLVIEW_REF = 'listviewscroll'; + +/** + * Simpler version of `ListView`, optimized for smooth scrolling in feed. This is + * achieved by skipping unecessary processing caused by race condition (with paging + * and handling view resize callback) and, more importantly, handling heavier + * computation (i.e rendering more rows, possibly fetching more data when end of + * feed is reached) only once current scroll gesture completes. + * + * Internally, listview uses an periodic tick callback that will render ahead each time + * additionl row(s), the number of which is controlled by `pageSize` props, until + * no more rows are available in data source. + * + */ + +var THFeedListView = React.createClass({ + mixins: [ScrollResponder.Mixin, TimerMixin], + + statics: { + DataSource: ListViewDataSource, + }, + + /** + * You must provide a renderRow function. If you omit any of the other render + * functions, ListView will simply skip rendering them. + * + * - renderRow(rowData, sectionID, rowID, highlightRow); + */ + propTypes: { + ...ScrollView.propTypes, + + dataSource: PropTypes.instanceOf(ListViewDataSource).isRequired, + /** + * (sectionID, rowID, adjacentRowHighlighted) => renderable + * + * If provided, a renderable component to be rendered as the separator + * below each row but not the last row if there is a section header below. + * Take a sectionID and rowID of the row above and whether its adjacent row + * is highlighted. + */ + renderSeparator: PropTypes.func, + /** + * (rowData, sectionID, rowID, highlightRow) => renderable + * + * Takes a data entry from the data source and its ids and should return + * a renderable component to be rendered as the row. By default the data + * is exactly what was put into the data source, but it's also possible to + * provide custom extractors. ListView can be notified when a row is + * being highlighted by calling highlightRow function. The separators above and + * below will be hidden when a row is highlighted. The highlighted state of + * a row can be reset by calling highlightRow(null). + */ + renderRow: PropTypes.func.isRequired, + /** + * How many rows to render on initial component mount. Use this to make + * it so that the first screen worth of data appears at one time instead of + * over the course of multiple frames. + */ + initialListSize: PropTypes.number, + /** + * Called when all rows have been rendered and the list has been scrolled + * to within onEndReachedThreshold of the bottom. The native scroll + * event is provided. + */ + onEndReached: PropTypes.func, + /** + * Threshold in pixels for onEndReached. + */ + onEndReachedThreshold: PropTypes.number, + /** + * Number of rows to render per event loop. + */ + pageSize: PropTypes.number, + /** + * () => renderable + * + * The header and footer are always rendered (if these props are provided) + * on every render pass. If they are expensive to re-render, wrap them + * in StaticContainer or other mechanism as appropriate. Footer is always + * at the bottom of the list, and header at the top, on every render pass. + */ + renderFooter: PropTypes.func, + renderHeader: PropTypes.func, + /** + * (props) => renderable + * + * A function that returns the scrollable component in which the list rows + * are rendered. Defaults to returning a ScrollView with the given props. + */ + renderScrollComponent: React.PropTypes.func.isRequired, + /** + * (visibleRows, changedRows) => void + * + * Called when the set of visible rows changes. `visibleRows` maps + * { sectionID: { rowID: true }} for all the visible rows, and + * `changedRows` maps { sectionID: { rowID: true | false }} for the rows + * that have changed their visibility, with true indicating visible, and + * false indicating the view has moved out of view. + */ + onChangeVisibleRows: React.PropTypes.func, + /** + * A performance optimization for improving scroll perf of + * large lists, used in conjunction with overflow: 'hidden' on the row + * containers. This is enabled by default. + */ + removeClippedSubviews: React.PropTypes.bool, + }, + + /** + * 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(); + }, + + getInnerViewNode: function() { + return this.refs[SCROLLVIEW_REF] && + this.refs[SCROLLVIEW_REF].getInnerViewNode(); + }, + + setNativeProps: function(props) { + this.refs[SCROLLVIEW_REF] && + this.refs[SCROLLVIEW_REF].setNativeProps(props); + }, + + scrollTo: function(destY, destX) { + this.getScrollResponder().scrollResponderScrollTo(destX || 0, destY || 0); + }, + + /** + * React life cycle hooks. + */ + + getDefaultProps: function() { + return { + initialListSize: DEFAULT_INITIAL_ROWS, + pageSize: DEFAULT_PAGE_SIZE, + onEndReachedThreshold: DEFAULT_END_REACHED_THRESHOLD, + }; + }, + + getInitialState: function() { + return { + curRenderedRowsCount: this.props.initialListSize, + highlightedRow: {}, + }; + }, + + componentWillMount: function() { + // 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._lastEndReachedContentLength = null; + this._lastScrollEventTime = 0; // TODO(9622421): use native scroll events when available + this._lastUpdateEventTime = 0; + }, + + componentDidMount: function() { + // potentially trigger fetching additional data as soon as possible. + this._callOnEndReachedIfNeeded(); + + // schedule periodic check to check if we have more rows to render or need to feth more rows + this.setInterval(this.onTick, DEFAULT_TICK_FREQUENCY); + }, + + componentWillReceiveProps: function(nextProps) { + if (this.props.dataSource !== nextProps.dataSource) { + this._prevRenderedRowsCount = 0; + } + if (this.props.initialListSize !== nextProps.initialListSize) { + this.setState((state, props) => { + return { + curRenderedRowsCount: Math.max( + state.curRenderedRowsCount, + props.initialListSize + ), + }; + }); + } + }, + + componentDidUpdate: function () { + this._lastUpdateEventTime = performanceNow(); + }, + + onRowHighlighted: function(sectionID, rowID) { + this.setState({highlightedRow: {sectionID, rowID}}); + }, + + onTick: function() { + if (!this.isMounted()) { + return; + } + if (performanceNow() < this._lastScrollEventTime + DEFAULT_END_SCROLL_TIMEOUT) { + // listview is currently scrolling, skip any work until scroll completes + return; + } + if (performanceNow() < this._lastUpdateEventTime + DEFAULT_ROWS_YIELD_TIME) { + // ensure at least enough time has elapsed between 2 row rendering + return; + } + this._callOnEndReachedIfNeeded(); + this._renderMoreRowsIfAvailable(); + }, + + render: function() { + var dataSource = this.props.dataSource; + var allRowIDs = dataSource.rowIdentities; + var rowCount = 0; + var header = this.props.renderHeader && this.props.renderHeader(); + var footer = this.props.renderFooter && this.props.renderFooter(); + var totalIndex = header ? 1 : 0; + + var bodyComponents = []; + for (var sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { + var sectionID = dataSource.sectionIdentities[sectionIdx]; + var rowIDs = allRowIDs[sectionIdx]; + if (rowIDs.length === 0) { + continue; + } + + for (var rowIdx = 0; rowIdx < rowIDs.length; rowIdx++) { + var rowID = rowIDs[rowIdx]; + var comboID = sectionID + '_' + rowID; + var shouldUpdateRow = rowCount >= this._prevRenderedRowsCount && + dataSource.rowShouldUpdate(sectionIdx, rowIdx); + var row = + ; + 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.props.renderSeparator( + 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, + + // 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 + */ + + _onContentSizeChange: function(width, height) { + var contentLength = !this.props.horizontal ? height : width; + this._scrollProperties.contentLength = contentLength; + this.props.onContentSizeChange && this.props.onContentSizeChange(width, height); + }, + + _onLayout: function(event) { + var {width, height} = event.nativeEvent.layout; + var visibleLength = !this.props.horizontal ? height : width; + this._scrollProperties.visibleLength = visibleLength; + this.props.onLayout && this.props.onLayout(event); + }, + + _onScroll: function(e) { + var data = e.nativeEvent; + var isVertical = !this.props.horizontal; + this._scrollProperties.visibleLength = data.layoutMeasurement[isVertical ? 'height' : 'width']; + this._scrollProperties.contentLength = data.contentSize[isVertical ? 'height' : 'width']; + this._scrollProperties.offset = data.contentOffset[isVertical ? 'y' : 'x']; + this._lastScrollEventTime = performanceNow(); + this.props.onScroll && this.props.onScroll(e); + }, + + _callOnEndReachedIfNeeded: function() { + if (!this.props.onEndReached) { + // skip any processing if parent not interested in end of feed callbacks + return; + } + if (this._getDistanceFromEnd() > this.props.onEndReachedThreshold) { + // Scrolled out of the end zone, so it should be able to trigger again. + this._lastEndReachedContentLength = null; + return; + } + if (this.state.curRenderedRowsCount !== this.props.dataSource.getRowCount()) { + // we have more rows to render, so we haven't reached end of feed + return; + } + if (this._scrollProperties.contentLength && + this._scrollProperties.contentLength === this._lastEndReachedContentLength) { + // we have already invoked end of feed callback for this given position in feed + return; + } + this._lastEndReachedContentLength = this._scrollProperties.contentLength; + this.props.onEndReached(); + }, + + _renderMoreRowsIfAvailable: function() { + if (this.state.curRenderedRowsCount !== this.props.dataSource.getRowCount()) { + this.setState((state, props) => { + var curRenderedRowsCount = Math.min( + state.curRenderedRowsCount + props.pageSize, + props.dataSource.getRowCount() + ); + this._prevRenderedRowsCount = state.curRenderedRowsCount; + return {curRenderedRowsCount}; + }, () => { + this._prevRenderedRowsCount = this.state.curRenderedRowsCount; + }); + } + }, + + _getDistanceFromEnd: function() { + var scrollProperties = this._scrollProperties; + var maxLength = Math.max( + scrollProperties.contentLength, + scrollProperties.visibleLength + ); + return maxLength - scrollProperties.visibleLength - scrollProperties.offset; + }, + +}); + +module.exports = THFeedListView;