Move new components out of Experimental directory

Summary: I think these are sufficiently baked. Also beef up comments.

Reviewed By: yungsters

Differential Revision: D4632604

fbshipit-source-id: 64ae6b240a05d62e418099f7403e1781f9b4717c
This commit is contained in:
Spencer Ahrens
2017-03-01 09:11:59 -08:00
committed by Facebook Github Bot
parent 5facc23799
commit 7b35eb3fdb
11 changed files with 30 additions and 4 deletions

View File

@@ -0,0 +1,376 @@
/**
* 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 View = require('View');
const VirtualizedList = require('VirtualizedList');
const invariant = require('invariant');
import type {StyleObj} from 'StyleSheetTypes';
import type {ViewabilityConfig, ViewToken} from 'ViewabilityHelper';
import type {Props as VirtualizedListProps} from 'VirtualizedList';
type RequiredProps<ItemT> = {
/**
* Takes an item from `data` and renders it into the list. Typicaly usage:
*
* _renderItem = ({item}) => (
* <TouchableOpacity onPress={() => this._onPress(item)}>
* <Text>{item.title}}</Text>
* <TouchableOpacity/>
* );
* ...
* <FlatList data={[{title: 'Title Text', key: 'item1'}]} renderItem={this._renderItem} />
*
* Provides additional metadata like `index` if you need it.
*/
renderItem: ({item: ItemT, index: number}) => ?React.Element<*>,
/**
* 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<ItemT>,
};
type OptionalProps<ItemT> = {
/**
* 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, index})}
*
* Remember to include separator length (height or width) in your offset calculation if you
* specify `SeparatorComponent`.
*/
getItemLayout?: (data: ?Array<ItemT>, index: number) =>
{length: number, offset: number, index: 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: ItemT, index: number) => string,
/**
* Multiple columns can only be rendered with horizontal={false} and will zig-zag like a flexWrap
* layout. Items should all be the same height - masonry layouts are not supported.
*/
numColumns: number,
/**
* 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<ViewToken>, changed: Array<ViewToken>}) => void,
legacyImplementation?: ?boolean,
/**
* Set this true while waiting for new data from a refresh.
*/
refreshing?: ?boolean,
/**
* Optional custom style for multi-item rows generated when numColumns > 1
*/
columnWrapperStyle?: StyleObj,
/**
* Optional optimization to minimize re-rendering items.
*/
shouldItemUpdate: (
prevInfo: {item: ItemT, index: number},
nextInfo: {item: ItemT, index: number}
) => boolean,
/**
* See ViewabilityHelper for flow type and further documentation.
*/
viewabilityConfig?: ViewabilityConfig,
};
type Props<ItemT> = RequiredProps<ItemT> & OptionalProps<ItemT> & VirtualizedListProps;
const defaultProps = {
...VirtualizedList.defaultProps,
getItem: undefined,
getItemCount: undefined,
numColumns: 1,
};
type DefaultProps = typeof defaultProps;
/**
* 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 for now.
*
* Minimal Example:
*
* <FlatList
* data={[{key: 'a'}, {key: 'b'}]}
* renderItem={({item}) => <Text>{item.key}</Text>}
* />
*
* Some notes for all of the `VirtualizedList` based components:
* - Internal state is not preserved when content scrolls out of the render window. Make sure all
* your data is captured in the item data or external stores like Flux, Redux, or Relay.
* - In order to constrain memory and enable smooth scrolling, content is rendered asynchronously
* offscreen. This means it's possible to scroll faster than the fill rate ands momentarily see
* blank content. This is a tradeoff that can be adjusted to suit the needs of each application,
* and we are working on improving it behind the scenes.
* - By default, the list looks for a `key` prop on each item and uses that for the React key.
* Alternatively, you can provide a custom keyExtractor prop.
*/
class FlatList<ItemT> extends React.PureComponent<DefaultProps, Props<ItemT>, void> {
static defaultProps: DefaultProps = defaultProps;
props: Props<ItemT>;
/**
* 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: ItemT, 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);
}
/**
* Tells the list an interaction has occured, which should trigger viewability calculations, e.g.
* if waitForInteractions is true and the user has not scrolled. This is typically called by taps
* on items or by navigation actions.
*/
recordInteraction() {
this._listRef.recordInteraction();
}
componentWillMount() {
this._checkProps(this.props);
}
componentWillReceiveProps(nextProps: Props<ItemT>) {
this._checkProps(nextProps);
}
_hasWarnedLegacy = false;
_listRef: VirtualizedList;
_captureRef = (ref) => { this._listRef = ref; };
_checkProps(props: Props<ItemT>) {
const {
getItem,
getItemCount,
horizontal,
legacyImplementation,
numColumns,
columnWrapperStyle,
} = props;
invariant(!getItem && !getItemCount, 'FlatList does not support custom data formats.');
if (numColumns > 1) {
invariant(!horizontal, 'numColumns does not support horizontal.');
} else {
invariant(!columnWrapperStyle, 'columnWrapperStyle not supported for single column lists');
}
if (legacyImplementation) {
invariant(numColumns === 1, 'Legacy list does not support multiple columns.');
// 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;
}
}
}
_getItem = (data: Array<ItemT>, index: number): ItemT | Array<ItemT> => {
const {numColumns} = this.props;
if (numColumns > 1) {
const ret = [];
for (let kk = 0; kk < numColumns; kk++) {
const item = data[index * numColumns + kk];
item && ret.push(item);
}
return ret;
} else {
return data[index];
}
};
_getItemCount = (data?: ?Array<ItemT>): number => {
return data ? Math.ceil(data.length / this.props.numColumns) : 0;
};
_keyExtractor = (items: ItemT | Array<ItemT>, index: number): string => {
const {keyExtractor, numColumns} = this.props;
if (numColumns > 1) {
invariant(
Array.isArray(items),
'FlatList: Encountered internal consistency error, expected each item to consist of an ' +
'array with 1-%s columns; instead, received a single item.',
numColumns,
);
return items.map((it, kk) => keyExtractor(it, index * numColumns + kk)).join(':');
} else {
return keyExtractor(items, index);
}
};
_pushMultiColumnViewable(arr: Array<ViewToken>, v: ViewToken): void {
const {numColumns, keyExtractor} = this.props;
v.item.forEach((item, ii) => {
invariant(v.index != null, 'Missing index!');
const index = v.index * numColumns + ii;
arr.push({...v, item, key: keyExtractor(item, index), index});
});
}
_onViewableItemsChanged = (info) => {
const {numColumns, onViewableItemsChanged} = this.props;
if (!onViewableItemsChanged) {
return;
}
if (numColumns > 1) {
const changed = [];
const viewableItems = [];
info.viewableItems.forEach((v) => this._pushMultiColumnViewable(viewableItems, v));
info.changed.forEach((v) => this._pushMultiColumnViewable(changed, v));
onViewableItemsChanged({viewableItems, changed});
} else {
onViewableItemsChanged(info);
}
};
_renderItem = (info: {item: ItemT | Array<ItemT>, index: number}) => {
const {renderItem, numColumns, columnWrapperStyle} = this.props;
if (numColumns > 1) {
const {item, index} = info;
invariant(Array.isArray(item), 'Expected array of items with numColumns > 1');
return (
<View style={[{flexDirection: 'row'}, columnWrapperStyle]}>
{item.map((it, kk) => {
const element = renderItem({item: it, index: index * numColumns + kk});
return element && React.cloneElement(element, {key: kk});
})}
</View>
);
} else {
return renderItem(info);
}
};
_shouldItemUpdate = (prev, next) => {
const {numColumns, shouldItemUpdate} = this.props;
if (numColumns > 1) {
return prev.item.length !== next.item.length ||
prev.item.some((prevItem, ii) => shouldItemUpdate(
{item: prevItem, index: prev.index + ii},
{item: next.item[ii], index: next.index + ii},
));
} else {
return shouldItemUpdate(prev, next);
}
};
render() {
if (this.props.legacyImplementation) {
return <MetroListView {...this.props} items={this.props.data} ref={this._captureRef} />;
} else {
return (
<VirtualizedList
{...this.props}
renderItem={this._renderItem}
getItem={this._getItem}
getItemCount={this._getItemCount}
keyExtractor={this._keyExtractor}
ref={this._captureRef}
shouldItemUpdate={this._shouldItemUpdate}
onViewableItemsChanged={this.props.onViewableItemsChanged && this._onViewableItemsChanged}
/>
);
}
}
}
module.exports = FlatList;

View File

@@ -0,0 +1,176 @@
/**
* 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<*>,
renderItem: ({item: Item, index: number}) => ?React.Element<*>,
renderSectionHeader?: ({section: Object}) => ?React.Element<*>,
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, data: 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.data;
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) => {
return this.props.renderItem({item, index: rowID});
};
_renderSectionHeader = (section, sectionID) => {
const {renderSectionHeader} = this.props;
invariant(renderSectionHeader, 'Must provide renderSectionHeader with sections prop');
return renderSectionHeader({section});
}
_renderSeparator = (sID, rID) => <this.props.SeparatorComponent key={sID + rID} />;
}
module.exports = MetroListView;

View File

@@ -0,0 +1,158 @@
/**
* 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 SectionList
* @flow
*/
'use strict';
const MetroListView = require('MetroListView');
const React = require('React');
const VirtualizedSectionList = require('VirtualizedSectionList');
import type {ViewToken} from 'ViewabilityHelper';
import type {Props as VirtualizedSectionListProps} from 'VirtualizedSectionList';
type Item = any;
type SectionBase<SectionItemT> = {
// Must be provided directly on each section.
data: Array<SectionItemT>,
key: string,
// Optional props will override list-wide props just for this section.
renderItem?: ?({item: SectionItemT, index: number}) => ?React.Element<*>,
SeparatorComponent?: ?ReactClass<*>,
keyExtractor?: (item: SectionItemT) => string,
// TODO: support more optional/override props
// FooterComponent?: ?ReactClass<*>,
// HeaderComponent?: ?ReactClass<*>,
// onViewableItemsChanged?: ({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
};
type RequiredProps<SectionT: SectionBase<*>> = {
sections: Array<SectionT>,
};
type OptionalProps<SectionT: SectionBase<*>> = {
/**
* Default renderer for every item in every section. Can be over-ridden on a per-section basis.
*/
renderItem: ({item: Item, index: number}) => ?React.Element<*>,
/**
* Rendered in between adjacent Items within each section.
*/
ItemSeparatorComponent?: ?ReactClass<*>,
/**
* Rendered at the very beginning of the list.
*/
ListHeaderComponent?: ?ReactClass<*>,
/**
* Rendered at the very end of the list.
*/
ListFooterComponent?: ?ReactClass<*>,
/**
* Rendered at the top of each section. Sticky headers are not yet supported.
*/
renderSectionHeader?: ?({section: SectionT}) => ?React.Element<*>,
/**
* Rendered in between each section.
*/
SectionSeparatorComponent?: ?ReactClass<*>,
/**
* Warning: Virtualization can drastically improve memory consumption for long lists, but trashes
* the state of items when they scroll out of the render window, so make sure all relavent data is
* stored outside of the recursive `renderItem` instance tree.
*/
enableVirtualization?: ?boolean,
keyExtractor: (item: Item, index: number) => string,
onEndReached?: ?({distanceFromEnd: number}) => void,
/**
* 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
* `viewabilityConfig` prop.
*/
onViewableItemsChanged?: ?({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
/**
* Set this true while waiting for new data from a refresh.
*/
refreshing?: ?boolean,
/**
* This is an optional optimization to minimize re-rendering items.
*/
shouldItemUpdate: (
prevProps: {item: Item, index: number},
nextProps: {item: Item, index: number}
) => boolean,
};
type Props<SectionT> = RequiredProps<SectionT>
& OptionalProps<SectionT>
& VirtualizedSectionListProps<SectionT>;
type DefaultProps = typeof VirtualizedSectionList.defaultProps;
/**
* A performant interface for rendering sectioned lists, supporting the most handy features:
*
* - Fully cross-platform.
* - Viewability callbacks.
* - Footer support.
* - Separator support.
* - Heterogeneous data and item support.
* - Pull to Refresh.
*
* If you don't need section support and want a simpler interface, use FlatList.
*/
class SectionList<SectionT: SectionBase<*>>
extends React.PureComponent<DefaultProps, Props<SectionT>, *>
{
props: Props<SectionT>;
static defaultProps: DefaultProps = VirtualizedSectionList.defaultProps;
render() {
const {ListFooterComponent, ListHeaderComponent, ItemSeparatorComponent} = this.props;
const List = this.props.legacyImplementation ? MetroListView : VirtualizedSectionList;
return (
<List
{...this.props}
FooterComponent={ListFooterComponent}
HeaderComponent={ListHeaderComponent}
SeparatorComponent={ItemSeparatorComponent}
/>
);
}
}
module.exports = SectionList;

View File

@@ -0,0 +1,285 @@
/**
* 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 ViewabilityHelper
* @flow
*/
'use strict';
const invariant = require('invariant');
export type ViewToken = {item: any, key: string, index: ?number, isViewable: boolean, section?: any};
export type ViewabilityConfig = {|
/**
* Minimum amount of time (in milliseconds) that an item must be physically viewable before the
* viewability callback will be fired. A high number means that scrolling through content without
* stopping will not mark the content as viewable.
*/
minimumViewTime?: 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.
*/
viewAreaCoveragePercentThreshold?: number,
/**
* Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible,
* rather than the fraction of the viewable area it covers.
*/
itemVisiblePercentThreshold?: number,
/**
* Nothing is considered viewable until the user scrolls or `recordInteraction` is called after
* render.
*/
waitForInteraction?: boolean,
/**
* Criteria to filter out certain scroll events so they don't count as interactions. By default,
* any non-zero scroll offset will be considered an interaction.
*/
scrollInteractionFilter?: {|
minimumOffset?: number, // scrolls with an offset less than this are ignored.
minimumElapsed?: number, // scrolls that happen before this are ignored.
|},
|};
/**
* A Utility class for calculating viewable items based on current metrics like scroll position and
* layout.
*
* An item is said to be in a "viewable" state when any of the following
* is true for longer than `minViewTime` milliseconds (after an interaction if `waitForInteraction`
* is true):
*
* - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
* visible in the view area >= `itemVisiblePercentThreshold`.
* - Entirely visible on screen
*/
class ViewabilityHelper {
_config: ViewabilityConfig;
_hasInteracted: boolean = false;
_lastUpdateTime: number = 0;
_timers: Set<number> = new Set();
_viewableIndices: Array<number> = [];
_viewableItems: Map<string, ViewToken> = new Map();
constructor(config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}) {
invariant(
config.scrollInteractionFilter == null || config.waitForInteraction,
'scrollInteractionFilter only works in conjunction with waitForInteraction',
);
this._config = config;
}
/**
* Cleanup, e.g. on unmount. Clears any pending timers.
*/
dispose() {
this._timers.forEach(clearTimeout);
}
/**
* Determines which items are viewable based on the current metrics and config.
*/
computeViewableItems(
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 {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} = this._config;
const viewAreaMode = viewAreaCoveragePercentThreshold != null;
const viewablePercentThreshold = viewAreaMode ?
viewAreaCoveragePercentThreshold :
itemVisiblePercentThreshold;
invariant(
viewablePercentThreshold != null &&
(itemVisiblePercentThreshold != null) !== (viewAreaCoveragePercentThreshold != null),
'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold',
);
const viewableIndices = [];
if (itemCount === 0) {
return viewableIndices;
}
let firstVisible = -1;
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 = metrics.offset - scrollOffset;
const bottom = top + metrics.length;
if ((top < viewportHeight) && (bottom > 0)) {
firstVisible = idx;
if (_isViewable(
viewAreaMode,
viewablePercentThreshold,
top,
bottom,
viewportHeight,
metrics.length,
)) {
viewableIndices.push(idx);
}
} else if (firstVisible >= 0) {
break;
}
}
return viewableIndices;
}
/**
* Figures out which items are viewable and how that has changed from before and calls
* `onViewableItemsChanged` as appropriate.
*/
onUpdate(
itemCount: number,
scrollOffset: number,
viewportHeight: number,
getFrameMetrics: (index: number) => ?{length: number, offset: number},
createViewToken: (index: number, isViewable: boolean) => ViewToken,
onViewableItemsChanged: ({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size
): void {
const updateTime = Date.now();
if (this._lastUpdateTime === 0 && getFrameMetrics(0)) {
// Only count updates after the first item is rendered and has a frame.
this._lastUpdateTime = updateTime;
}
const updateElapsed = this._lastUpdateTime ? updateTime - this._lastUpdateTime : 0;
if (this._config.waitForInteraction && !this._hasInteracted && scrollOffset !== 0) {
const filter = this._config.scrollInteractionFilter;
if (filter) {
if ((filter.minimumOffset == null || scrollOffset >= filter.minimumOffset) &&
(filter.minimumElapsed == null || updateElapsed >= filter.minimumElapsed)) {
this._hasInteracted = true;
}
} else {
this._hasInteracted = true;
}
}
if (this._config.waitForInteraction && !this._hasInteracted) {
return;
}
let viewableIndices = [];
if (itemCount) {
viewableIndices = this.computeViewableItems(
itemCount,
scrollOffset,
viewportHeight,
getFrameMetrics,
renderRange,
);
}
if (this._viewableIndices.length === viewableIndices.length &&
this._viewableIndices.every((v, ii) => v === viewableIndices[ii])) {
// We might get a lot of scroll events where visibility doesn't change and we don't want to do
// extra work in those cases.
return;
}
this._viewableIndices = viewableIndices;
this._lastUpdateTime = updateTime;
if (this._config.minViewTime && updateElapsed < this._config.minViewTime) {
const handle = setTimeout(
() => {
this._timers.delete(handle);
this._onUpdateSync(viewableIndices, onViewableItemsChanged, createViewToken);
},
this._config.minViewTime,
);
this._timers.add(handle);
} else {
this._onUpdateSync(viewableIndices, onViewableItemsChanged, createViewToken);
}
}
/**
* Records that an interaction has happened even if there has been no scroll.
*/
recordInteraction() {
this._hasInteracted = true;
}
_onUpdateSync(viewableIndicesToCheck, onViewableItemsChanged, createViewToken) {
// Filter out indices that have gone out of view since this call was scheduled.
viewableIndicesToCheck = viewableIndicesToCheck.filter(
(ii) => this._viewableIndices.includes(ii)
);
const prevItems = this._viewableItems;
const nextItems = new Map(
viewableIndicesToCheck.map(ii => {
const viewable = createViewToken(ii, true);
return [viewable.key, viewable];
})
);
const changed = [];
for (const [key, viewable] of nextItems) {
if (!prevItems.has(key)) {
changed.push(viewable);
}
}
for (const [key, viewable] of prevItems) {
if (!nextItems.has(key)) {
changed.push({...viewable, isViewable: false});
}
}
if (changed.length > 0) {
this._viewableItems = nextItems;
onViewableItemsChanged({viewableItems: Array.from(nextItems.values()), changed});
}
}
}
function _isViewable(
viewAreaMode: boolean,
viewablePercentThreshold: number,
top: number,
bottom: number,
viewportHeight: number,
itemLength: number,
): bool {
if (_isEntirelyVisible(top, bottom, viewportHeight)) {
return true;
} else {
const pixels = _getPixelsVisible(top, bottom, viewportHeight);
const percent = 100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
return percent >= viewablePercentThreshold;
}
}
function _getPixelsVisible(
top: number,
bottom: number,
viewportHeight: number
): number {
const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
return Math.max(0, visibleHeight);
}
function _isEntirelyVisible(
top: number,
bottom: number,
viewportHeight: number
): bool {
return top >= 0 && bottom <= viewportHeight && bottom > top;
}
module.exports = ViewabilityHelper;

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,671 @@
/**
* 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 {ViewabilityConfig, ViewToken} from 'ViewabilityHelper';
type Item = any;
type renderItemType = ({item: Item, index: number}) => ?React.Element<*>;
/**
* Renders a virtual list of items given a data blob and accessor functions. Items that are outside
* the render window (except for the initial items at the top) 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 = {
renderItem: renderItemType,
/**
* 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<*>,
/**
* `debug` will turn on extra logging and visual overlays to aid with debugging both usage and
* implementation.
*/
debug?: ?boolean,
/**
* 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, index: 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
* `viewabilityConfig` prop.
*/
onViewableItemsChanged?: ?({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
/**
* Set this true while waiting for new data from a refresh.
*/
refreshing?: ?boolean,
removeClippedSubviews?: boolean,
renderScrollComponent: (props: Object) => React.Element<*>,
shouldItemUpdate: (
props: {item: Item, index: number},
nextProps: {item: Item, index: number}
) => boolean,
updateCellsBatchingPeriod: number,
viewabilityConfig?: ViewabilityConfig,
windowSize: number, // units of visible length
};
export type Props = RequiredProps & OptionalProps;
let _usedIndexForKey = false;
class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
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}
);
}
recordInteraction() {
this._viewabilityHelper.recordInteraction();
this._updateViewableItems(this.props.data);
}
static defaultProps = {
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
removeClippedSubviews: true,
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,
windowSize: 21, // multiples of length
};
state = {
first: 0,
last: this.props.initialNumToRender,
};
constructor(props: Props) {
super(props);
invariant(
!props.onScroll || !props.onScroll.__isNative,
'VirtualizedList does not support AnimatedEvent with onScroll and useNativeDriver',
);
this._updateCellsToRenderBatcher = new Batchinator(
this._updateCellsToRender,
this.props.updateCellsBatchingPeriod,
);
this._viewabilityHelper = new ViewabilityHelper(this.props.viewabilityConfig);
this.state = {
first: 0,
last: Math.min(this.props.getItemCount(this.props.data), this.props.initialNumToRender) - 1,
};
}
componentWillUnmount() {
this._updateViewableItems(null);
this._updateCellsToRenderBatcher.dispose();
this._viewabilityHelper.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();
}
_pushCells(cells, first, last) {
const {SeparatorComponent, data, getItem, getItemCount, keyExtractor} = this.props;
const end = getItemCount(data) - 1;
last = Math.min(end, last);
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}
onUnmount={this._onCellUnmount}
parentProps={this.props}
/>
);
if (SeparatorComponent && ii < end) {
cells.push(<SeparatorComponent key={'sep' + ii}/>);
}
}
}
render() {
const {FooterComponent, HeaderComponent} = this.props;
const {data, disableVirtualization, horizontal} = 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 lastInitialIndex = this.props.initialNumToRender - 1;
const {first, last} = this.state;
this._pushCells(cells, 0, lastInitialIndex);
if (!disableVirtualization && first > lastInitialIndex) {
const initBlock = this._getFrameMetricsApprox(lastInitialIndex);
const firstSpace = this._getFrameMetricsApprox(first).offset -
(initBlock.offset + initBlock.length);
cells.push(
<View key="$lead_spacer" style={{[!horizontal ? 'height' : 'width']: firstSpace}} />
);
}
this._pushCells(cells, Math.max(lastInitialIndex + 1, first), last);
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,
);
if (this.props.debug) {
return <View style={{flex: 1}}>{ret}{this._renderDebugOverlay()}</View>;
} else {
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;
_viewabilityHelper: ViewabilityHelper;
_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,
inLayout: true,
};
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();
}
};
_onCellUnmount = (cellKey: string) => {
const curr = this._frames[cellKey];
if (curr) {
this._frames[cellKey] = {...curr, inLayout: false};
}
};
_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);
};
_renderDebugOverlay() {
const normalize = this._scrollMetrics.visibleLength / this._scrollMetrics.contentLength;
const framesInLayout = [];
const itemCount = this.props.getItemCount(this.props.data);
for (let ii = 0; ii < itemCount; ii++) {
const frame = this._getFrameMetricsApprox(ii);
if (frame.inLayout) {
framesInLayout.push(frame);
}
}
const windowTop = this._getFrameMetricsApprox(this.state.first).offset;
const frameLast = this._getFrameMetricsApprox(this.state.last);
const windowLen = frameLast.offset + frameLast.length - windowTop;
const visTop = this._scrollMetrics.offset;
const visLen = this._scrollMetrics.visibleLength;
const baseStyle = {position: 'absolute', top: 0, right: 0};
return (
<View style={{...baseStyle, bottom: 0, width: 20, borderColor: 'blue', borderWidth: 1}}>
{framesInLayout.map((f, ii) =>
<View key={'f' + ii} style={{
...baseStyle,
left: 0,
top: f.offset * normalize,
height: f.length * normalize,
backgroundColor: 'orange',
}} />
)}
<View style={{
...baseStyle,
left: 0,
top: windowTop * normalize,
height: windowLen * normalize,
borderColor: 'green',
borderWidth: 2,
}} />
<View style={{
...baseStyle,
left: 0,
top: visTop * normalize,
height: visLen * normalize,
borderColor: 'red',
borderWidth: 2,
}} />
</View>
);
}
_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) => {
if (this.props.onScroll) {
this.props.onScroll(e);
}
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;
this._updateViewableItems(data);
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;
});
};
_createViewToken = (index: number, isViewable: boolean): ViewToken => {
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} = this.props;
if (!onViewableItemsChanged) {
return;
}
this._viewabilityHelper.onUpdate(
getItemCount(data),
this._scrollMetrics.offset,
this._scrollMetrics.visibleLength,
this._getFrameMetrics,
this._createViewToken,
onViewableItemsChanged,
this.state,
);
}
}
class CellRenderer extends React.Component {
props: {
cellKey: string,
index: number,
item: Item,
onLayout: (event: Object, cellKey: string, index: number) => void,
onUnmount: (cellKey: string) => void,
parentProps: {
renderItem: renderItemType,
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);
}
componentWillUnmount() {
this.props.onUnmount(this.props.cellKey);
}
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 {renderItem, getItemLayout} = parentProps;
invariant(renderItem, 'no renderItem!');
const element = renderItem({item, index});
if (getItemLayout && !parentProps.debug) {
return element;
}
return (
<View onLayout={this._onLayout}>
{element}
</View>
);
}
}
module.exports = VirtualizedList;

View File

@@ -0,0 +1,321 @@
/**
* 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 VirtualizedSectionList
* @flow
*/
'use strict';
const React = require('React');
const View = require('View');
const VirtualizedList = require('VirtualizedList');
const invariant = require('invariant');
const warning = require('warning');
import type {ViewToken} from 'ViewabilityHelper';
import type {Props as VirtualizedListProps} from 'VirtualizedList';
type Item = any;
type SectionItem = any;
type SectionBase = {
// Must be provided directly on each section.
data: Array<SectionItem>,
key: string,
// Optional props will override list-wide props just for this section.
renderItem?: ?({item: SectionItem, index: number}) => ?React.Element<*>,
SeparatorComponent?: ?ReactClass<*>,
keyExtractor?: (item: SectionItem) => string,
// TODO: support more optional/override props
// FooterComponent?: ?ReactClass<*>,
// HeaderComponent?: ?ReactClass<*>,
// onViewableItemsChanged?: ({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
};
type RequiredProps<SectionT: SectionBase> = {
sections: Array<SectionT>,
};
type OptionalProps<SectionT: SectionBase> = {
/**
* Rendered after the last item in the last section.
*/
ListFooterComponent?: ?ReactClass<*>,
/**
* Rendered at the very beginning of the list.
*/
ListHeaderComponent?: ?ReactClass<*>,
/**
* Default renderer for every item in every section.
*/
renderItem: ({item: Item, index: number}) => ?React.Element<*>,
/**
* Rendered at the top of each section. In the future, a sticky option will be added.
*/
renderSectionHeader?: ?({section: SectionT}) => ?React.Element<*>,
/**
* Rendered at the bottom of every Section, except the very last one, in place of the normal
* SeparatorComponent.
*/
SectionSeparatorComponent?: ?ReactClass<*>,
/**
* Rendered at the bottom of every Item except the very last one in the last section.
*/
ItemSeparatorComponent?: ?ReactClass<*>,
/**
* Warning: Virtualization can drastically improve memory consumption for long lists, but trashes
* the state of items when they scroll out of the render window, so make sure all relavent data is
* stored outside of the recursive `renderItem` instance tree.
*/
enableVirtualization?: ?boolean,
keyExtractor: (item: Item, index: number) => string,
onEndReached?: ?({distanceFromEnd: number}) => void,
/**
* 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
* `viewabilityConfig` prop.
*/
onViewableItemsChanged?: ?({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
/**
* Set this true while waiting for new data from a refresh.
*/
refreshing?: ?boolean,
/**
* This is an optional optimization to minimize re-rendering items.
*/
shouldItemUpdate: (
prevProps: {item: Item, index: number},
nextProps: {item: Item, index: number}
) => boolean,
};
export type Props<SectionT> =
RequiredProps<SectionT> &
OptionalProps<SectionT> &
VirtualizedListProps;
type DefaultProps = (typeof VirtualizedList.defaultProps) & {data: Array<Item>};
type State = {childProps: VirtualizedListProps};
/**
* Right now this just flattens everything into one list and uses VirtualizedList under the
* hood. The only operation that might not scale well is concatting the data arrays of all the
* sections when new props are received, which should be plenty fast for up to ~10,000 items.
*/
class VirtualizedSectionList<SectionT: SectionBase>
extends React.PureComponent<DefaultProps, Props<SectionT>, State>
{
props: Props<SectionT>;
state: State;
static defaultProps: DefaultProps = {
...VirtualizedList.defaultProps,
data: [],
};
_keyExtractor = (item: Item, index: number) => {
const info = this._subExtractor(index);
return (info && info.key) || String(index);
};
_subExtractor(
index: number,
): ?{
section: SectionT,
key: string, // Key of the section or combined key for section + item
index: ?number, // Relative index within the section
} {
let itemIndex = index;
const defaultKeyExtractor = this.props.keyExtractor;
for (let ii = 0; ii < this.props.sections.length; ii++) {
const section = this.props.sections[ii];
const key = section.key;
warning(
key != null,
'VirtualizedSectionList: A `section` you supplied is missing the `key` property.'
);
itemIndex -= 1; // The section itself is an item
if (itemIndex >= section.data.length) {
itemIndex -= section.data.length;
} else if (itemIndex === -1) {
return {section, key, index: null};
} else {
const keyExtractor = section.keyExtractor || defaultKeyExtractor;
return {
section,
key: key + ':' + keyExtractor(section.data[itemIndex], itemIndex),
index: itemIndex,
};
}
}
}
_convertViewable = (viewable: ViewToken): ?ViewToken => {
invariant(viewable.index != null, 'Received a broken ViewToken');
const info = this._subExtractor(viewable.index);
if (!info) {
return null;
}
const keyExtractor = info.section.keyExtractor || this.props.keyExtractor;
return {
...viewable,
index: info.index,
key: keyExtractor(viewable.item, info.index),
section: info.section,
};
};
_onViewableItemsChanged = (
{viewableItems, changed}: {viewableItems: Array<ViewToken>, changed: Array<ViewToken>}
) => {
if (this.props.onViewableItemsChanged) {
this.props.onViewableItemsChanged({
viewableItems: viewableItems.map(this._convertViewable, this).filter(Boolean),
changed: changed.map(this._convertViewable, this).filter(Boolean),
});
}
}
_isItemSticky = (item, index) => {
const info = this._subExtractor(index);
return info && info.index == null;
};
_renderItem = ({item, index}: {item: Item, index: number}) => {
const info = this._subExtractor(index);
if (!info) {
return null;
} else if (info.index == null) {
const {renderSectionHeader} = this.props;
return renderSectionHeader ? renderSectionHeader({section: info.section}) : null;
} else {
const renderItem = info.section.renderItem ||
this.props.renderItem;
const SeparatorComponent = this._getSeparatorComponent(index, info);
invariant(renderItem, 'no renderItem!');
return (
<View>
{renderItem({item, index: info.index || 0})}
{SeparatorComponent && <SeparatorComponent />}
</View>
);
}
};
_getSeparatorComponent(index: number, info?: ?Object): ?ReactClass<*> {
info = info || this._subExtractor(index);
if (!info) {
return null;
}
const SeparatorComponent = info.section.SeparatorComponent || this.props.ItemSeparatorComponent;
const {SectionSeparatorComponent} = this.props;
const isLastItemInList = index === this.state.childProps.getItemCount() - 1;
const isLastItemInSection = info.index === info.section.data.length - 1;
if (SectionSeparatorComponent && isLastItemInSection && !isLastItemInList) {
return SectionSeparatorComponent;
}
if (SeparatorComponent && !isLastItemInSection && !isLastItemInList) {
return SeparatorComponent;
}
return null;
}
_shouldItemUpdate = (prev, next) => {
const {shouldItemUpdate} = this.props;
if (!shouldItemUpdate || shouldItemUpdate(prev, next)) {
return true;
}
return this._getSeparatorComponent(prev.index) !== this._getSeparatorComponent(next.index);
}
_computeState(props: Props<SectionT>): State {
const itemCount = props.sections.reduce((v, section) => v + section.data.length + 1, 0);
return {
childProps: {
...props,
FooterComponent: this.props.ListFooterComponent,
HeaderComponent: this.props.ListHeaderComponent,
renderItem: this._renderItem,
SeparatorComponent: undefined, // Rendered with renderItem
data: props.sections,
getItemCount: () => itemCount,
getItem,
isItemSticky: this._isItemSticky,
keyExtractor: this._keyExtractor,
onViewableItemsChanged:
props.onViewableItemsChanged ? this._onViewableItemsChanged : undefined,
shouldItemUpdate: this._shouldItemUpdate,
},
};
}
constructor(props: Props<SectionT>, context: Object) {
super(props, context);
warning(
!props.stickySectionHeadersEnabled,
'VirtualizedSectionList: Sticky headers only supported with legacyImplementation for now.'
);
this.state = this._computeState(props);
}
componentWillReceiveProps(nextProps: Props<SectionT>) {
this.setState(this._computeState(nextProps));
}
render() {
return <VirtualizedList {...this.state.childProps} />;
}
}
function getItem(sections: ?Array<Item>, index: number): ?Item {
if (!sections) {
return null;
}
let itemIdx = index - 1;
for (let ii = 0; ii < sections.length; ii++) {
if (itemIdx === -1) {
return sections[ii]; // The section itself is the item
} else if (itemIdx < sections[ii].data.length) {
return sections[ii].data[itemIdx];
} else {
itemIdx -= (sections[ii].data.length + 1);
}
}
return null;
}
module.exports = VirtualizedSectionList;

View File

@@ -0,0 +1,90 @@
/**
* 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.
*
* @flow
*/
'use strict';
const FlatList = require('FlatList');
const React = require('react');
function renderMyListItem(info: {item: {title: string}, index: number}) {
return <span />;
}
module.exports = {
testEverythingIsFine() {
const data = [{
title: 'Title Text',
key: 1,
}];
return <FlatList renderItem={renderMyListItem} data={data} />;
},
testBadDataWithTypicalItem() {
// $FlowExpectedError - bad title type 6, should be string
const data = [{
title: 6,
key: 1,
}];
return <FlatList renderItem={renderMyListItem} data={data} />;
},
testMissingFieldWithTypicalItem() {
const data = [{
key: 1,
}];
// $FlowExpectedError - missing title
return <FlatList renderItem={renderMyListItem} data={data} />;
},
testGoodDataWithBadCustomRenderItemFunction() {
const data = [{
widget: 6,
key: 1,
}];
return (
<FlatList
renderItem={(info) =>
// $FlowExpectedError - bad widgetCount type 6, should be Object
<span>{info.item.widget.missingProp}</span>
}
data={data}
/>
);
},
testBadRenderItemFunction() {
const data = [{
title: 'foo',
key: 1,
}];
return [
// $FlowExpectedError - title should be inside `item`
<FlatList renderItem={(info: {title: string}) => <span /> } data={data} />,
// $FlowExpectedError - bad index type string, should be number
<FlatList renderItem={(info: {item: any, index: string}) => <span /> } data={data} />,
// $FlowExpectedError - bad title type number, should be string
<FlatList renderItem={(info: {item: {title: number}}) => <span /> } data={data} />,
// EverythingIsFine
<FlatList renderItem={(info: {item: {title: string}}) => <span /> } data={data} />,
];
},
testOtherBadProps() {
return [
// $FlowExpectedError - bad numColumns type "lots"
<FlatList renderItem={renderMyListItem} data={[]} numColumns="lots" />,
// $FlowExpectedError - bad windowSize type "big"
<FlatList renderItem={renderMyListItem} data={[]} windowSize="big" />,
// $FlowExpectedError - missing `data` prop
<FlatList renderItem={renderMyListItem} />,
];
},
};

View File

@@ -0,0 +1,89 @@
/**
* 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.
*
* @flow
*/
'use strict';
const React = require('react');
const SectionList = require('SectionList');
function renderMyListItem(info: {item: {title: string}, index: number}) {
return <span />;
}
const renderMyHeader = ({section}: {section: {fooNumber: number} & Object}) => <span />;
module.exports = {
testGoodDataWithGoodItem() {
const sections = [{
key: 'a', data: [{
title: 'foo',
key: 1,
}],
}];
return <SectionList renderItem={renderMyListItem} sections={sections} />;
},
testBadRenderItemFunction() {
const sections = [{
key: 'a', data: [{
title: 'foo',
key: 1,
}],
}];
return [
// $FlowExpectedError - title should be inside `item`
<SectionList renderItem={(info: {title: string}) => <span /> } sections={sections} />,
// $FlowExpectedError - bad index type string, should be number
<SectionList renderItem={(info: {index: string}) => <span /> } sections={sections} />,
// EverythingIsFine
<SectionList renderItem={(info: {item: {title: string}}) => <span /> } sections={sections} />,
];
},
testBadInheritedDefaultProp(): React.Element<*> {
const sections = [];
// $FlowExpectedError - bad windowSize type "big"
return <SectionList renderItem={renderMyListItem} sections={sections} windowSize="big" />;
},
testMissingData(): React.Element<*> {
// $FlowExpectedError - missing `sections` prop
return <SectionList renderItem={renderMyListItem} />;
},
testBadSectionsShape(): React.Element<*> {
const sections = [{
key: 'a', items: [{
title: 'foo',
key: 1,
}],
}];
// $FlowExpectedError - section missing `data` field
return <SectionList renderItem={renderMyListItem} sections={sections} />;
},
testBadSectionsMetadata(): React.Element<*> {
// $FlowExpectedError - section has bad meta data `fooNumber` field of type string
const sections = [{
key: 'a', fooNumber: 'string', data: [{
title: 'foo',
key: 1,
}],
}];
return (
<SectionList
renderSectionHeader={renderMyHeader}
renderItem={renderMyListItem}
sections={sections}
/>
);
},
};

View File

@@ -0,0 +1,367 @@
/**
* 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};
}
function createViewToken(index: number, isViewable: boolean) {
return {key: data[index].key, isViewable};
}
describe('computeViewableItems', function() {
it('returns all 4 entirely visible rows as viewable', function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
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(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([0, 1, 2, 3]);
});
it(
'returns top 2 rows as viewable (1. entirely visible and 2. majority)',
function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
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(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([0, 1]);
});
it(
'returns only 2nd row as viewable (majority)',
function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
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(helper.computeViewableItems(data.length, 25, 200, getFrameMetrics))
.toEqual([1]);
});
it(
'handles empty input',
function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
rowFrames = {};
data = [];
expect(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([]);
});
it(
'handles different view area coverage percent thresholds',
function() {
rowFrames = {
a: {y: 0, height: 50},
b: {y: 50, height: 150},
c: {y: 200, height: 500},
d: {y: 700, height: 50},
};
data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
let helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 0});
expect(helper.computeViewableItems(data.length, 0, 50, getFrameMetrics))
.toEqual([0]);
expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics))
.toEqual([0, 1]);
expect(helper.computeViewableItems(data.length, 199, 50, getFrameMetrics))
.toEqual([1, 2]);
expect(helper.computeViewableItems(data.length, 250, 50, getFrameMetrics))
.toEqual([2]);
helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 100});
expect(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([0, 1]);
expect(helper.computeViewableItems(data.length, 1, 200, getFrameMetrics))
.toEqual([1]);
expect(helper.computeViewableItems(data.length, 400, 200, getFrameMetrics))
.toEqual([2]);
expect(helper.computeViewableItems(data.length, 600, 200, getFrameMetrics))
.toEqual([3]);
helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 10});
expect(helper.computeViewableItems(data.length, 30, 200, getFrameMetrics))
.toEqual([0, 1, 2]);
expect(helper.computeViewableItems(data.length, 31, 200, getFrameMetrics))
.toEqual([1, 2]);
});
it(
'handles different item visible percent thresholds',
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'}];
let helper = new ViewabilityHelper({itemVisiblePercentThreshold: 0});
expect(helper.computeViewableItems(data.length, 0, 50, getFrameMetrics))
.toEqual([0]);
expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics))
.toEqual([0, 1]);
helper = new ViewabilityHelper({itemVisiblePercentThreshold: 100});
expect(helper.computeViewableItems(data.length, 0, 250, getFrameMetrics))
.toEqual([0, 1, 2]);
expect(helper.computeViewableItems(data.length, 1, 250, getFrameMetrics))
.toEqual([1, 2]);
helper = new ViewabilityHelper({itemVisiblePercentThreshold: 10});
expect(helper.computeViewableItems(data.length, 184, 20, getFrameMetrics))
.toEqual([1]);
expect(helper.computeViewableItems(data.length, 185, 20, getFrameMetrics))
.toEqual([1, 2]);
expect(helper.computeViewableItems(data.length, 186, 20, getFrameMetrics))
.toEqual([2]);
});
});
describe('onUpdate', function() {
it(
'returns 1 visible row as viewable then scrolls away',
function() {
const helper = new ViewabilityHelper();
rowFrames = {
a: {y: 0, height: 50},
};
data = [{key: 'a'}];
const onViewableItemsChanged = jest.fn();
helper.onUpdate(
data.length,
0,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
changed: [{isViewable: true, key: 'a'}],
viewableItems: [{isViewable: true, key: 'a'}],
});
helper.onUpdate(
data.length,
0,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(1); // nothing changed!
helper.onUpdate(
data.length,
100,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(2);
expect(onViewableItemsChanged.mock.calls[1][0]).toEqual({
changed: [{isViewable: false, key: 'a'}],
viewableItems: [],
});
},
);
it(
'returns 1st visible row then 1st and 2nd then just 2nd',
function() {
const helper = new ViewabilityHelper();
rowFrames = {
a: {y: 0, height: 200},
b: {y: 200, height: 200},
};
data = [{key: 'a'}, {key: 'b'}];
const onViewableItemsChanged = jest.fn();
helper.onUpdate(
data.length,
0,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
changed: [{isViewable: true, key: 'a'}],
viewableItems: [{isViewable: true, key: 'a'}],
});
helper.onUpdate(
data.length,
100,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(2);
// Both visible with 100px overlap each
expect(onViewableItemsChanged.mock.calls[1][0]).toEqual({
changed: [{isViewable: true, key: 'b'}],
viewableItems: [{isViewable: true, key: 'a'}, {isViewable: true, key: 'b'}],
});
helper.onUpdate(
data.length,
200,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(3);
expect(onViewableItemsChanged.mock.calls[2][0]).toEqual({
changed: [{isViewable: false, key: 'a'}],
viewableItems: [{isViewable: true, key: 'b'}],
});
},
);
it(
'minViewTime delays callback',
function() {
const helper = new ViewabilityHelper({minViewTime: 350, viewAreaCoveragePercentThreshold: 0});
rowFrames = {
a: {y: 0, height: 200},
b: {y: 200, height: 200},
};
data = [{key: 'a'}, {key: 'b'}];
const onViewableItemsChanged = jest.fn();
helper.onUpdate(
data.length,
0,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged).not.toBeCalled();
jest.runAllTimers();
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
changed: [{isViewable: true, key: 'a'}],
viewableItems: [{isViewable: true, key: 'a'}],
});
}
);
it(
'minViewTime skips briefly visible items',
function() {
const helper = new ViewabilityHelper({minViewTime: 350, viewAreaCoveragePercentThreshold: 0});
rowFrames = {
a: {y: 0, height: 250},
b: {y: 250, height: 200},
};
data = [{key: 'a'}, {key: 'b'}];
const onViewableItemsChanged = jest.fn();
helper.onUpdate(
data.length,
0,
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
helper.onUpdate(
data.length,
300, // scroll past item 'a'
200,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
jest.runAllTimers();
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
changed: [{isViewable: true, key: 'b'}],
viewableItems: [{isViewable: true, key: 'b'}],
});
}
);
it(
'waitForInteraction blocks callback until scroll',
function() {
const helper = new ViewabilityHelper({
waitForInteraction: true,
viewAreaCoveragePercentThreshold: 0,
scrollInteractionFilter: {
minimumOffset: 20,
},
});
rowFrames = {
a: {y: 0, height: 200},
b: {y: 200, height: 200},
};
data = [{key: 'a'}, {key: 'b'}];
const onViewableItemsChanged = jest.fn();
helper.onUpdate(
data.length,
0,
100,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged).not.toBeCalled();
helper.onUpdate(
data.length,
10, // not far enough to meet minimumOffset
100,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged).not.toBeCalled();
helper.onUpdate(
data.length,
20,
100,
getFrameMetrics,
createViewToken,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
changed: [{isViewable: true, key: 'a'}],
viewableItems: [{isViewable: true, key: 'a'}],
});
}
);
});

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