/** * Copyright (c) 2015-present, Facebook, Inc. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow * @format */ 'use strict'; const invariant = require('fbjs/lib/invariant'); const isEmpty = require('isEmpty'); /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an error * found when Flow v0.54 was deployed. To see the error delete this comment and * run Flow. */ const 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) => boolean; 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 ListView.DataSource({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 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: $ReadOnlyArray | {+[key: string]: any}, rowIdentities: ?$ReadOnlyArray, ): ListViewDataSource { const 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', ...]. The identifiers should correspond to the keys or array indexes * of the data you wish to include. If not provided, it's assumed that the * keys of dataBlob are the section identities. * * Note: this returns a new object! * * ``` * const dataSource = ds.cloneWithRowsAndSections({ * addresses: ['row 1', 'row 2'], * phone_numbers: ['data 1', 'data 2'], * }, ['phone_numbers']); * ``` */ cloneWithRowsAndSections( dataBlob: any, sectionIdentities: ?Array, rowIdentities: ?Array>, ): ListViewDataSource { invariant( typeof this._sectionHeaderHasChanged === 'function', 'Must provide a sectionHeaderHasChanged function with section data.', ); invariant( !sectionIdentities || !rowIdentities || sectionIdentities.length === rowIdentities.length, 'row and section ids lengths must be the same', ); const 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; } /** * Returns the total number of rows in the data source. * * If you are specifying the rowIdentities or sectionIdentities, then `getRowCount` will return the number of rows in the filtered data source. */ getRowCount(): number { return this._cachedRowCount; } /** * Returns the total number of rows in the data source (see `getRowCount` for how this is calculated) plus the number of sections in the data. * * If you are specifying the rowIdentities or sectionIdentities, then `getRowAndSectionCount` will return the number of rows & sections in the filtered data source. */ getRowAndSectionCount(): number { return this._cachedRowCount + this.sectionIdentities.length; } /** * Returns if the row is dirtied and needs to be rerendered */ rowShouldUpdate(sectionIndex: number, rowIndex: number): boolean { const 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 { const sectionID = this.sectionIdentities[sectionIndex]; const 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 { let accessIndex = index; for (let 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 { let accessIndex = index; for (let 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 { const results = []; for (let 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): boolean { const 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; } const 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 const prevSectionsHash = keyedDictionaryFromArray(prevSectionIDs); const prevRowsHash = {}; for (let 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 = []; let dirty; for (let 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]; const 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 ( let rIndex = 0; rIndex < this.rowIdentities[sIndex].length; rIndex++ ) { const 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) { let totalRows = 0; for (let sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { const rowIDs = allRowIDs[sectionIdx]; totalRows += rowIDs.length; } return totalRows; } function keyedDictionaryFromArray(arr) { if (isEmpty(arr)) { return {}; } const result = {}; for (let ii = 0; ii < arr.length; ii++) { const key = arr[ii]; warning(!result[key], 'Value appears more than once in array: ' + key); result[key] = true; } return result; } module.exports = ListViewDataSource;