Better ListView - FlatList

Summary:
We really need a better list view - so here it is!

Main changes from existing `ListView`:

* Items are "virtualized" to limit memory - that is, items outside of the render window are unmounted and their memory is reclaimed. This means that instance state is not preserved when items scroll out of the render window.
* No `DataSource` - just a simple `data` prop of shape `Array<any>`. By default, they are expected to be of the shape `{key: string}` but a custom `rowExtractor` function can be provided for different shapes, e.g. graphql data where you want to map `id` to `key`. Note the underlying `VirtualizedList` is much more flexible.
* Fancy `scrollTo` functionality: `scrollToEnd`, `scrollToIndex`, and `scrollToItem` in addition to the normal `scrollToOffset`.
* Built-in pull to refresh support - set set the `onRefresh` and `refreshing` props.
* Rendering additional rows is usually done with low priority, after any interactions/animations complete, unless we're about to run out of rendered content. This should help apps feel more responsive.
* Component props replace render functions, e.g. `ItemComponent: ReactClass<{item: Item, index: number}>` replaces `renderRow: (...) => React.Element<*>`
* Supports dynamic items automatically by using `onLayout`, or `getItemLayout` can be provided for a perf boost and smoother `scrollToIndex` and scroll bar behavior.
* Visibility callback replaced with more powerful viewability callback and works in vertical and horizontal mode on at least Android and iOS, but probably other platforms as well. Extra power comes from the `viewablePercentThreshold` that lets the client decide when an item should be considered viewable.

Demo:

https://www.facebook.com/groups/576288835853049/permalink/753923058089625/

Reviewed By: yungsters

Differential Revision: D4412469

fbshipit-source-id: e2d891490bf76fe14df49294ecddf78a58adcf23
This commit is contained in:
Spencer Ahrens 2017-02-04 10:25:32 -08:00 committed by Facebook Github Bot
parent 57daad98f0
commit a3457486e3
12 changed files with 1896 additions and 18 deletions

View File

@ -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 = '<FlatList>';
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 (
<UIExplorerPage
noSpacer={true}
noScroll={true}>
<View style={styles.searchRow}>
<PlainInput
onChangeText={this._onChangeFilterText}
placeholder="Search..."
value={this.state.filterText}
/>
<PlainInput
onChangeText={this._onChangeScrollToIndex}
placeholder="scrollToIndex..."
style={styles.searchTextInput}
/>
<View style={styles.options}>
{renderSmallSwitchOption(this, 'virtualized')}
{renderSmallSwitchOption(this, 'horizontal')}
{renderSmallSwitchOption(this, 'fixedHeight')}
{renderSmallSwitchOption(this, 'logViewable')}
</View>
</View>
<FlatList
HeaderComponent={HeaderComponent}
FooterComponent={FooterComponent}
ItemComponent={this._renderItemComponent}
SeparatorComponent={SeparatorComponent}
disableVirtualization={!this.state.virtualized}
getItemLayout={this.state.fixedHeight ? this._getItemLayout : undefined}
horizontal={this.state.horizontal}
data={filteredData}
key={(this.state.horizontal ? 'h' : 'v') + (this.state.fixedHeight ? 'f' : 'd')}
legacyImplementation={false}
onRefresh={() => alert('onRefresh: nothing to refresh :P')}
refreshing={false}
onViewableItemsChanged={this._onViewableItemsChanged}
ref={this._captureRef}
shouldItemUpdate={this._shouldItemUpdate}
/>
</UIExplorerPage>
);
}
_captureRef = (ref) => { this._listRef = ref; };
_getItemLayout = (data: any, index: number) => {
return getItemLayout(data, index, this.state.horizontal);
};
_renderItemComponent = ({item}) => {
return (
<ItemComponent
item={item}
horizontal={this.state.horizontal}
fixedHeight={this.state.fixedHeight}
onPress={this._pressItem}
/>
);
};
_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;

View File

@ -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<Item> {
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 (
<TouchableHighlight
onPress={this._onPress}
style={horizontal ? styles.horizItem : styles.item}>
<View style={[
styles.row, horizontal && {width: HORIZ_WIDTH}]}>
<Image style={styles.thumb} source={imgSource} />
<Text
style={styles.text}
numberOfLines={(horizontal || fixedHeight) ? 3 : undefined}>
{item.title} - {item.text}
</Text>
</View>
</TouchableHighlight>
);
}
}
class FooterComponent extends React.PureComponent {
render() {
return (
<View>
<SeparatorComponent />
<View style={styles.headerFooter}>
<Text>FOOTER</Text>
</View>
</View>
);
}
}
class HeaderComponent extends React.PureComponent {
render() {
return (
<View>
<View style={styles.headerFooter}>
<Text>HEADER</Text>
</View>
<SeparatorComponent />
</View>
);
}
}
class SeparatorComponent extends React.PureComponent {
render() {
return <View style={styles.separator} />;
}
}
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 (
<View style={styles.option}>
<Text>{key}:</Text>
<Switch
style={styles.smallSwitch}
value={context.state[key]}
onValueChange={(value) => context.setState({[key]: value})}
/>
</View>
);
}
function PlainInput({placeholder, value, onChangeText}: Object) {
return (
<TextInput
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="always"
onChangeText={onChangeText}
placeholder={placeholder}
underlineColorAndroid="transparent"
style={styles.searchTextInput}
value={value}
/>
);
}
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,
};

View File

@ -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 (
<UIExplorerPage
title={this.props.navigator ? null : '<FlatList> - 2 Columns'}
noSpacer={true}
noScroll={true}>
<View style={styles.searchRow}>
<PlainInput
onChangeText={this._onChangeFilterText}
placeholder="Search..."
value={this.state.filterText}
/>
<View style={styles.row}>
{renderSmallSwitchOption(this, 'virtualized')}
{renderSmallSwitchOption(this, 'fixedHeight')}
{renderSmallSwitchOption(this, 'logViewable')}
</View>
</View>
<FlatList
FooterComponent={FooterComponent}
HeaderComponent={HeaderComponent}
ItemComponent={this._renderItemComponent}
SeparatorComponent={SeparatorComponent}
getItemLayout={this.state.fixedHeight ? this._getItemLayout : undefined}
data={grid}
key={this.state.fixedHeight ? 'f' : 'v'}
shouldItemUpdate={this._shouldItemUpdate}
disableVirtualization={!this.state.virtualized}
onViewableItemsChanged={this._onViewableItemsChanged}
legacyImplementation={false}
/>
</UIExplorerPage>
);
}
_getItemLayout(data: any, index: number): {length: number, offset: number} {
return getItemLayout(data, index);
}
_renderItemComponent = ({item}) => {
return (
<View style={styles.row}>
{item.columns.map((it, ii) => (
<ItemComponent
key={ii}
item={it}
fixedHeight={this.state.fixedHeight}
onPress={this._pressItem}
/>
))}
</View>
);
};
_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;

View File

@ -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);

View File

@ -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<Item>,
};
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<Item>, 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<Viewable>, changed: Array<Viewable>}) => 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:
*
* <FlatList
* data={[{key: 'a', {key: 'b'}]}
* ItemComponent={({item}) => <Text>{item.key}</Text>}
* />
*/
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 <MetroListView {...this.props} items={this.props.data} ref={this._captureRef} />;
} else {
return <VirtualizedList {...this.props} ref={this._captureRef} />;
}
}
}
module.exports = FlatList;

View File

@ -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<Item>, // By default, an Item is assumed to be {key: string}
sections?: ?Array<{key: string, items: Array<Item>}>,
/**
* 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 (
<ScrollView
{...props}
refreshControl={
<RefreshControl
refreshing={props.refreshing}
onRefresh={props.onRefresh}
/>
}
/>
);
} else {
return <ScrollView {...props} />;
}
},
};
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 (
<ListView
{...this.props}
dataSource={this.state.ds}
ref={this._captureRef}
renderRow={this._renderRow}
renderFooter={this.props.FooterComponent && this._renderFooter}
renderSectionHeader={this.props.sections && this._renderSectionHeader}
renderSeparator={this.props.SeparatorComponent && this._renderSeparator}
/>
);
}
_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 = () => <this.props.FooterComponent key="$footer" />;
_renderRow = (item, sectionID, rowID, highlightRow) => {
const {ItemComponent} = this.props;
return <ItemComponent item={item} index={rowID} />;
};
_renderSectionHeader = (section, sectionID) => {
const {SectionHeaderComponent} = this.props;
invariant(SectionHeaderComponent, 'Must provide SectionHeaderComponent with sections prop');
return <SectionHeaderComponent section={section} />;
}
_renderSeparator = (sID, rID) => <this.props.SeparatorComponent key={sID + rID} />;
}
module.exports = MetroListView;

View File

@ -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<number> {
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,

View File

@ -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<number>,
itemCount: number,
getFrameMetrics: (index: number) => {length: number, offset: number},
): Array<number> {
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;

View File

@ -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<Viewable>, changed: Array<Viewable>}) => 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 (
<ScrollView
{...props}
refreshControl={
<RefreshControl
refreshing={props.refreshing}
onRefresh={props.onRefresh}
/>
}
/>
);
} else {
return <ScrollView {...props} />;
}
},
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(
<View key="$header" onLayout={this._onLayoutHeader}>
<HeaderComponent />
</View>
);
}
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(
<View key="$lead_spacer" style={{[!horizontal ? 'height' : 'width']: firstOffset}} />
);
}
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(
<CellRenderer
cellKey={key}
index={ii}
item={item}
key={key}
onLayout={this._onCellLayout}
parentProps={this.props}
/>
);
if (SeparatorComponent && ii < last) {
cells.push(<SeparatorComponent key={'sep' + ii}/>);
}
}
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(
<View key="$tail_spacer" style={{[!horizontal ? 'height' : 'width']: tailSpacerLength}} />
);
}
}
if (FooterComponent) {
cells.push(
<View key="$footer" onLayout={this._onLayoutFooter}>
<FooterComponent />
</View>
);
}
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<Viewable> = [];
_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 = <ItemComponent item={item} index={index} />;
if (getItemLayout) {
return element;
}
return (
<View onLayout={this._onLayout}>
{element}
</View>
);
}
}
module.exports = VirtualizedList;

View File

@ -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;

View File

@ -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([]);
});
});

View File

@ -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]);
});
});