From aef5efbad37375f9c9aaced0bd06b4491ba9f1f3 Mon Sep 17 00:00:00 2001 From: IjzerenHein Date: Tue, 8 Mar 2016 13:51:59 +0100 Subject: [PATCH] [add] basic ListView component Close #87 --- src/components/ListView/ListViewDataSource.js | 408 ++++++++++++++++++ src/components/ListView/ListViewPropTypes.js | 22 + .../ListView/__tests__/index-test.js | 4 + src/components/ListView/index.js | 98 ++++- 4 files changed, 523 insertions(+), 9 deletions(-) create mode 100644 src/components/ListView/ListViewDataSource.js create mode 100644 src/components/ListView/ListViewPropTypes.js diff --git a/src/components/ListView/ListViewDataSource.js b/src/components/ListView/ListViewDataSource.js new file mode 100644 index 00000000..d7579c3e --- /dev/null +++ b/src/components/ListView/ListViewDataSource.js @@ -0,0 +1,408 @@ +/* eslint-disable */ +/** + * 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 ListViewDataSource + * @typechecks + * @flow + */ +'use strict'; + +var invariant = require('fbjs/lib/invariant'); +var isEmpty = require('fbjs/lib/isEmpty'); +var warning = require('fbjs/lib/warning'); + +function defaultGetRowData( + dataBlob: any, + sectionID: number | string, + rowID: number | string +): any { + return dataBlob[sectionID][rowID]; +} + +function defaultGetSectionHeaderData( + dataBlob: any, + sectionID: number | string +): any { + return dataBlob[sectionID]; +} + +type differType = (data1: any, data2: any) => bool; + +type ParamType = { + rowHasChanged: differType; + getRowData: ?typeof defaultGetRowData; + sectionHeaderHasChanged: ?differType; + getSectionHeaderData: ?typeof defaultGetSectionHeaderData; +} + +/** + * Provides efficient data processing and access to the + * `ListView` component. A `ListViewDataSource` is created with functions for + * extracting data from the input blob, and comparing elements (with default + * implementations for convenience). The input blob can be as simple as an + * array of strings, or an object with rows nested inside section objects. + * + * To update the data in the datasource, use `cloneWithRows` (or + * `cloneWithRowsAndSections` if you care about sections). The data in the + * data source is immutable, so you can't modify it directly. The clone methods + * suck in the new data and compute a diff for each row so ListView knows + * whether to re-render it or not. + * + * In this example, a component receives data in chunks, handled by + * `_onDataArrived`, which concats the new data onto the old data and updates the + * data source. We use `concat` to create a new array - mutating `this._data`, + * e.g. with `this._data.push(newRowData)`, would be an error. `_rowHasChanged` + * understands the shape of the row data and knows how to efficiently compare + * it. + * + * ``` + * getInitialState: function() { + * var ds = new ListViewDataSource({rowHasChanged: this._rowHasChanged}); + * return {ds}; + * }, + * _onDataArrived(newData) { + * this._data = this._data.concat(newData); + * this.setState({ + * ds: this.state.ds.cloneWithRows(this._data) + * }); + * } + * ``` + */ + +class ListViewDataSource { + + /** + * You can provide custom extraction and `hasChanged` functions for section + * headers and rows. If absent, data will be extracted with the + * `defaultGetRowData` and `defaultGetSectionHeaderData` functions. + * + * The default extractor expects data of one of the following forms: + * + * { sectionID_1: { rowID_1: , ... }, ... } + * + * or + * + * { sectionID_1: [ , , ... ], ... } + * + * or + * + * [ [ , , ... ], ... ] + * + * The constructor takes in a params argument that can contain any of the + * following: + * + * - getRowData(dataBlob, sectionID, rowID); + * - getSectionHeaderData(dataBlob, sectionID); + * - rowHasChanged(prevRowData, nextRowData); + * - sectionHeaderHasChanged(prevSectionData, nextSectionData); + */ + constructor(params: ParamType) { + invariant( + params && typeof params.rowHasChanged === 'function', + 'Must provide a rowHasChanged function.' + ); + this._rowHasChanged = params.rowHasChanged; + this._getRowData = params.getRowData || defaultGetRowData; + this._sectionHeaderHasChanged = params.sectionHeaderHasChanged; + this._getSectionHeaderData = + params.getSectionHeaderData || defaultGetSectionHeaderData; + + this._dataBlob = null; + this._dirtyRows = []; + this._dirtySections = []; + this._cachedRowCount = 0; + + // These two private variables are accessed by outsiders because ListView + // uses them to iterate over the data in this class. + this.rowIdentities = []; + this.sectionIdentities = []; + } + + /** + * Clones this `ListViewDataSource` with the specified `dataBlob` and + * `rowIdentities`. The `dataBlob` is just an arbitrary blob of data. At + * construction an extractor to get the interesting information was defined + * (or the default was used). + * + * The `rowIdentities` is is a 2D array of identifiers for rows. + * ie. [['a1', 'a2'], ['b1', 'b2', 'b3'], ...]. If not provided, it's + * assumed that the keys of the section data are the row identities. + * + * Note: This function does NOT clone the data in this data source. It simply + * passes the functions defined at construction to a new data source with + * the data specified. If you wish to maintain the existing data you must + * handle merging of old and new data separately and then pass that into + * this function as the `dataBlob`. + */ + cloneWithRows( + dataBlob: Array | {[key: string]: any}, + rowIdentities: ?Array + ): ListViewDataSource { + var rowIds = rowIdentities ? [rowIdentities] : null; + if (!this._sectionHeaderHasChanged) { + this._sectionHeaderHasChanged = () => false; + } + return this.cloneWithRowsAndSections({s1: dataBlob}, ['s1'], rowIds); + } + + /** + * This performs the same function as the `cloneWithRows` function but here + * you also specify what your `sectionIdentities` are. If you don't care + * about sections you should safely be able to use `cloneWithRows`. + * + * `sectionIdentities` is an array of identifiers for sections. + * ie. ['s1', 's2', ...]. If not provided, it's assumed that the + * keys of dataBlob are the section identities. + * + * Note: this returns a new object! + */ + cloneWithRowsAndSections( + dataBlob: any, + sectionIdentities: ?Array, + rowIdentities: ?Array> + ): ListViewDataSource { + invariant( + typeof this._sectionHeaderHasChanged === 'function', + 'Must provide a sectionHeaderHasChanged function with section data.' + ); + var newSource = new ListViewDataSource({ + getRowData: this._getRowData, + getSectionHeaderData: this._getSectionHeaderData, + rowHasChanged: this._rowHasChanged, + sectionHeaderHasChanged: this._sectionHeaderHasChanged, + }); + newSource._dataBlob = dataBlob; + if (sectionIdentities) { + newSource.sectionIdentities = sectionIdentities; + } else { + newSource.sectionIdentities = Object.keys(dataBlob); + } + if (rowIdentities) { + newSource.rowIdentities = rowIdentities; + } else { + newSource.rowIdentities = []; + newSource.sectionIdentities.forEach((sectionID) => { + newSource.rowIdentities.push(Object.keys(dataBlob[sectionID])); + }); + } + newSource._cachedRowCount = countRows(newSource.rowIdentities); + + newSource._calculateDirtyArrays( + this._dataBlob, + this.sectionIdentities, + this.rowIdentities + ); + + return newSource; + } + + getRowCount(): number { + return this._cachedRowCount; + } + + /** + * Returns if the row is dirtied and needs to be rerendered + */ + rowShouldUpdate(sectionIndex: number, rowIndex: number): bool { + var needsUpdate = this._dirtyRows[sectionIndex][rowIndex]; + warning(needsUpdate !== undefined, + 'missing dirtyBit for section, row: ' + sectionIndex + ', ' + rowIndex); + return needsUpdate; + } + + /** + * Gets the data required to render the row. + */ + getRowData(sectionIndex: number, rowIndex: number): any { + var sectionID = this.sectionIdentities[sectionIndex]; + var rowID = this.rowIdentities[sectionIndex][rowIndex]; + warning( + sectionID !== undefined && rowID !== undefined, + 'rendering invalid section, row: ' + sectionIndex + ', ' + rowIndex + ); + return this._getRowData(this._dataBlob, sectionID, rowID); + } + + /** + * Gets the rowID at index provided if the dataSource arrays were flattened, + * or null of out of range indexes. + */ + getRowIDForFlatIndex(index: number): ?string { + var accessIndex = index; + for (var ii = 0; ii < this.sectionIdentities.length; ii++) { + if (accessIndex >= this.rowIdentities[ii].length) { + accessIndex -= this.rowIdentities[ii].length; + } else { + return this.rowIdentities[ii][accessIndex]; + } + } + return null; + } + + /** + * Gets the sectionID at index provided if the dataSource arrays were flattened, + * or null for out of range indexes. + */ + getSectionIDForFlatIndex(index: number): ?string { + var accessIndex = index; + for (var ii = 0; ii < this.sectionIdentities.length; ii++) { + if (accessIndex >= this.rowIdentities[ii].length) { + accessIndex -= this.rowIdentities[ii].length; + } else { + return this.sectionIdentities[ii]; + } + } + return null; + } + + /** + * Returns an array containing the number of rows in each section + */ + getSectionLengths(): Array { + var results = []; + for (var ii = 0; ii < this.sectionIdentities.length; ii++) { + results.push(this.rowIdentities[ii].length); + } + return results; + } + + /** + * Returns if the section header is dirtied and needs to be rerendered + */ + sectionHeaderShouldUpdate(sectionIndex: number): bool { + var needsUpdate = this._dirtySections[sectionIndex]; + warning(needsUpdate !== undefined, + 'missing dirtyBit for section: ' + sectionIndex); + return needsUpdate; + } + + /** + * Gets the data required to render the section header + */ + getSectionHeaderData(sectionIndex: number): any { + if (!this._getSectionHeaderData) { + return null; + } + var sectionID = this.sectionIdentities[sectionIndex]; + warning(sectionID !== undefined, + 'renderSection called on invalid section: ' + sectionIndex); + return this._getSectionHeaderData(this._dataBlob, sectionID); + } + + /** + * Private members and methods. + */ + + _getRowData: typeof defaultGetRowData; + _getSectionHeaderData: typeof defaultGetSectionHeaderData; + _rowHasChanged: differType; + _sectionHeaderHasChanged: ?differType; + + _dataBlob: any; + _dirtyRows: Array>; + _dirtySections: Array; + _cachedRowCount: number; + + // These two 'protected' variables are accessed by ListView to iterate over + // the data in this class. + rowIdentities: Array>; + sectionIdentities: Array; + + _calculateDirtyArrays( + prevDataBlob: any, + prevSectionIDs: Array, + prevRowIDs: Array> + ): void { + // construct a hashmap of the existing (old) id arrays + var prevSectionsHash = keyedDictionaryFromArray(prevSectionIDs); + var prevRowsHash = {}; + for (var ii = 0; ii < prevRowIDs.length; ii++) { + var sectionID = prevSectionIDs[ii]; + warning( + !prevRowsHash[sectionID], + 'SectionID appears more than once: ' + sectionID + ); + prevRowsHash[sectionID] = keyedDictionaryFromArray(prevRowIDs[ii]); + } + + // compare the 2 identity array and get the dirtied rows + this._dirtySections = []; + this._dirtyRows = []; + + var dirty; + for (var sIndex = 0; sIndex < this.sectionIdentities.length; sIndex++) { + var sectionID = this.sectionIdentities[sIndex]; + // dirty if the sectionHeader is new or _sectionHasChanged is true + dirty = !prevSectionsHash[sectionID]; + var sectionHeaderHasChanged = this._sectionHeaderHasChanged; + if (!dirty && sectionHeaderHasChanged) { + dirty = sectionHeaderHasChanged( + this._getSectionHeaderData(prevDataBlob, sectionID), + this._getSectionHeaderData(this._dataBlob, sectionID) + ); + } + this._dirtySections.push(!!dirty); + + this._dirtyRows[sIndex] = []; + for (var rIndex = 0; rIndex < this.rowIdentities[sIndex].length; rIndex++) { + var rowID = this.rowIdentities[sIndex][rIndex]; + // dirty if the section is new, row is new or _rowHasChanged is true + dirty = + !prevSectionsHash[sectionID] || + !prevRowsHash[sectionID][rowID] || + this._rowHasChanged( + this._getRowData(prevDataBlob, sectionID, rowID), + this._getRowData(this._dataBlob, sectionID, rowID) + ); + this._dirtyRows[sIndex].push(!!dirty); + } + } + } +} + +function countRows(allRowIDs) { + var totalRows = 0; + for (var sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { + var rowIDs = allRowIDs[sectionIdx]; + totalRows += rowIDs.length; + } + return totalRows; +} + +function keyedDictionaryFromArray(arr) { + if (isEmpty(arr)) { + return {}; + } + var result = {}; + for (var ii = 0; ii < arr.length; ii++) { + var key = arr[ii]; + warning(!result[key], 'Value appears more than once in array: ' + key); + result[key] = true; + } + return result; +} + + +module.exports = ListViewDataSource; diff --git a/src/components/ListView/ListViewPropTypes.js b/src/components/ListView/ListViewPropTypes.js new file mode 100644 index 00000000..0c39f027 --- /dev/null +++ b/src/components/ListView/ListViewPropTypes.js @@ -0,0 +1,22 @@ +import { PropTypes } from 'react' +import ScrollView from '../ScrollView' +import ListViewDataSource from './ListViewDataSource' + +export default { + ...ScrollView.propTypes, + dataSource: PropTypes.instanceOf(ListViewDataSource).isRequired, + renderSeparator: PropTypes.func, + renderRow: PropTypes.func.isRequired, + initialListSize: PropTypes.number, + onEndReached: PropTypes.func, + onEndReachedThreshold: PropTypes.number, + pageSize: PropTypes.number, + renderFooter: PropTypes.func, + renderHeader: PropTypes.func, + renderSectionHeader: PropTypes.func, + renderScrollComponent: PropTypes.func.isRequired, + scrollRenderAheadDistance: PropTypes.number, + onChangeVisibleRows: PropTypes.func, + removeClippedSubviews: PropTypes.bool, + stickyHeaderIndices: PropTypes.arrayOf(PropTypes.number) +} diff --git a/src/components/ListView/__tests__/index-test.js b/src/components/ListView/__tests__/index-test.js index 38743c8e..6619d0a6 100644 --- a/src/components/ListView/__tests__/index-test.js +++ b/src/components/ListView/__tests__/index-test.js @@ -1 +1,5 @@ /* eslint-env mocha */ + +suite('components/ListView', () => { + test('NO TEST COVERAGE') +}) diff --git a/src/components/ListView/index.js b/src/components/ListView/index.js index bd316527..27813f93 100644 --- a/src/components/ListView/index.js +++ b/src/components/ListView/index.js @@ -1,22 +1,102 @@ import NativeMethodsDecorator from '../../modules/NativeMethodsDecorator' -import React, { Component, PropTypes } from 'react' +import React, { Component } from 'react' import ScrollView from '../ScrollView' +import ListViewDataSource from './ListViewDataSource' +import ListViewPropTypes from './ListViewPropTypes' + +const SCROLLVIEW_REF = 'listviewscroll' @NativeMethodsDecorator class ListView extends Component { - static propTypes = { - children: PropTypes.any, - style: ScrollView.propTypes.style - }; + static propTypes = ListViewPropTypes; static defaultProps = { - style: {} + initialListSize: 10, + pageSize: 1, + renderScrollComponent: (props) => , + scrollRenderAheadDistance: 1000, + onEndReachedThreshold: 1000, + stickyHeaderIndices: [] }; + static DataSource = ListViewDataSource; + + constructor(props) { + super(props) + this.state = { + curRenderedRowsCount: this.props.initialListSize, + highlightedRow: {} + } + this.onRowHighlighted = (sectionId, rowId) => this._onRowHighlighted(sectionId, rowId) + } + + getScrollResponder() { + return this.refs[SCROLLVIEW_REF] && this.refs[SCROLLVIEW_REF].getScrollResponder() + } + + scrollTo(...args) { + return this.refs[SCROLLVIEW_REF] && this.refs[SCROLLVIEW_REF].scrollTo(...args) + } + + setNativeProps(props) { + return this.refs[SCROLLVIEW_REF] && this.refs[SCROLLVIEW_REF].setNativeProps(props) + } + + _onRowHighlighted(sectionId, rowId) { + this.setState({highlightedRow: {sectionId, rowId}}) + } + render() { - return ( - - ) + const dataSource = this.props.dataSource + const header = this.props.renderHeader ? this.props.renderHeader() : undefined + const footer = this.props.renderFooter ? this.props.renderFooter() : undefined + + // render sections and rows + const children = [] + const sections = dataSource.rowIdentities + const renderRow = this.props.renderRow + const renderSectionHeader = this.props.renderSectionHeader + const renderSeparator = this.props.renderSeparator + for (let sectionIdx = 0, sectionCnt = sections.length; sectionIdx < sectionCnt; sectionIdx++) { + const rows = sections[sectionIdx] + const sectionId = dataSource.sectionIdentities[sectionIdx] + + // render optional section header + if (renderSectionHeader) { + const section = dataSource.getSectionHeaderData(sectionIdx) + const key = 's_' + sectionId + const child =
{renderSectionHeader(section, sectionId)}
+ children.push(child) + } + + // render rows + for (let rowIdx = 0, rowCnt = rows.length; rowIdx < rowCnt; rowIdx++) { + const rowId = rows[rowIdx] + const row = dataSource.getRowData(sectionIdx, rowIdx) + const key = 'r_' + sectionId + '_' + rowId + const child =
{renderRow(row, sectionId, rowId, this.onRowHighlighted)}
+ children.push(child) + + // render optional separator + if (renderSeparator && ((rowIdx !== rows.length - 1) || (sectionIdx === sections.length - 1))) { + const adjacentRowHighlighted = + this.state.highlightedRow.sectionID === sectionId && ( + this.state.highlightedRow.rowID === rowId || + this.state.highlightedRow.rowID === rows[rowIdx + 1]) + const separator = renderSeparator(sectionId, rowId, adjacentRowHighlighted) + children.push(separator) + } + } + } + + const { + renderScrollComponent, + ...props + } = this.props + + return React.cloneElement(renderScrollComponent(props), { + ref: SCROLLVIEW_REF + }, header, children, footer) } }