diff --git a/Examples/UIExplorer/js/FlatListExample.js b/Examples/UIExplorer/js/FlatListExample.js new file mode 100644 index 000000000..7917445b7 --- /dev/null +++ b/Examples/UIExplorer/js/FlatListExample.js @@ -0,0 +1,166 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +const React = require('react'); +const ReactNative = require('react-native'); +const { + StyleSheet, + View, +} = ReactNative; + +const FlatList = require('FlatList'); +const UIExplorerPage = require('./UIExplorerPage'); + +const infoLog = require('infoLog'); + +const { + FooterComponent, + HeaderComponent, + ItemComponent, + PlainInput, + SeparatorComponent, + genItemData, + getItemLayout, + pressItem, + renderSmallSwitchOption, +} = require('./ListExampleShared'); + +class FlatListExample extends React.PureComponent { + static title = ''; + static description = 'Performant, scrollable list of data.'; + + state = { + data: genItemData(1000), + horizontal: false, + filterText: '', + fixedHeight: true, + logViewable: false, + virtualized: true, + }; + _onChangeFilterText = (filterText) => { + this.setState({filterText}); + }; + _onChangeScrollToIndex = (text) => { + this._listRef.scrollToIndex({viewPosition: 0.5, index: Number(text)}); + }; + render() { + const filterRegex = new RegExp(String(this.state.filterText), 'i'); + const filter = (item) => (filterRegex.test(item.text) || filterRegex.test(item.title)); + const filteredData = this.state.data.filter(filter); + return ( + + + + + + {renderSmallSwitchOption(this, 'virtualized')} + {renderSmallSwitchOption(this, 'horizontal')} + {renderSmallSwitchOption(this, 'fixedHeight')} + {renderSmallSwitchOption(this, 'logViewable')} + + + alert('onRefresh: nothing to refresh :P')} + refreshing={false} + onViewableItemsChanged={this._onViewableItemsChanged} + ref={this._captureRef} + shouldItemUpdate={this._shouldItemUpdate} + /> + + ); + } + _captureRef = (ref) => { this._listRef = ref; }; + _getItemLayout = (data: any, index: number) => { + return getItemLayout(data, index, this.state.horizontal); + }; + _renderItemComponent = ({item}) => { + return ( + + ); + }; + _shouldItemUpdate(prev, next) { + /** + * Note that this does not check state.horizontal or state.fixedheight because we blow away the + * whole list by changing the key in those cases. Make sure that you do the same in your code, + * or incorporate all relevant data into the item data, or skip this optimization entirely. + */ + return prev.item !== next.item; + } + // This is called when items change viewability by scrolling into or out of the viewable area. + _onViewableItemsChanged = (info: { + changed: Array<{ + key: string, isViewable: boolean, item: any, index: ?number, section?: any + }> + } + ) => { + // Impressions can be logged here + if (this.state.logViewable) { + infoLog('onViewableItemsChanged: ', info.changed.map((v) => ({...v, item: '...'}))); + } + }; + _pressItem = (key: number) => { + pressItem(this, key); + }; + _listRef: FlatList; +} + + +const styles = StyleSheet.create({ + options: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + }, + searchRow: { + backgroundColor: '#eeeeee', + padding: 10, + }, +}); + +module.exports = FlatListExample; diff --git a/Examples/UIExplorer/js/ListExampleShared.js b/Examples/UIExplorer/js/ListExampleShared.js new file mode 100644 index 000000000..2b8e9c2da --- /dev/null +++ b/Examples/UIExplorer/js/ListExampleShared.js @@ -0,0 +1,268 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +const React = require('react'); +const ReactNative = require('react-native'); +const { + Image, + Platform, + TouchableHighlight, + StyleSheet, + Switch, + Text, + TextInput, + View, +} = ReactNative; + +type Item = {title: string, text: string, key: number, pressed: boolean}; + +function genItemData(count: number): Array { + const dataBlob = []; + for (let ii = 0; ii < count; ii++) { + const itemHash = Math.abs(hashCode('Item ' + ii)); + dataBlob.push({ + title: 'Item ' + ii, + text: LOREM_IPSUM.substr(0, itemHash % 301 + 20), + key: ii, + pressed: false, + }); + } + return dataBlob; +} + +const HORIZ_WIDTH = 200; + +class ItemComponent extends React.PureComponent { + props: { + fixedHeight?: ?boolean, + horizontal?: ?boolean, + item: Item, + onPress: (key: number) => void, + }; + _onPress = () => { + this.props.onPress(this.props.item.key); + }; + render() { + const {fixedHeight, horizontal, item} = this.props; + const itemHash = Math.abs(hashCode(item.title)); + const imgSource = THUMB_URLS[itemHash % THUMB_URLS.length]; + return ( + + + + + {item.title} - {item.text} + + + + ); + } +} + +class FooterComponent extends React.PureComponent { + render() { + return ( + + + + FOOTER + + + ); + } +} + +class HeaderComponent extends React.PureComponent { + render() { + return ( + + + HEADER + + + + ); + } +} + +class SeparatorComponent extends React.PureComponent { + render() { + return ; + } +} + +const THUMB_URLS = [ + require('./Thumbnails/like.png'), + require('./Thumbnails/dislike.png'), + require('./Thumbnails/call.png'), + require('./Thumbnails/fist.png'), + require('./Thumbnails/bandaged.png'), + require('./Thumbnails/flowers.png'), + require('./Thumbnails/heart.png'), + require('./Thumbnails/liking.png'), + require('./Thumbnails/party.png'), + require('./Thumbnails/poke.png'), + require('./Thumbnails/superlike.png'), + require('./Thumbnails/victory.png'), +]; + +const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, ius ad pertinax oportere accommodare, an vix \ +civibus corrumpit referrentur. Te nam case ludus inciderint, te mea facilisi adipiscing. Sea id \ +integre luptatum. In tota sale consequuntur nec. Erat ocurreret mei ei. Eu paulo sapientem \ +vulputate est, vel an accusam intellegam interesset. Nam eu stet pericula reprimique, ea vim illud \ +modus, putant invidunt reprehendunt ne qui.'; + +/* eslint no-bitwise: 0 */ +function hashCode(str: string): number { + let hash = 15; + for (let ii = str.length - 1; ii >= 0; ii--) { + hash = ((hash << 5) - hash) + str.charCodeAt(ii); + } + return hash; +} + +const HEADER = {height: 30, width: 80}; +const SEPARATOR_HEIGHT = StyleSheet.hairlineWidth; + +function getItemLayout(data: any, index: number, horizontal?: boolean) { + const [length, separator, header] = horizontal ? + [HORIZ_WIDTH, 0, HEADER.width] : [84, SEPARATOR_HEIGHT, HEADER.height]; + return {length, offset: (length + separator) * index + header, index}; +} + +function pressItem(context: Object, key: number) { + const pressed = !context.state.data[key].pressed; + context.setState((state) => { + const newData = [...state.data]; + newData[key] = { + ...state.data[key], + pressed, + title: 'Item ' + key + (pressed ? ' (pressed)' : ''), + }; + return {data: newData}; + }); +} + +function renderSmallSwitchOption(context: Object, key: string) { + return ( + + {key}: + context.setState({[key]: value})} + /> + + ); +} + +function PlainInput({placeholder, value, onChangeText}: Object) { + return ( + + ); +} + +const styles = StyleSheet.create({ + headerFooter: { + ...HEADER, + alignSelf: 'center', + alignItems: 'center', + justifyContent: 'center', + }, + horizItem: { + alignSelf: 'flex-start', // Necessary for touch highlight + }, + item: { + flex: 1, + }, + option: { + flexDirection: 'row', + padding: 8, + paddingRight: 0, + }, + row: { + flexDirection: 'row', + padding: 10, + backgroundColor: '#F6F6F6', + }, + searchTextInput: { + backgroundColor: 'white', + borderColor: '#cccccc', + borderRadius: 3, + borderWidth: 1, + paddingLeft: 8, + paddingVertical: 0, + height: 26, + fontSize: 14, + }, + separator: { + height: SEPARATOR_HEIGHT, + backgroundColor: 'gray', + }, + smallSwitch: Platform.select({ + android: { + top: 1, + margin: -6, + transform: [{scale: 0.7}], + }, + ios: { + top: 4, + margin: -10, + transform: [{scale: 0.5}], + }, + }), + thumb: { + width: 64, + height: 64, + }, + text: { + flex: 1, + }, +}); + +module.exports = { + FooterComponent, + HeaderComponent, + ItemComponent, + PlainInput, + SeparatorComponent, + genItemData, + getItemLayout, + pressItem, + renderSmallSwitchOption, +}; diff --git a/Examples/UIExplorer/js/TwoColumnExample.js b/Examples/UIExplorer/js/TwoColumnExample.js new file mode 100644 index 000000000..624e32e78 --- /dev/null +++ b/Examples/UIExplorer/js/TwoColumnExample.js @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +const React = require('react'); +const ReactNative = require('react-native'); +const { + StyleSheet, + View, +} = ReactNative; + +const FlatList = require('FlatList'); +const UIExplorerPage = require('./UIExplorerPage'); + +const infoLog = require('infoLog'); + +const { + FooterComponent, + HeaderComponent, + ItemComponent, + PlainInput, + SeparatorComponent, + genItemData, + getItemLayout, + pressItem, + renderSmallSwitchOption, +} = require('./ListExampleShared'); + +class TwoColumnExample extends React.PureComponent { + static title = 'Two Columns with FlatList'; + static description = 'Performant, scrollable list of data in two columns.'; + + state = { + data: genItemData(1000), + filterText: '', + fixedHeight: true, + logViewable: false, + virtualized: true, + }; + _onChangeFilterText = (filterText) => { + this.setState(() => ({filterText})); + }; + render() { + const filterRegex = new RegExp(String(this.state.filterText), 'i'); + const filter = (item) => (filterRegex.test(item.text) || filterRegex.test(item.title)); + const filteredData = this.state.data.filter(filter); + const grid = []; + for (let ii = 0; ii < filteredData.length; ii += 2) { + const i1 = filteredData[ii]; + const i2 = filteredData[ii + 1]; + grid.push({columns: i2 ? [i1, i2] : [i1], key: i1.key + (i2 && i2.key)}); + } + return ( + - 2 Columns'} + noSpacer={true} + noScroll={true}> + + + + {renderSmallSwitchOption(this, 'virtualized')} + {renderSmallSwitchOption(this, 'fixedHeight')} + {renderSmallSwitchOption(this, 'logViewable')} + + + + + ); + } + _getItemLayout(data: any, index: number): {length: number, offset: number} { + return getItemLayout(data, index); + } + _renderItemComponent = ({item}) => { + return ( + + {item.columns.map((it, ii) => ( + + ))} + + ); + }; + _shouldItemUpdate(curr, next) { + // Note that this does not check state.fixedHeight because we blow away the whole list by + // changing the key anyway. + return curr.item.columns.some((cIt, idx) => cIt !== next.item.columns[idx]); + } + // This is called when items change viewability by scrolling into our out of the viewable area. + _onViewableItemsChanged = (info: { + changed: Array<{ + key: string, isViewable: boolean, item: {columns: Array<*>}, index: ?number, section?: any + }>}, + ) => { + // Impressions can be logged here + if (this.state.logViewable) { + infoLog('onViewableItemsChanged: ', info.changed.map((v) => ({...v, item: '...'}))); + } + }; + _pressItem = (key: number) => { + pressItem(this, key); + }; +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + }, + searchRow: { + backgroundColor: '#eeeeee', + padding: 10, + }, +}); + +module.exports = TwoColumnExample; diff --git a/Libraries/CustomComponents/ListView/ListView.js b/Libraries/CustomComponents/ListView/ListView.js index 86ca2659e..5922a01cc 100644 --- a/Libraries/CustomComponents/ListView/ListView.js +++ b/Libraries/CustomComponents/ListView/ListView.js @@ -300,7 +300,7 @@ var ListView = React.createClass({ * * See `ScrollView#scrollToEnd`. */ - scrollToEnd: function(options?: { animated?: boolean }) { + scrollToEnd: function(options?: ?{ animated?: ?boolean }) { if (this._scrollComponent) { if (this._scrollComponent.scrollToEnd) { this._scrollComponent.scrollToEnd(options); diff --git a/Libraries/Experimental/FlatList.js b/Libraries/Experimental/FlatList.js new file mode 100644 index 000000000..362e26ca4 --- /dev/null +++ b/Libraries/Experimental/FlatList.js @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * 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 FlatList + * @flow + */ +'use strict'; + +const MetroListView = require('MetroListView'); // Used as a fallback legacy option +const React = require('React'); +const VirtualizedList = require('VirtualizedList'); + +import type {Viewable} from 'ViewabilityHelper'; + +type Item = any; + +type RequiredProps = { + /** + * Note this can be a normal class component, or a functional component, such as a render method + * on your main component. + */ + ItemComponent: ReactClass<{item: Item, index: number}>, + /** + * For simplicity, data is just a plain array. If you want to use something else, like an + * immutable list, use the underlying `VirtualizedList` directly. + */ + data: ?Array, +}; +type OptionalProps = { + /** + * Rendered at the bottom of all the items. + */ + FooterComponent?: ?ReactClass<*>, + /** + * Rendered at the top of all the items. + */ + HeaderComponent?: ?ReactClass<*>, + /** + * Rendered in between each item, but not at the top or bottom. + */ + SeparatorComponent?: ?ReactClass<*>, + /** + * getItemLayout is an optional optimizations that let us skip measurement of dynamic content if + * you know the height of items a priori. getItemLayout is the most efficient, and is easy to use + * if you have fixed height items, for example: + * + * getItemLayout={(data, index) => ({length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index})} + * + * Remember to include separator length (height or width) in your offset calculation if you + * specify `SeparatorComponent`. + */ + getItemLayout?: (data: ?Array, index: number) => {length: number, offset: number}, + /** + * If true, renders items next to each other horizontally instead of stacked vertically. + */ + horizontal?: ?boolean, + /** + * Used to extract a unique key for a given item at the specified index. Key is used for caching + * and as the react key to track item re-ordering. The default extractor checks item.key, then + * falls back to using the index, like react does. + */ + keyExtractor?: (item: Item, index: number) => string, + /** + * Called once when the scroll position gets within onEndReachedThreshold of the rendered content. + */ + onEndReached?: ?({distanceFromEnd: number}) => void, + onEndReachedThreshold?: ?number, + /** + * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make + * sure to also set the `refreshing` prop correctly. + */ + onRefresh?: ?Function, + /** + * Called when the viewability of rows changes, as defined by the + * `viewablePercentThreshold` prop. + */ + onViewableItemsChanged?: ?({viewableItems: Array, changed: Array}) => void, + /** + * Set this true while waiting for new data from a refresh. + */ + refreshing?: ?boolean, + /** + * Optional optimization to minimize re-rendering items. + */ + shouldItemUpdate?: ?( + prevProps: {item: Item, index: number}, + nextProps: {item: Item, index: number} + ) => boolean, +}; +type Props = RequiredProps & OptionalProps; // plus props from the underlying implementation + +/** + * A performant interface for rendering simple, flat lists, supporting the most handy features: + * + * - Fully cross-platform. + * - Optional horizontal mode. + * - Viewability callbacks. + * - Footer support. + * - Separator support. + * - Pull to Refresh + * + * If you need sticky section header support, use ListView. + * + * Minimal Example: + * + * {item.key}} + * /> + */ +class FlatList extends React.PureComponent { + props: Props; + /** + * Scrolls to the end of the content. May be janky without getItemLayout prop. + */ + scrollToEnd(params?: ?{animated?: ?boolean}) { + this._listRef.scrollToEnd(params); + } + + /** + * Scrolls to the item at a the specified index such that it is positioned in the viewable area + * such that viewPosition 0 places it at the top, 1 at the bottom, and 0.5 centered in the middle. + * + * May be janky without getItemLayout prop. + */ + scrollToIndex(params: {animated?: ?boolean, index: number, viewPosition?: number}) { + this._listRef.scrollToIndex(params); + } + + /** + * Requires linear scan through data - use scrollToIndex instead if possible. May be janky without + * `getItemLayout` prop. + */ + scrollToItem(params: {animated?: ?boolean, item: Item, viewPosition?: number}) { + this._listRef.scrollToItem(params); + } + + /** + * Scroll to a specific content pixel offset, like a normal ScrollView. + */ + scrollToOffset(params: {animated?: ?boolean, offset: number}) { + this._listRef.scrollToOffset(params); + } + + _hasWarnedLegacy = false; + _listRef: VirtualizedList; + _captureRef = (ref) => { this._listRef = ref; }; + render() { + if (this.props.legacyImplementation) { + // Warning: may not have full feature parity and is meant more for debugging and performance + // comparison. + if (!this._hasWarnedLegacy) { + console.warn( + 'FlatList: Using legacyImplementation - some features not supported and performance ' + + 'may suffer' + ); + this._hasWarnedLegacy = true; + } + return ; + } else { + return ; + } + } +} + +module.exports = FlatList; diff --git a/Libraries/Experimental/MetroListView.js b/Libraries/Experimental/MetroListView.js new file mode 100644 index 000000000..7533bb166 --- /dev/null +++ b/Libraries/Experimental/MetroListView.js @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * 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 MetroListView + * @flow + */ +'use strict'; + +const ListView = require('ListView'); +const React = require('React'); +const RefreshControl = require('RefreshControl'); +const ScrollView = require('ScrollView'); + +const invariant = require('fbjs/lib/invariant'); + +type Item = any; + +type NormalProps = { + FooterComponent?: ReactClass<*>, + ItemComponent: ReactClass<{item: Item, index: number}>, + SectionHeaderComponent?: ReactClass<{info: Object}>, + SeparatorComponent?: ReactClass<*>, // not supported yet + + // Provide either `items` or `sections` + items?: ?Array, // By default, an Item is assumed to be {key: string} + sections?: ?Array<{key: string, items: Array}>, + + /** + * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make + * sure to also set the `refreshing` prop correctly. + */ + onRefresh?: ?Function, + /** + * Set this true while waiting for new data from a refresh. + */ + refreshing?: boolean, +}; +type DefaultProps = { + shouldItemUpdate: (curr: {item: Item}, next: {item: Item}) => boolean, + keyExtractor: (item: Item) => string, +}; +type Props = NormalProps & DefaultProps; + +/** + * This is just a wrapper around the legacy ListView that matches the new API of FlatList, but with + * some section support tacked on. It is recommended to just use FlatList directly, this component + * is mostly for debugging and performance comparison. + */ +class MetroListView extends React.Component { + props: Props; + scrollToEnd(params?: ?{animated?: ?boolean}) { + throw new Error('scrollToEnd not supported in legacy ListView.'); + } + scrollToIndex(params: {animated?: ?boolean, index: number, viewPosition?: number}) { + throw new Error('scrollToIndex not supported in legacy ListView.'); + } + scrollToItem(params: {animated?: ?boolean, item: Item, viewPosition?: number}) { + throw new Error('scrollToItem not supported in legacy ListView.'); + } + scrollToOffset(params: {animated?: ?boolean, offset: number}) { + const {animated, offset} = params; + this._listRef.scrollTo( + this.props.horizontal ? {x: offset, animated} : {y: offset, animated} + ); + } + static defaultProps: DefaultProps = { + shouldItemUpdate: () => true, + keyExtractor: (item, index) => item.key || index, + renderScrollComponent: (props: Props) => { + if (props.onRefresh) { + return ( + + } + /> + ); + } else { + return ; + } + }, + }; + state = this._computeState( + this.props, + { + ds: new ListView.DataSource({ + rowHasChanged: (itemA, itemB) => this.props.shouldItemUpdate({item: itemA}, {item: itemB}), + sectionHeaderHasChanged: () => true, + getSectionHeaderData: (dataBlob, sectionID) => this.state.sectionHeaderData[sectionID], + }), + sectionHeaderData: {}, + }, + ); + componentWillReceiveProps(newProps: Props) { + this.setState((state) => this._computeState(newProps, state)); + } + render() { + return ( + + ); + } + _listRef: ListView; + _captureRef = (ref) => { this._listRef = ref; }; + _computeState(props: Props, state) { + const sectionHeaderData = {}; + if (props.sections) { + invariant(!props.items, 'Cannot have both sections and items props.'); + const sections = {}; + props.sections.forEach((sectionIn, ii) => { + const sectionID = 's' + ii; + sections[sectionID] = sectionIn.itemData; + sectionHeaderData[sectionID] = sectionIn; + }); + return { + ds: state.ds.cloneWithRowsAndSections(sections), + sectionHeaderData, + }; + } else { + invariant(!props.sections, 'Cannot have both sections and items props.'); + return { + ds: state.ds.cloneWithRows(props.items), + sectionHeaderData, + }; + } + } + _renderFooter = () => ; + _renderRow = (item, sectionID, rowID, highlightRow) => { + const {ItemComponent} = this.props; + return ; + }; + _renderSectionHeader = (section, sectionID) => { + const {SectionHeaderComponent} = this.props; + invariant(SectionHeaderComponent, 'Must provide SectionHeaderComponent with sections prop'); + return ; + } + _renderSeparator = (sID, rID) => ; +} + +module.exports = MetroListView; diff --git a/Libraries/Experimental/ViewabilityHelper.js b/Libraries/Experimental/ViewabilityHelper.js index 90798e1e6..fcaddac54 100644 --- a/Libraries/Experimental/ViewabilityHelper.js +++ b/Libraries/Experimental/ViewabilityHelper.js @@ -11,6 +11,10 @@ */ 'use strict'; +const invariant = require('invariant'); + +export type Viewable = {item: any, key: string, index: ?number, isViewable: boolean, section?: any}; + /** * A row is said to be in a "viewable" state when either of the following * is true: @@ -18,22 +22,31 @@ * - Entirely visible on screen */ const ViewabilityHelper = { - computeViewableRows( + computeViewableItems( viewablePercentThreshold: number, - rowFrames: {[key: string]: Object}, - data: Array<{rowKey: string, rowData: any}>, - scrollOffsetY: number, - viewportHeight: number + itemCount: number, + scrollOffset: number, + viewportHeight: number, + getFrameMetrics: (index: number) => ?{length: number, offset: number}, + renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size ): Array { - const viewableRows = []; + const viewableIndices = []; + if (itemCount === 0) { + return viewableIndices; + } let firstVisible = -1; - for (let idx = 0; idx < data.length; idx++) { - const frame = rowFrames[data[idx].rowKey]; - if (!frame) { + const {first, last} = renderRange || {first: 0, last: itemCount - 1}; + invariant( + last < itemCount, + 'Invalid render range ' + JSON.stringify({renderRange, itemCount}) + ); + for (let idx = first; idx <= last; idx++) { + const metrics = getFrameMetrics(idx); + if (!metrics) { continue; } - const top = frame.y - scrollOffsetY; - const bottom = top + frame.height; + const top = metrics.offset - scrollOffset; + const bottom = top + metrics.length; if ((top < viewportHeight) && (bottom > 0)) { firstVisible = idx; if (_isViewable( @@ -42,16 +55,17 @@ const ViewabilityHelper = { bottom, viewportHeight )) { - viewableRows.push(idx); + viewableIndices.push(idx); } } else if (firstVisible >= 0) { break; } } - return viewableRows; + return viewableIndices; }, }; + function _isViewable( viewablePercentThreshold: number, top: number, diff --git a/Libraries/Experimental/VirtualizeUtils.js b/Libraries/Experimental/VirtualizeUtils.js new file mode 100644 index 000000000..c366cc80a --- /dev/null +++ b/Libraries/Experimental/VirtualizeUtils.js @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule VirtualizeUtils + * @flow + */ +'use strict'; + +const invariant = require('invariant'); + +/** + * Used to find the indices of the frames that overlap the given offsets. Useful for finding the + * items that bound different windows of content, such as the visible area or the buffered overscan + * area. + */ +function elementsThatOverlapOffsets( + offsets: Array, + itemCount: number, + getFrameMetrics: (index: number) => {length: number, offset: number}, +): Array { + const out = []; + for (let ii = 0; ii < itemCount; ii++) { + const frame = getFrameMetrics(ii); + const trailingOffset = frame.offset + frame.length; + for (let kk = 0; kk < offsets.length; kk++) { + if (out[kk] == null && trailingOffset >= offsets[kk]) { + out[kk] = ii; + if (kk === offsets.length - 1) { + invariant( + out.length === offsets.length, + 'bad offsets input, should be in increasing order ' + JSON.stringify(offsets) + ); + return out; + } + } + } + } + return out; +} + +/** + * Computes the number of elements in the `next` range that are new compared to the `prev` range. + * Handy for calculating how many new items will be rendered when the render window changes so we + * can restrict the number of new items render at once so that content can appear on the screen + * faster. + */ +function newRangeCount( + prev: {first: number, last: number}, + next: {first: number, last: number}, +): number { + return (next.last - next.first + 1) - + Math.max( + 0, + 1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first) + ); +} + +/** + * Custom logic for determining which items should be rendered given the current frame and scroll + * metrics, as well as the previous render state. The algorithm may evolve over time, but generally + * prioritizes the visible area first, then expands that with overscan regions ahead and behind, + * biased in the direction of scroll. + */ +function computeWindowedRenderLimits( + props: { + data: any, + getItemCount: (data: any) => number, + maxToRenderPerBatch: number, + windowSize: number, + }, + prev: {first: number, last: number}, + getFrameMetricsApprox: (index: number) => {length: number, offset: number}, + scrollMetrics: {dt: number, offset: number, velocity: number, visibleLength: number}, +): {first: number, last: number} { + const {data, getItemCount, maxToRenderPerBatch, windowSize} = props; + const itemCount = getItemCount(data); + if (itemCount === 0) { + return prev; + } + const {offset, velocity, visibleLength} = scrollMetrics; + + // Start with visible area, then compute maximum overscan region by expanding from there, biased + // in the direction of scroll. Total overscan area is capped, which should cap memory consumption + // too. + const visibleBegin = Math.max(0, offset); + const visibleEnd = visibleBegin + visibleLength; + const overscanLength = (windowSize - 1) * visibleLength; + const leadFactor = Math.max(0, Math.min(1, velocity / 5 + 0.5)); + const overscanBegin = Math.max(0, visibleBegin - (1 - leadFactor) * overscanLength); + const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength); + + // Find the indices that correspond to the items at the render boundaries we're targetting. + let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets( + [overscanBegin, visibleBegin, visibleEnd, overscanEnd], + props.getItemCount(props.data), + getFrameMetricsApprox, + ); + overscanFirst = overscanFirst == null ? 0 : overscanFirst; + first = first == null ? Math.max(0, overscanFirst) : first; + overscanLast = overscanLast == null ? (itemCount - 1) : overscanLast; + last = last == null ? Math.min(overscanLast, first + maxToRenderPerBatch - 1) : last; + const visible = {first, last}; + + // We want to limit the number of new cells we're rendering per batch so that we can fill the + // content on the screen quickly. If we rendered the entire overscan window at once, the user + // could be staring at white space for a long time waiting for a bunch of offscreen content to + // render. + let newCellCount = newRangeCount(prev, visible); + + while (true) { + if (first <= overscanFirst && last >= overscanLast) { + // If we fill the entire overscan range, we're done. + break; + } + const maxNewCells = newCellCount >= maxToRenderPerBatch; + const firstWillAddMore = first <= prev.first || first > prev.last; + const firstShouldIncrement = first > overscanFirst && (!maxNewCells || !firstWillAddMore); + const lastWillAddMore = last >= prev.last || last < prev.first; + const lastShouldIncrement = last < overscanLast && (!maxNewCells || !lastWillAddMore); + if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) { + // We only want to stop if we've hit maxNewCells AND we cannot increment first or last + // without rendering new items. This let's us preserve as many already rendered items as + // possible, reducing render churn and keeping the rendered overscan range as large as + // possible. + break; + } + if (firstShouldIncrement) { + if (firstWillAddMore) { + newCellCount++; + } + first--; + } + if (lastShouldIncrement) { + if (lastWillAddMore) { + newCellCount++; + } + last++; + } + } + if (!( + last >= first && + first >= 0 && last < itemCount && + first >= overscanFirst && last <= overscanLast && + first <= visible.first && last >= visible.last + )) { + throw new Error('Bad window calculation ' + + JSON.stringify({first, last, itemCount, overscanFirst, overscanLast, visible})); + } + return {first, last}; +} + +const VirtualizeUtils = { + computeWindowedRenderLimits, + elementsThatOverlapOffsets, + newRangeCount, +}; + +module.exports = VirtualizeUtils; diff --git a/Libraries/Experimental/VirtualizedList.js b/Libraries/Experimental/VirtualizedList.js new file mode 100644 index 000000000..2c32c9f5c --- /dev/null +++ b/Libraries/Experimental/VirtualizedList.js @@ -0,0 +1,595 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * 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 VirtualizedList + * @flow + */ +'use strict'; + +const Batchinator = require('Batchinator'); +const React = require('React'); +const RefreshControl = require('RefreshControl'); +const ScrollView = require('ScrollView'); +const View = require('View'); +const ViewabilityHelper = require('ViewabilityHelper'); + +const infoLog = require('infoLog'); +const invariant = require('fbjs/lib/invariant'); + +const {computeWindowedRenderLimits} = require('VirtualizeUtils'); + +import type {Viewable} from 'ViewabilityHelper'; + +type Item = any; +type ItemComponentType = ReactClass<{item: Item, index: number}>; + +/** + * Renders a virtual list of items given a data blob and accessor functions. Items that are outside + * the render window are 'virtualized' e.g. unmounted or never rendered in the first place. This + * improves performance and saves memory for large data sets, but will reset state on items that + * scroll too far out of the render window. + * + * TODO: Note that LayoutAnimation and sticky section headers both have bugs when used with this and + * are therefor not supported, but new Animated impl might work? + * https://github.com/facebook/react-native/pull/11315 + * + * TODO: removeClippedSubviews might not be necessary and may cause bugs? + * + */ +type RequiredProps = { + ItemComponent: ItemComponentType, + /** + * The default accessor functions assume this is an Array<{key: string}> but you can override + * getItem, getItemCount, and keyExtractor to handle any type of index-based data. + */ + data: any, +}; +type OptionalProps = { + FooterComponent?: ?ReactClass<*>, + HeaderComponent?: ?ReactClass<*>, + SeparatorComponent?: ?ReactClass<*>, + /** + * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully + * unmounts react instances that are outside of the render window. You should only need to disable + * this for debugging purposes. + */ + disableVirtualization: boolean, + getItem: (items: any, index: number) => ?Item, + getItemCount: (items: any) => number, + getItemLayout?: (items: any, index: number) => {length: number, offset: number}, // e.g. height, y + horizontal: boolean, + initialNumToRender: number, + keyExtractor: (item: Item, index: number) => string, + maxToRenderPerBatch: number, + onEndReached: ({distanceFromEnd: number}) => void, + onEndReachedThreshold: number, // units of visible length + onLayout?: ?Function, + /** + * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make + * sure to also set the `refreshing` prop correctly. + */ + onRefresh?: ?Function, + /** + * Called when the viewability of rows changes, as defined by the + * `viewablePercentThreshold` prop. + */ + onViewableItemsChanged?: ({viewableItems: Array, changed: Array}) => void, + /** + * Set this true while waiting for new data from a refresh. + */ + refreshing?: boolean, + renderScrollComponent: (props: Object) => React.Element<*>, + shouldItemUpdate: ( + props: {item: Item, index: number}, + nextProps: {item: Item, index: number} + ) => boolean, + updateCellsBatchingPeriod: number, + /** + * Percent of viewport that must be covered for a partially occluded item to count as + * "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means + * that a single pixel in the viewport makes the item viewable, and a value of 100 means that + * an item must be either entirely visible or cover the entire viewport to count as viewable. + */ + viewablePercentThreshold: number, + windowSize: number, // units of visible length +}; +type Props = RequiredProps & OptionalProps; + +let _usedIndexForKey = false; + +class VirtualizedList extends React.PureComponent { + props: Props; + + // scrollToEnd may be janky without getItemLayout prop + scrollToEnd(params?: ?{animated?: ?boolean}) { + const animated = params ? params.animated : true; + const veryLast = this.props.getItemCount(this.props.data) - 1; + const frame = this._getFrameMetricsApprox(veryLast); + const offset = frame.offset + frame.length + this._footerLength - + this._scrollMetrics.visibleLength; + this._scrollRef.scrollTo( + this.props.horizontal ? {x: offset, animated} : {y: offset, animated} + ); + } + + // scrollToIndex may be janky without getItemLayout prop + scrollToIndex(params: {animated?: ?boolean, index: number, viewPosition?: number}) { + const {data, horizontal, getItemCount} = this.props; + const {animated, index, viewPosition} = params; + if (!(index >= 0 && index < getItemCount(data))) { + console.warn('scrollToIndex out of range ' + index); + return; + } + const frame = this._getFrameMetricsApprox(index); + const offset = Math.max( + 0, + frame.offset - (viewPosition || 0) * (this._scrollMetrics.visibleLength - frame.length), + ); + this._scrollRef.scrollTo(horizontal ? {x: offset, animated} : {y: offset, animated}); + } + + // scrollToItem may be janky without getItemLayout prop. Required linear scan through items - + // use scrollToIndex instead if possible. + scrollToItem(params: {animated?: ?boolean, item: Item, viewPosition?: number}) { + const {item} = params; + const {data, getItem, getItemCount} = this.props; + const itemCount = getItemCount(data); + for (let index = 0; index < itemCount; index++) { + if (getItem(data, index) === item) { + this.scrollToIndex({...params, index}); + break; + } + } + } + + scrollToOffset(params: {animated?: ?boolean, offset: number}) { + const {animated, offset} = params; + this._scrollRef.scrollTo( + this.props.horizontal ? {x: offset, animated} : {y: offset, animated} + ); + } + + static defaultProps: OptionalProps = { + disableVirtualization: false, + getItem: (data: any, index: number) => data[index], + getItemCount: (data: any) => data ? data.length : 0, + horizontal: false, + initialNumToRender: 10, + keyExtractor: (item: Item, index: number) => { + if (item.key != null) { + return item.key; + } + _usedIndexForKey = true; + return String(index); + }, + maxToRenderPerBatch: 10, + onEndReached: () => {}, + onEndReachedThreshold: 2, // multiples of length + renderScrollComponent: (props: Props) => { + if (props.onRefresh) { + invariant( + typeof props.refreshing === 'boolean', + '`refreshing` prop must be set as a boolean in order to use `onRefresh`, but got `' + + JSON.stringify(props.refreshing) + '`', + ); + return ( + + } + /> + ); + } else { + return ; + } + }, + shouldItemUpdate: ( + props: {item: Item, index: number}, + nextProps: {item: Item, index: number}, + ) => true, + updateCellsBatchingPeriod: 50, + viewablePercentThreshold: 10, + windowSize: 21, // multiples of length + }; + + state = { + first: 0, + last: this.props.initialNumToRender, + }; + + constructor(props: Props) { + super(props); + this._updateCellsToRenderBatcher = new Batchinator( + this._updateCellsToRender, + this.props.updateCellsBatchingPeriod, + ); + this.state = { + first: 0, + last: Math.min(this.props.getItemCount(this.props.data), this.props.initialNumToRender) - 1, + }; + } + + componentWillUnmount() { + this._updateViewableItems(null); + this._updateCellsToRenderBatcher.dispose(); + } + + componentWillReceiveProps(newProps: Props) { + const {data, getItemCount, maxToRenderPerBatch} = newProps; + // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make + // sure we're rendering a reasonable range here. + this.setState({ + first: Math.max(0, Math.min(this.state.first, getItemCount(data) - 1 - maxToRenderPerBatch)), + last: Math.max(0, Math.min(this.state.last, getItemCount(data) - 1)), + }); + this._updateCellsToRenderBatcher.schedule(); + } + + render() { + const {FooterComponent, HeaderComponent, SeparatorComponent} = this.props; + const {data, disableVirtualization, getItem, horizontal, keyExtractor} = this.props; + const cells = []; + if (HeaderComponent) { + cells.push( + + + + ); + } + const itemCount = this.props.getItemCount(data); + if (itemCount > 0) { + _usedIndexForKey = false; + const {first, last} = this.state; + if (!disableVirtualization && first > 0) { + const firstOffset = this._getFrameMetricsApprox(first).offset - this._headerLength; + cells.push( + + ); + } + for (let ii = first; ii <= last; ii++) { + const item = getItem(data, ii); + invariant(item, 'No item for index ' + ii); + const key = keyExtractor(item, ii); + cells.push( + + ); + if (SeparatorComponent && ii < last) { + cells.push(); + } + } + if (!this._hasWarned.keys && _usedIndexForKey) { + console.warn( + 'VirtualizedList: missing keys for items, make sure to specify a key property on each ' + + 'item or provide a custom keyExtractor.' + ); + this._hasWarned.keys = true; + } + if (!disableVirtualization && last < itemCount - 1) { + const lastFrame = this._getFrameMetricsApprox(last); + const end = this.props.getItemLayout ? + itemCount - 1 : + Math.min(itemCount - 1, this._highestMeasuredFrameIndex); + const endFrame = this._getFrameMetricsApprox(end); + const tailSpacerLength = + (endFrame.offset + endFrame.length) - + (lastFrame.offset + lastFrame.length); + cells.push( + + ); + } + } + if (FooterComponent) { + cells.push( + + + + ); + } + const ret = React.cloneElement( + this.props.renderScrollComponent(this.props), + { + onContentSizeChange: this._onContentSizeChange, + onLayout: this._onLayout, + onScroll: this._onScroll, + ref: this._captureScrollRef, + scrollEventThrottle: 50, // TODO: Android support + }, + cells, + ); + return ret; + } + + componentDidUpdate() { + this._updateCellsToRenderBatcher.schedule(); + } + + _averageCellLength = 0; + _hasWarned = {}; + _highestMeasuredFrameIndex = 0; + _headerLength = 0; + _frames = {}; + _footerLength = 0; + _scrollMetrics = { + visibleLength: 0, contentLength: 0, offset: 0, dt: 10, velocity: 0, timestamp: 0, + }; + _scrollRef = (null: any); + _sentEndForContentLength = 0; + _totalCellLength = 0; + _totalCellsMeasured = 0; + _updateCellsToRenderBatcher: Batchinator; + _viewableKeys: {[key: string]: boolean} = {}; + _viewableItems: Array = []; + + _captureScrollRef = (ref) => { + this._scrollRef = ref; + }; + + _onCellLayout = (e, cellKey, index) => { + const layout = e.nativeEvent.layout; + const next = {offset: this._selectOffset(layout), length: this._selectLength(layout), index}; + const curr = this._frames[cellKey]; + if (!curr || + next.offset !== curr.offset || + next.length !== curr.length || + index !== curr.index + ) { + this._totalCellLength += next.length - (curr ? curr.length : 0); + this._totalCellsMeasured += (curr ? 0 : 1); + this._averageCellLength = this._totalCellLength / this._totalCellsMeasured; + this._frames[cellKey] = next; + this._highestMeasuredFrameIndex = Math.max(this._highestMeasuredFrameIndex, index); + this._updateCellsToRenderBatcher.schedule(); + } + }; + + _onLayout = (e: Object) => { + this._scrollMetrics.visibleLength = this._selectLength(e.nativeEvent.layout); + this.props.onLayout && this.props.onLayout(e); + this._updateCellsToRenderBatcher.schedule(); + }; + + _onLayoutFooter = (e) => { + this._footerLength = this._selectLength(e.nativeEvent.layout); + }; + + _onLayoutHeader = (e) => { + this._headerLength = this._selectLength(e.nativeEvent.layout); + }; + + _selectLength(metrics: {height: number, width: number}): number { + return !this.props.horizontal ? metrics.height : metrics.width; + } + + _selectOffset(metrics: {x: number, y: number}): number { + return !this.props.horizontal ? metrics.y : metrics.x; + } + + _onContentSizeChange = (width: number, height: number) => { + this._scrollMetrics.contentLength = this._selectLength({height, width}); + this._updateCellsToRenderBatcher.schedule(); + }; + + _onScroll = (e: Object) => { + const timestamp = e.timeStamp; + const visibleLength = this._selectLength(e.nativeEvent.layoutMeasurement); + const contentLength = this._selectLength(e.nativeEvent.contentSize); + const offset = this._selectOffset(e.nativeEvent.contentOffset); + const dt = Math.max(1, timestamp - this._scrollMetrics.timestamp); + if (dt > 500 && this._scrollMetrics.dt > 500 && (contentLength > (5 * visibleLength)) && + !this._hasWarned.perf) { + infoLog( + 'VirtualizedList: You have a large list that is slow to update - make sure ' + + 'shouldItemUpdate is implemented effectively and consider getItemLayout, PureComponent, ' + + 'etc.', + {dt, prevDt: this._scrollMetrics.dt, contentLength}, + ); + this._hasWarned.perf = true; + } + const dOffset = offset - this._scrollMetrics.offset; + const velocity = dOffset / dt; + this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength}; + const {data, getItemCount, onEndReached, onEndReachedThreshold, windowSize} = this.props; + if (!data) { + return; + } + const distanceFromEnd = contentLength - visibleLength - offset; + const itemCount = getItemCount(data); + if (distanceFromEnd < onEndReachedThreshold * visibleLength && + this._scrollMetrics.contentLength !== this._sentEndForContentLength && + this.state.last === itemCount - 1) { + // Only call onEndReached for a given content length once. + this._sentEndForContentLength = this._scrollMetrics.contentLength; + onEndReached({distanceFromEnd}); + } + const {first, last} = this.state; + if ((first > 0 && velocity < 0) || (last < itemCount - 1 && velocity > 0)) { + const distanceToContentEdge = Math.min( + Math.abs(this._getFrameMetricsApprox(first).offset - offset), + Math.abs(this._getFrameMetricsApprox(last).offset - (offset + visibleLength)), + ); + const hiPri = distanceToContentEdge < (windowSize * visibleLength / 4); + if (hiPri) { + // Don't worry about interactions when scrolling quickly; focus on filling content as fast + // as possible. + this._updateCellsToRenderBatcher.dispose({abort: true}); + this._updateCellsToRender(); + return; + } + } + this._updateCellsToRenderBatcher.schedule(); + }; + + _updateCellsToRender = () => { + const {data, disableVirtualization, getItemCount, onEndReachedThreshold} = this.props; + this._updateViewableItems(data); + if (!data) { + return; + } + this.setState((state) => { + let newState; + if (!disableVirtualization) { + newState = computeWindowedRenderLimits( + this.props, state, this._getFrameMetricsApprox, this._scrollMetrics, + ); + } else { + const {contentLength, offset, visibleLength} = this._scrollMetrics; + const distanceFromEnd = contentLength - visibleLength - offset; + const renderAhead = distanceFromEnd < onEndReachedThreshold * visibleLength ? + this.props.maxToRenderPerBatch : 0; + newState = { + first: 0, + last: Math.min(state.last + renderAhead, getItemCount(data) - 1), + }; + } + return newState; + }); + }; + + _createViewable(index: number, isViewable: boolean): Viewable { + const {data, getItem, keyExtractor} = this.props; + const item = getItem(data, index); + invariant(item, 'Missing item for index ' + index); + return {index, item, key: keyExtractor(item, index), isViewable}; + } + + _getFrameMetricsApprox = (index: number): {length: number, offset: number} => { + const frame = this._getFrameMetrics(index); + if (frame && frame.index === index) { // check for invalid frames due to row re-ordering + return frame; + } else { + const {getItemLayout} = this.props; + invariant( + !getItemLayout, + 'Should not have to estimate frames when a measurement metrics function is provided' + ); + return { + length: this._averageCellLength, + offset: this._averageCellLength * index, + }; + } + }; + + _getFrameMetrics = (index: number): ?{length: number, offset: number, index: number} => { + const {data, getItem, getItemCount, getItemLayout, keyExtractor} = this.props; + invariant(getItemCount(data) > index, 'Tried to get frame for out of range index ' + index); + const item = getItem(data, index); + let frame = item && this._frames[keyExtractor(item, index)]; + if (!frame || frame.index !== index) { + if (getItemLayout) { + frame = getItemLayout(data, index); + } + } + return frame; + }; + + _updateViewableItems(data: any) { + const {getItemCount, onViewableItemsChanged, viewablePercentThreshold} = this.props; + if (!onViewableItemsChanged) { + return; + } + let viewableIndices = []; + if (data) { + viewableIndices = ViewabilityHelper.computeViewableItems( + viewablePercentThreshold, + getItemCount(data), + this._scrollMetrics.offset, + this._scrollMetrics.visibleLength, + this._getFrameMetrics, + this.state, + ); + } + const viewableKeys = {}; + const viewableItems = viewableIndices.map((ii) => { + const viewable = this._createViewable(ii, true); + viewableKeys[viewable.key] = true; + return viewable; + }); + const changed = viewableItems.filter(v => !this._viewableKeys[v.key]) + .concat( + this._viewableItems.filter(v => !viewableKeys[v.key]) + .map(v => ({...v, isViewable: false})) + ); + if (changed.length > 0) { + onViewableItemsChanged({viewableItems, changed}); + this._viewableItems = viewableItems; + this._viewableKeys = viewableKeys; + } + } +} + +class CellRenderer extends React.Component { + props: { + cellKey: string, + index: number, + item: Item, + onLayout: (event: Object, cellKey: string, index: number) => void, + parentProps: { + ItemComponent: ItemComponentType, + getItemLayout?: ?Function, + shouldItemUpdate: ( + props: {item: Item, index: number}, + nextProps: {item: Item, index: number} + ) => boolean, + }, + }; + _onLayout = (e) => { + this.props.onLayout(e, this.props.cellKey, this.props.index); + } + shouldComponentUpdate(nextProps, nextState) { + const curr = {item: this.props.item, index: this.props.index}; + const next = {item: nextProps.item, index: nextProps.index}; + return nextProps.parentProps.shouldItemUpdate(curr, next); + } + render() { + const {item, index, parentProps} = this.props; + const {ItemComponent, getItemLayout} = parentProps; + const element = ; + if (getItemLayout) { + return element; + } + return ( + + {element} + + ); + } +} + +module.exports = VirtualizedList; diff --git a/Libraries/Experimental/WindowedListView.js b/Libraries/Experimental/WindowedListView.js index d00c29bf5..9bc4ad553 100644 --- a/Libraries/Experimental/WindowedListView.js +++ b/Libraries/Experimental/WindowedListView.js @@ -257,6 +257,10 @@ class WindowedListView extends React.Component { _onMomentumScrollEnd = (e: Object) => { this._onScroll(e); }; + _getFrameMetrics = (index: number): ?{length: number, offset: number} => { + const frame = this._rowFrames[this.props.data[index].rowKey]; + return frame && {length: frame.height, offset: frame.y}; + } _onScroll = (e: Object) => { const newScrollY = e.nativeEvent.contentOffset.y; this._isScrolling = this._scrollOffsetY !== newScrollY; @@ -268,12 +272,12 @@ class WindowedListView extends React.Component { this._computeRowsToRenderBatcher.schedule(); } if (this.props.onViewableRowsChanged && Object.keys(this._rowFrames).length) { - const viewableRows = ViewabilityHelper.computeViewableRows( + const viewableRows = ViewabilityHelper.computeViewableItems( this.props.viewablePercentThreshold, - this._rowFrames, - this.props.data, + this.props.data.length, e.nativeEvent.contentOffset.y, - e.nativeEvent.layoutMeasurement.height + e.nativeEvent.layoutMeasurement.height, + this._getFrameMetrics, ); if (deepDiffer(viewableRows, this._viewableRows)) { this._viewableRows = viewableRows; diff --git a/Libraries/Experimental/__tests__/ViewabilityHelper-test.js b/Libraries/Experimental/__tests__/ViewabilityHelper-test.js new file mode 100644 index 000000000..9056bec83 --- /dev/null +++ b/Libraries/Experimental/__tests__/ViewabilityHelper-test.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ +'use strict'; + +jest.unmock('ViewabilityHelper'); +const ViewabilityHelper = require('ViewabilityHelper'); + +let rowFrames; +let data; +function getFrameMetrics(index: number) { + const frame = rowFrames[data[index].key]; + return {length: frame.height, offset: frame.y}; +} + +describe('computeViewableItems', function() { + it('returns all 4 entirely visible rows as viewable', function() { + rowFrames = { + a: {y: 0, height: 50}, + b: {y: 50, height: 50}, + c: {y: 100, height: 50}, + d: {y: 150, height: 50}, + }; + data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}]; + expect(ViewabilityHelper.computeViewableItems(50, data.length, 0, 200, getFrameMetrics)) + .toEqual([0, 1, 2, 3]); + }); + + it( + 'returns top 2 rows as viewable (1. entirely visible and 2. majority)', + function() { + rowFrames = { + a: {y: 0, height: 50}, + b: {y: 50, height: 150}, + c: {y: 200, height: 50}, + d: {y: 250, height: 50}, + }; + data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}]; + expect(ViewabilityHelper.computeViewableItems(50, data.length, 0, 200, getFrameMetrics)) + .toEqual([0, 1]); + }); + + it( + 'returns only 2nd row as viewable (majority)', + function() { + rowFrames = { + a: {y: 0, height: 50}, + b: {y: 50, height: 150}, + c: {y: 200, height: 50}, + d: {y: 250, height: 50}, + }; + data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}]; + expect(ViewabilityHelper.computeViewableItems(50, data.length, 25, 200, getFrameMetrics)) + .toEqual([1]); + }); + + it( + 'handles empty input', + function() { + rowFrames = {}; + data = []; + expect(ViewabilityHelper.computeViewableItems(50, data.length, 0, 200, getFrameMetrics)) + .toEqual([]); + }); +}); diff --git a/Libraries/Experimental/__tests__/VirtualizeUtils-test.js b/Libraries/Experimental/__tests__/VirtualizeUtils-test.js new file mode 100644 index 000000000..3c0d95ef6 --- /dev/null +++ b/Libraries/Experimental/__tests__/VirtualizeUtils-test.js @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ +'use strict'; + +jest.unmock('VirtualizeUtils'); + +const { elementsThatOverlapOffsets, newRangeCount } = require('VirtualizeUtils'); + +describe('newRangeCount', function() { + it('handles subset', function() { + expect(newRangeCount({first: 1, last: 4}, {first: 2, last: 3})).toBe(0); + }); + it('handles forward disjoint set', function() { + expect(newRangeCount({first: 1, last: 4}, {first: 6, last: 9})).toBe(4); + }); + it('handles reverse disjoint set', function() { + expect(newRangeCount({first: 6, last: 8}, {first: 1, last: 4})).toBe(4); + }); + it('handles superset', function() { + expect(newRangeCount({first: 1, last: 4}, {first: 0, last: 5})).toBe(2); + }); + it('handles end extension', function() { + expect(newRangeCount({first: 1, last: 4}, {first: 1, last: 8})).toBe(4); + }); + it('handles front extension', function() { + expect(newRangeCount({first: 1, last: 4}, {first: 0, last: 4})).toBe(1); + }); + it('handles forward insersect', function() { + expect(newRangeCount({first: 1, last: 4}, {first: 3, last: 6})).toBe(2); + }); + it('handles reverse intersect', function() { + expect(newRangeCount({first: 3, last: 6}, {first: 1, last: 4})).toBe(2); + }); +}); + +describe('elementsThatOverlapOffsets', function() { + it('handles fixed length', function() { + const offsets = [0, 250, 350, 450]; + function getFrameMetrics(index: number) { + return { + length: 100, + offset: (100 * index), + }; + } + expect(elementsThatOverlapOffsets(offsets, 100, getFrameMetrics)).toEqual([0, 2, 3, 4]); + }); + it('handles variable length', function() { + const offsets = [150, 250, 900]; + const frames = [ + {offset: 0, length: 50}, + {offset: 50, length: 200}, + {offset: 250, length: 600}, + {offset: 850, length: 100}, + {offset: 950, length: 150}, + ]; + expect(elementsThatOverlapOffsets(offsets, frames.length, (ii) => frames[ii])).toEqual([1,1,3]); + }); + it('handles out of bounds', function() { + const offsets = [150, 900]; + const frames = [ + {offset: 0, length: 50}, + {offset: 50, length: 150}, + {offset: 250, length: 100}, + ]; + expect(elementsThatOverlapOffsets(offsets, frames.length, (ii) => frames[ii])).toEqual([1]); + }); +});