diff --git a/Examples/UIExplorer/js/SectionListExample.js b/Examples/UIExplorer/js/SectionListExample.js
index be919d38b..20d0368fc 100644
--- a/Examples/UIExplorer/js/SectionListExample.js
+++ b/Examples/UIExplorer/js/SectionListExample.js
@@ -65,7 +65,14 @@ const renderSectionHeader = ({section}) => (
);
-const CustomSeparatorComponent = ({text, highlighted}) => (
+const renderSectionFooter = ({section}) => (
+
+ SECTION FOOTER: {section.key}
+
+
+);
+
+const CustomSeparatorComponent = ({highlighted, text}) => (
{text}
@@ -128,11 +135,11 @@ class SectionListExample extends React.PureComponent {
-
+ SectionSeparatorComponent={(info) =>
+
}
- ItemSeparatorComponent={({highlighted}) =>
-
+ ItemSeparatorComponent={(info) =>
+
}
debug={this.state.debug}
enableVirtualization={this.state.virtualized}
@@ -142,15 +149,23 @@ class SectionListExample extends React.PureComponent {
refreshing={false}
renderItem={this._renderItemComponent}
renderSectionHeader={renderSectionHeader}
+ renderSectionFooter={renderSectionFooter}
stickySectionHeadersEnabled
sections={[
- {renderItem: renderStackedItem, key: 's1', data: [
- {title: 'Item In Header Section', text: 'Section s1', key: 'header item'},
- ]},
- {key: 's2', data: [
- {noImage: true, title: '1st item', text: 'Section s2', key: 'noimage0'},
- {noImage: true, title: '2nd item', text: 'Section s2', key: 'noimage1'},
- ]},
+ {
+ renderItem: renderStackedItem,
+ key: 's1',
+ data: [
+ {title: 'Item In Header Section', text: 'Section s1', key: 'header item'},
+ ],
+ },
+ {
+ key: 's2',
+ data: [
+ {noImage: true, title: '1st item', text: 'Section s2', key: 'noimage0'},
+ {noImage: true, title: '2nd item', text: 'Section s2', key: 'noimage1'},
+ ],
+ },
...filteredSectionData,
]}
style={styles.list}
diff --git a/Libraries/Lists/FillRateHelper.js b/Libraries/Lists/FillRateHelper.js
index 11838a8d5..bbb1ad699 100644
--- a/Libraries/Lists/FillRateHelper.js
+++ b/Libraries/Lists/FillRateHelper.js
@@ -10,38 +10,35 @@
* @flow
*/
+/* eslint-disable no-console-disallow */
+
'use strict';
const performanceNow = require('fbjs/lib/performanceNow');
const warning = require('fbjs/lib/warning');
-export type FillRateExceededInfo = {
- event: {
- sample_type: string,
- blankness: number,
- blank_pixels_top: number,
- blank_pixels_bottom: number,
- scroll_offset: number,
- visible_length: number,
- scroll_speed: number,
- first_frame: Object,
- last_frame: Object,
- },
- aggregate: {
- avg_blankness: number,
- min_speed_when_blank: number,
- avg_speed_when_blank: number,
- avg_blankness_when_any_blank: number,
- fraction_any_blank: number,
- all_samples_timespan_sec: number,
- fill_rate_sample_counts: {[key: string]: number},
- },
-};
+export type FillRateInfo = Info;
+
+class Info {
+ any_blank_count = 0;
+ any_blank_ms = 0;
+ any_blank_speed_sum = 0;
+ mostly_blank_count = 0;
+ mostly_blank_ms = 0;
+ pixels_blank = 0;
+ pixels_sampled = 0;
+ pixels_scrolled = 0;
+ total_time_spent = 0;
+ sample_count = 0;
+}
type FrameMetrics = {inLayout?: boolean, length: number, offset: number};
-let _listeners: Array<(FillRateExceededInfo) => void> = [];
-let _sampleRate = null;
+const DEBUG = false;
+
+let _listeners: Array<(Info) => void> = [];
+let _minSampleCount = 10;
+let _sampleRate = DEBUG ? 1 : null;
/**
* A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded.
@@ -52,20 +49,19 @@ let _sampleRate = null;
* `SceneTracker.getActiveScene` to determine the context of the events.
*/
class FillRateHelper {
+ _anyBlankStartTime = (null: ?number);
+ _enabled = false;
_getFrameMetrics: (index: number) => ?FrameMetrics;
- _anyBlankCount = 0;
- _anyBlankMinSpeed = Number.MAX_SAFE_INTEGER;
- _anyBlankSpeedSum = 0;
- _sampleCounts = {};
- _fractionBlankSum = 0;
- _samplesStartTime = 0;
+ _info = new Info();
+ _mostlyBlankStartTime = (null: ?number);
+ _samplesStartTime = (null: ?number);
- static addFillRateExceededListener(
- callback: (FillRateExceededInfo) => void
+ static addListener(
+ callback: (FillRateInfo) => void
): {remove: () => void} {
warning(
_sampleRate !== null,
- 'Call `FillRateHelper.setSampleRate` before `addFillRateExceededListener`.'
+ 'Call `FillRateHelper.setSampleRate` before `addListener`.'
);
_listeners.push(callback);
return {
@@ -79,16 +75,62 @@ class FillRateHelper {
_sampleRate = sampleRate;
}
- static enabled(): boolean {
- return (_sampleRate || 0) > 0.0;
+ static setMinSampleCount(minSampleCount: number) {
+ _minSampleCount = minSampleCount;
}
constructor(getFrameMetrics: (index: number) => ?FrameMetrics) {
this._getFrameMetrics = getFrameMetrics;
+ this._enabled = (_sampleRate || 0) > Math.random();
+ this._resetData();
}
- computeInfoSampled(
- sampleType: string,
+ activate() {
+ if (this._enabled && this._samplesStartTime == null) {
+ DEBUG && console.debug('FillRateHelper: activate');
+ this._samplesStartTime = performanceNow();
+ }
+ }
+
+ deactivateAndFlush() {
+ if (!this._enabled) {
+ return;
+ }
+ const start = this._samplesStartTime; // const for flow
+ if (start == null) {
+ DEBUG && console.debug('FillRateHelper: bail on deactivate with no start time');
+ return;
+ }
+ if (this._info.sample_count < _minSampleCount) {
+ // Don't bother with under-sampled events.
+ this._resetData();
+ return;
+ }
+ const total_time_spent = performanceNow() - start;
+ const info: any = {
+ ...this._info,
+ total_time_spent,
+ };
+ if (DEBUG) {
+ const derived = {
+ avg_blankness: this._info.pixels_blank / this._info.pixels_sampled,
+ avg_speed: this._info.pixels_scrolled / (total_time_spent / 1000),
+ avg_speed_when_any_blank: this._info.any_blank_speed_sum / this._info.any_blank_count,
+ any_blank_per_min: this._info.any_blank_count / (total_time_spent / 1000 / 60),
+ any_blank_time_frac: this._info.any_blank_ms / total_time_spent,
+ mostly_blank_per_min: this._info.mostly_blank_count / (total_time_spent / 1000 / 60),
+ mostly_blank_time_frac: this._info.mostly_blank_ms / total_time_spent,
+ };
+ for (const key in derived) {
+ derived[key] = Math.round(1000 * derived[key]) / 1000;
+ }
+ console.debug('FillRateHelper deactivateAndFlush: ', {derived, info});
+ }
+ _listeners.forEach((listener) => listener(info));
+ this._resetData();
+ }
+
+ computeBlankness(
props: {
data: Array,
getItemCount: (data: Array) => number,
@@ -99,22 +141,35 @@ class FillRateHelper {
last: number,
},
scrollMetrics: {
+ dOffset: number,
offset: number,
velocity: number,
visibleLength: number,
},
- ): ?FillRateExceededInfo {
- if (!FillRateHelper.enabled() || (_sampleRate || 0) <= Math.random()) {
- return null;
+ ): number {
+ if (!this._enabled || props.getItemCount(props.data) === 0 || this._samplesStartTime == null) {
+ return 0;
}
- const start = performanceNow();
- if (props.getItemCount(props.data) === 0) {
- return null;
+ const {dOffset, offset, velocity, visibleLength} = scrollMetrics;
+
+ // Denominator metrics that we track for all events - most of the time there is no blankness and
+ // we want to capture that.
+ this._info.sample_count++;
+ this._info.pixels_sampled += Math.round(visibleLength);
+ this._info.pixels_scrolled += Math.round(Math.abs(dOffset));
+ const scrollSpeed = Math.round(Math.abs(velocity) * 1000); // px / sec
+
+ // Whether blank now or not, record the elapsed time blank if we were blank last time.
+ const now = performanceNow();
+ if (this._anyBlankStartTime != null) {
+ this._info.any_blank_ms += now - this._anyBlankStartTime;
}
- if (!this._samplesStartTime) {
- this._samplesStartTime = start;
+ this._anyBlankStartTime = null;
+ if (this._mostlyBlankStartTime != null) {
+ this._info.mostly_blank_ms += now - this._mostlyBlankStartTime;
}
- const {offset, velocity, visibleLength} = scrollMetrics;
+ this._mostlyBlankStartTime = null;
+
let blankTop = 0;
let first = state.first;
let firstFrame = this._getFrameMetrics(first);
@@ -122,7 +177,9 @@ class FillRateHelper {
firstFrame = this._getFrameMetrics(first);
first++;
}
- if (firstFrame) {
+ // Only count blankTop if we aren't rendering the first item, otherwise we will count the header
+ // as blank.
+ if (firstFrame && first > 0) {
blankTop = Math.min(visibleLength, Math.max(0, firstFrame.offset - offset));
}
let blankBottom = 0;
@@ -132,47 +189,38 @@ class FillRateHelper {
lastFrame = this._getFrameMetrics(last);
last--;
}
- if (lastFrame) {
+ // Only count blankBottom if we aren't rendering the last item, otherwise we will count the
+ // footer as blank.
+ if (lastFrame && last < props.getItemCount(props.data) - 1) {
const bottomEdge = lastFrame.offset + lastFrame.length;
blankBottom = Math.min(visibleLength, Math.max(0, offset + visibleLength - bottomEdge));
}
- this._sampleCounts.all = (this._sampleCounts.all || 0) + 1;
- this._sampleCounts[sampleType] = (this._sampleCounts[sampleType] || 0) + 1;
- const blankness = (blankTop + blankBottom) / visibleLength;
+ const pixels_blank = Math.round(blankTop + blankBottom);
+ const blankness = pixels_blank / visibleLength;
if (blankness > 0) {
- const scrollSpeed = Math.abs(velocity);
- if (scrollSpeed && sampleType === 'onScroll') {
- this._anyBlankMinSpeed = Math.min(this._anyBlankMinSpeed, scrollSpeed);
+ this._anyBlankStartTime = now;
+ this._info.any_blank_speed_sum += scrollSpeed;
+ this._info.any_blank_count++;
+ this._info.pixels_blank += pixels_blank;
+ if (blankness > 0.5) {
+ this._mostlyBlankStartTime = now;
+ this._info.mostly_blank_count++;
}
- this._anyBlankSpeedSum += scrollSpeed;
- this._anyBlankCount++;
- this._fractionBlankSum += blankness;
- const event = {
- sample_type: sampleType,
- blankness: blankness,
- blank_pixels_top: blankTop,
- blank_pixels_bottom: blankBottom,
- scroll_offset: offset,
- visible_length: visibleLength,
- scroll_speed: scrollSpeed,
- first_frame: {...firstFrame},
- last_frame: {...lastFrame},
- };
- const aggregate = {
- avg_blankness: this._fractionBlankSum / this._sampleCounts.all,
- min_speed_when_blank: this._anyBlankMinSpeed,
- avg_speed_when_blank: this._anyBlankSpeedSum / this._anyBlankCount,
- avg_blankness_when_any_blank: this._fractionBlankSum / this._anyBlankCount,
- fraction_any_blank: this._anyBlankCount / this._sampleCounts.all,
- all_samples_timespan_sec: (performanceNow() - this._samplesStartTime) / 1000.0,
- fill_rate_sample_counts: {...this._sampleCounts},
- compute_time: performanceNow() - start,
- };
- const info = {event, aggregate};
- _listeners.forEach((listener) => listener(info));
- return info;
+ } else if (scrollSpeed < 0.01 || Math.abs(dOffset) < 1) {
+ this.deactivateAndFlush();
}
- return null;
+ return blankness;
+ }
+
+ enabled(): boolean {
+ return this._enabled;
+ }
+
+ _resetData() {
+ this._anyBlankStartTime = null;
+ this._info = new Info();
+ this._mostlyBlankStartTime = null;
+ this._samplesStartTime = null;
}
}
diff --git a/Libraries/Lists/FlatList.js b/Libraries/Lists/FlatList.js
index ca00b5621..3ca5a75f8 100644
--- a/Libraries/Lists/FlatList.js
+++ b/Libraries/Lists/FlatList.js
@@ -116,6 +116,13 @@ type OptionalProps = {
* to improve perceived performance of scroll-to-top actions.
*/
initialNumToRender: number,
+ /**
+ * Instead of starting at the top with the first item, start at `initialScrollIndex`. This
+ * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items
+ * always rendered and immediately renders the items starting at this initial index. Requires
+ * `getItemLayout` to be implemented.
+ */
+ initialScrollIndex?: ?number,
/**
* 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
diff --git a/Libraries/Lists/SectionList.js b/Libraries/Lists/SectionList.js
index 24e400481..9222903e7 100644
--- a/Libraries/Lists/SectionList.js
+++ b/Libraries/Lists/SectionList.js
@@ -30,6 +30,7 @@ type SectionBase = {
renderItem?: ?(info: {
item: SectionItemT,
index: number,
+ section: SectionBase,
separators: {
highlight: () => void,
unhighlight: () => void,
@@ -66,6 +67,7 @@ type OptionalProps> = {
renderItem: (info: {
item: Item,
index: number,
+ section: SectionT,
separators: {
highlight: () => void,
unhighlight: () => void,
@@ -73,10 +75,10 @@ type OptionalProps> = {
},
}) => ?React.Element,
/**
- * Rendered in between each item, but not at the top or bottom. By default, `highlighted` and
- * `leadingItem` props are provided. `renderItem` provides `separators.highlight`/`unhighlight`
- * which will update the `highlighted` prop, but you can also add custom props with
- * `separators.updateProps`.
+ * Rendered in between each item, but not at the top or bottom. By default, `highlighted`,
+ * `section`, and `[leading/trailing][Item/Separator]` props are provided. `renderItem` provides
+ * `separators.highlight`/`unhighlight` which will update the `highlighted` prop, but you can also
+ * add custom props with `separators.updateProps`.
*/
ItemSeparatorComponent?: ?ReactClass,
/**
@@ -88,8 +90,11 @@ type OptionalProps> = {
*/
ListFooterComponent?: ?(ReactClass | React.Element),
/**
- * Rendered in between each section. Also receives `highlighted`, `leadingItem`, and any custom
- * props from `separators.updateProps`.
+ * Rendered at the top and bottom of each section (note this is different from
+ * `ItemSeparatorComponent` which is only rendered between items). These are intended to separate
+ * sections from the headers above and below and typically have the same highlight response as
+ * `ItemSeparatorComponent`. Also receives `highlighted`, `[leading/trailing][Item/Separator]`,
+ * and any custom props from `separators.updateProps`.
*/
SectionSeparatorComponent?: ?ReactClass,
/**
@@ -151,6 +156,10 @@ type OptionalProps> = {
* iOS. See `stickySectionHeadersEnabled`.
*/
renderSectionHeader?: ?(info: {section: SectionT}) => ?React.Element,
+ /**
+ * Rendered at the bottom of each section.
+ */
+ renderSectionFooter?: ?(info: {section: SectionT}) => ?React.Element,
/**
* Makes section headers stick to the top of the screen until the next one pushes it off. Only
* enabled by default on iOS because that is the platform standard there.
diff --git a/Libraries/Lists/VirtualizeUtils.js b/Libraries/Lists/VirtualizeUtils.js
index 9342d41ba..6e5cd810a 100644
--- a/Libraries/Lists/VirtualizeUtils.js
+++ b/Libraries/Lists/VirtualizeUtils.js
@@ -90,7 +90,12 @@ function computeWindowedRenderLimits(
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));
+
+ // Considering velocity seems to introduce more churn than it's worth.
+ const leadFactor = 0.5; // Math.max(0, Math.min(1, velocity / 25 + 0.5));
+
+ const fillPreference = velocity > 1 ? 'after' : (velocity < -1 ? 'before' : 'none');
+
const overscanBegin = Math.max(0, visibleBegin - (1 - leadFactor) * overscanLength);
const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength);
@@ -129,13 +134,15 @@ function computeWindowedRenderLimits(
// possible.
break;
}
- if (firstShouldIncrement) {
+ if (firstShouldIncrement &&
+ !(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore)) {
if (firstWillAddMore) {
newCellCount++;
}
first--;
}
- if (lastShouldIncrement) {
+ if (lastShouldIncrement &&
+ !(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore)) {
if (lastWillAddMore) {
newCellCount++;
}
diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js
index 111d9e2bb..08e0c642d 100644
--- a/Libraries/Lists/VirtualizedList.js
+++ b/Libraries/Lists/VirtualizedList.js
@@ -29,15 +29,7 @@ import type {ViewabilityConfig, ViewToken} from 'ViewabilityHelper';
type Item = any;
-type renderItemType = (info: {
- item: Item,
- index: number,
- separators: {
- highlight: () => void,
- unhighlight: () => void,
- updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
- },
-}) => ?React.Element;
+type renderItemType = (info: any) => ?React.Element;
type RequiredProps = {
renderItem: renderItemType,
@@ -82,6 +74,13 @@ type OptionalProps = {
* to improve perceived performance of scroll-to-top actions.
*/
initialNumToRender: number,
+ /**
+ * Instead of starting at the top with the first item, start at `initialScrollIndex`. This
+ * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items
+ * always rendered and immediately renders the items starting at this initial index. Requires
+ * `getItemLayout` to be implemented.
+ */
+ initialScrollIndex?: ?number,
keyExtractor: (item: Item, index: number) => string,
/**
* The maximum number of items to render in each incremental render batch. The more rendered at
@@ -312,15 +311,29 @@ class VirtualizedList extends React.PureComponent {
);
this._viewabilityHelper = new ViewabilityHelper(this.props.viewabilityConfig);
this.state = {
- first: 0,
- last: Math.min(this.props.getItemCount(this.props.data), this.props.initialNumToRender) - 1,
+ first: this.props.initialScrollIndex || 0,
+ last: Math.min(
+ this.props.getItemCount(this.props.data),
+ (this.props.initialScrollIndex || 0) + this.props.initialNumToRender,
+ ) - 1,
};
}
+ componentDidMount() {
+ if (this.props.initialScrollIndex) {
+ this._initialScrollIndexTimeout = setTimeout(
+ () => this.scrollToIndex({animated: false, index: this.props.initialScrollIndex}),
+ 0,
+ );
+ }
+ }
+
componentWillUnmount() {
this._updateViewableItems(null);
this._updateCellsToRenderBatcher.dispose();
this._viewabilityHelper.dispose();
+ this._fillRateHelper.deactivateAndFlush();
+ clearTimeout(this._initialScrollIndexTimeout);
}
componentWillReceiveProps(newProps: Props) {
@@ -358,8 +371,9 @@ class VirtualizedList extends React.PureComponent {
}
cells.push(
{
if (itemCount > 0) {
_usedIndexForKey = false;
const spacerKey = !horizontal ? 'height' : 'width';
- const lastInitialIndex = this.props.initialNumToRender - 1;
+ const lastInitialIndex = this.props.initialScrollIndex
+ ? -1
+ : this.props.initialNumToRender - 1;
const {first, last} = this.state;
this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, 0, lastInitialIndex);
const firstAfterInitial = Math.max(lastInitialIndex + 1, first);
@@ -481,6 +497,8 @@ class VirtualizedList extends React.PureComponent {
onLayout: this._onLayout,
onScroll: this._onScroll,
onScrollBeginDrag: this._onScrollBeginDrag,
+ onScrollEndDrag: this._onScrollEndDrag,
+ onMomentumScrollEnd: this._onMomentumScrollEnd,
ref: this._captureScrollRef,
scrollEventThrottle: this.props.scrollEventThrottle, // TODO: Android support
stickyHeaderIndices,
@@ -504,11 +522,12 @@ class VirtualizedList extends React.PureComponent {
_hasWarned = {};
_highestMeasuredFrameIndex = 0;
_headerLength = 0;
+ _initialScrollIndexTimeout = 0;
_fillRateHelper: FillRateHelper;
_frames = {};
_footerLength = 0;
_scrollMetrics = {
- visibleLength: 0, contentLength: 0, offset: 0, dt: 10, velocity: 0, timestamp: 0,
+ contentLength: 0, dOffset: 0, dt: 10, offset: 0, timestamp: 0, velocity: 0, visibleLength: 0,
};
_scrollRef = (null: any);
_sentEndForContentLength = 0;
@@ -521,6 +540,14 @@ class VirtualizedList extends React.PureComponent {
this._scrollRef = ref;
};
+ _computeBlankness() {
+ this._fillRateHelper.computeBlankness(
+ this.props,
+ this.state,
+ this._scrollMetrics,
+ );
+ }
+
_onCellLayout(e, cellKey, index) {
const layout = e.nativeEvent.layout;
const next = {
@@ -544,7 +571,7 @@ class VirtualizedList extends React.PureComponent {
} else {
this._frames[cellKey].inLayout = true;
}
- this._sampleFillRate('onCellLayout');
+ this._computeBlankness();
}
_onCellUnmount = (cellKey: string) => {
@@ -648,15 +675,6 @@ class VirtualizedList extends React.PureComponent {
this._maybeCallOnEndReached();
};
- _sampleFillRate(sampleType: string) {
- this._fillRateHelper.computeInfoSampled(
- sampleType,
- this.props,
- this.state,
- this._scrollMetrics,
- );
- }
-
_onScroll = (e: Object) => {
if (this.props.onScroll) {
this.props.onScroll(e);
@@ -678,11 +696,9 @@ class VirtualizedList extends React.PureComponent {
}
const dOffset = offset - this._scrollMetrics.offset;
const velocity = dOffset / dt;
- this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength};
+ this._scrollMetrics = {contentLength, dt, dOffset, offset, timestamp, velocity, visibleLength};
const {data, getItemCount, windowSize} = this.props;
- this._sampleFillRate('onScroll');
-
this._updateViewableItems(data);
if (!data) {
return;
@@ -690,6 +706,10 @@ class VirtualizedList extends React.PureComponent {
this._maybeCallOnEndReached();
const {first, last} = this.state;
+ if (velocity !== 0) {
+ this._fillRateHelper.activate();
+ }
+ this._computeBlankness();
const itemCount = getItemCount(data);
if ((first > 0 && velocity < 0) || (last < itemCount - 1 && velocity > 0)) {
const distanceToContentEdge = Math.min(
@@ -713,6 +733,21 @@ class VirtualizedList extends React.PureComponent {
this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e);
};
+ _onScrollEndDrag = (e): void => {
+ const {velocity} = e.nativeEvent;
+ if (velocity) {
+ this._scrollMetrics.velocity = this._selectOffset(velocity);
+ }
+ this._computeBlankness();
+ this.props.onScrollEndDrag && this.props.onScrollEndDrag(e);
+ };
+
+ _onMomentumScrollEnd = (e): void => {
+ this._scrollMetrics.velocity = 0;
+ this._computeBlankness();
+ this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e);
+ };
+
_updateCellsToRender = () => {
const {data, disableVirtualization, getItemCount, onEndReachedThreshold} = this.props;
this._updateViewableItems(data);
@@ -799,6 +834,7 @@ class CellRenderer extends React.Component {
props: {
ItemSeparatorComponent: ?ReactClass<*>,
cellKey: string,
+ fillRateHelper: FillRateHelper,
index: number,
item: Item,
onLayout: (event: Object) => void, // This is extracted by ScrollViewStickyHeader
@@ -844,7 +880,7 @@ class CellRenderer extends React.Component {
}
render() {
- const {ItemSeparatorComponent, item, index, parentProps} = this.props;
+ const {ItemSeparatorComponent, fillRateHelper, item, index, parentProps} = this.props;
const {renderItem, getItemLayout} = parentProps;
invariant(renderItem, 'no renderItem!');
const element = renderItem({
@@ -852,7 +888,7 @@ class CellRenderer extends React.Component {
index,
separators: this._separators,
});
- const onLayout = (getItemLayout && !parentProps.debug && !FillRateHelper.enabled())
+ const onLayout = (getItemLayout && !parentProps.debug && !fillRateHelper.enabled())
? undefined
: this.props.onLayout;
// NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and
diff --git a/Libraries/Lists/VirtualizedSectionList.js b/Libraries/Lists/VirtualizedSectionList.js
index 52ef51eed..087881782 100644
--- a/Libraries/Lists/VirtualizedSectionList.js
+++ b/Libraries/Lists/VirtualizedSectionList.js
@@ -33,6 +33,7 @@ type SectionBase = {
renderItem?: ?({
item: SectionItem,
index: number,
+ section: SectionBase,
separators: {
highlight: () => void,
unhighlight: () => void,
@@ -67,6 +68,7 @@ type OptionalProps = {
renderItem: (info: {
item: Item,
index: number,
+ section: SectionT,
separators: {
highlight: () => void,
unhighlight: () => void,
@@ -77,6 +79,10 @@ type OptionalProps = {
* Rendered at the top of each section.
*/
renderSectionHeader?: ?({section: SectionT}) => ?React.Element<*>,
+ /**
+ * Rendered at the bottom of each section.
+ */
+ renderSectionFooter?: ?({section: SectionT}) => ?React.Element<*>,
/**
* Rendered at the bottom of every Section, except the very last one, in place of the normal
* ItemSeparatorComponent.
@@ -164,6 +170,10 @@ class VirtualizedSectionList
section: SectionT,
key: string, // Key of the section or combined key for section + item
index: ?number, // Relative index within the section
+ leadingItem?: ?Item,
+ leadingSection?: ?SectionT,
+ trailingItem?: ?Item,
+ trailingSection?: ?SectionT,
} {
let itemIndex = index;
const defaultKeyExtractor = this.props.keyExtractor;
@@ -178,13 +188,17 @@ class VirtualizedSectionList
if (itemIndex >= section.data.length) {
itemIndex -= section.data.length;
} else if (itemIndex === -1) {
- return {section, key, index: null};
+ return {section, key, index: null, trailingSection: this.props.sections[ii + 1]};
} else {
const keyExtractor = section.keyExtractor || defaultKeyExtractor;
return {
section,
key: key + ':' + keyExtractor(section.data[itemIndex], itemIndex),
index: itemIndex,
+ leadingItem: section.data[itemIndex - 1],
+ leadingSection: this.props.sections[ii - 1],
+ trailingItem: section.data[itemIndex + 1],
+ trailingSection: this.props.sections[ii + 1],
};
}
}
@@ -239,11 +253,19 @@ class VirtualizedSectionList
cellKey={info.key}
index={infoIndex}
item={item}
+ leadingItem={info.leadingItem}
+ leadingSection={info.leadingSection}
onUpdateSeparator={this._onUpdateSeparator}
prevCellKey={(this._subExtractor(index - 1) || {}).key}
ref={(ref) => {this._cellRefs[info.key] = ref;}}
renderItem={renderItem}
+ renderSectionFooter={infoIndex === info.section.data.length - 1
+ ? this.props.renderSectionFooter
+ : undefined
+ }
section={info.section}
+ trailingItem={info.trailingItem}
+ trailingSection={info.trailingSection}
/>
);
}
@@ -259,7 +281,8 @@ class VirtualizedSectionList
if (!info) {
return null;
}
- const ItemSeparatorComponent = info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent;
+ const ItemSeparatorComponent =
+ info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent;
const {SectionSeparatorComponent} = this.props;
const isLastItemInList = index === this.state.childProps.getItemCount() - 1;
const isLastItemInSection = info.index === info.section.data.length - 1;
@@ -282,6 +305,7 @@ class VirtualizedSectionList
},
0
);
+
return {
childProps: {
...props,
@@ -326,17 +350,30 @@ class ItemWithSeparator extends React.Component {
onUpdateSeparator: (cellKey: string, newProps: Object) => void,
prevCellKey?: ?string,
renderItem: Function,
+ renderSectionFooter: ?Function,
section: Object,
+ leadingItem: ?Item,
+ leadingSection: ?Object,
+ trailingItem: ?Item,
+ trailingSection: ?Object,
};
state = {
separatorProps: {
highlighted: false,
leadingItem: this.props.item,
- leadingSection: this.props.section,
+ leadingSection: this.props.leadingSection,
+ section: this.props.section,
+ trailingItem: this.props.trailingItem,
+ trailingSection: this.props.trailingSection,
},
leadingSeparatorProps: {
highlighted: false,
+ leadingItem: this.props.leadingItem,
+ leadingSection: this.props.leadingSection,
+ section: this.props.section,
+ trailingItem: this.props.item,
+ trailingSection: this.props.trailingSection,
},
};
@@ -364,16 +401,20 @@ class ItemWithSeparator extends React.Component {
}
render() {
- const {LeadingSeparatorComponent, SeparatorComponent, renderItem, item, index} = this.props;
- const element = renderItem({
+ const {LeadingSeparatorComponent, SeparatorComponent, item, index, section} = this.props;
+ const element = this.props.renderItem({
item,
index,
+ section,
separators: this._separators,
});
const leadingSeparator = LeadingSeparatorComponent &&
;
const separator = SeparatorComponent && ;
- return separator ? {leadingSeparator}{element}{separator} : element;
+ const footer = this.props.renderSectionFooter && this.props.renderSectionFooter({section});
+ return (leadingSeparator || separator || footer)
+ ? {leadingSeparator}{element}{separator}{footer}
+ : element;
}
}
diff --git a/Libraries/Lists/__tests__/FillRateHelper-test.js b/Libraries/Lists/__tests__/FillRateHelper-test.js
index 9a0792367..4d4a21f9a 100644
--- a/Libraries/Lists/__tests__/FillRateHelper-test.js
+++ b/Libraries/Lists/__tests__/FillRateHelper-test.js
@@ -14,93 +14,102 @@ jest.unmock('FillRateHelper');
const FillRateHelper = require('FillRateHelper');
let rowFramesGlobal;
-const dataGlobal = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
+const dataGlobal =
+ [{key: 'header'}, {key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}, {key: 'footer'}];
function getFrameMetrics(index: number) {
const frame = rowFramesGlobal[dataGlobal[index].key];
return {length: frame.height, offset: frame.y, inLayout: frame.inLayout};
}
-function computeResult({helper, props, state, scroll}) {
- return helper.computeInfoSampled(
- 'test',
+function computeResult({helper, props, state, scroll}): number {
+ helper.activate();
+ return helper.computeBlankness(
{
data: dataGlobal,
- fillRateTrackingSampleRate: 1,
getItemCount: (data2) => data2.length,
initialNumToRender: 10,
...(props || {}),
},
- {first: 0, last: 1, ...(state || {})},
+ {first: 1, last: 2, ...(state || {})},
{offset: 0, visibleLength: 100, ...(scroll || {})},
);
}
-describe('computeInfoSampled', function() {
+describe('computeBlankness', function() {
beforeEach(() => {
FillRateHelper.setSampleRate(1);
+ FillRateHelper.setMinSampleCount(0);
});
it('computes correct blankness of viewport', function() {
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
+ header: {y: 0, height: 0, inLayout: true},
a: {y: 0, height: 50, inLayout: true},
b: {y: 50, height: 50, inLayout: true},
};
- let result = computeResult({helper});
- expect(result).toBeNull();
- result = computeResult({helper, state: {last: 0}});
- expect(result.event.blankness).toBe(0.5);
- result = computeResult({helper, scroll: {offset: 25}});
- expect(result.event.blankness).toBe(0.25);
- result = computeResult({helper, scroll: {visibleLength: 400}});
- expect(result.event.blankness).toBe(0.75);
- result = computeResult({helper, scroll: {offset: 100}});
- expect(result.event.blankness).toBe(1);
- expect(result.aggregate.avg_blankness).toBe(0.5);
+ let blankness = computeResult({helper});
+ expect(blankness).toBe(0);
+ blankness = computeResult({helper, state: {last: 1}});
+ expect(blankness).toBe(0.5);
+ blankness = computeResult({helper, scroll: {offset: 25}});
+ expect(blankness).toBe(0.25);
+ blankness = computeResult({helper, scroll: {visibleLength: 400}});
+ expect(blankness).toBe(0.75);
+ blankness = computeResult({helper, scroll: {offset: 100}});
+ expect(blankness).toBe(1);
});
it('skips frames that are not in layout', function() {
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
+ header: {y: 0, height: 0, inLayout: false},
a: {y: 0, height: 10, inLayout: false},
b: {y: 10, height: 30, inLayout: true},
c: {y: 40, height: 40, inLayout: true},
d: {y: 80, height: 20, inLayout: false},
+ footer: {y: 100, height: 0, inLayout: false},
};
- const result = computeResult({helper, state: {last: 3}});
- expect(result.event.blankness).toBe(0.3);
+ const blankness = computeResult({helper, state: {last: 4}});
+ expect(blankness).toBe(0.3);
});
it('sampling rate can disable', function() {
- const helper = new FillRateHelper(getFrameMetrics);
+ let helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
+ header: {y: 0, height: 0, inLayout: true},
a: {y: 0, height: 40, inLayout: true},
b: {y: 40, height: 40, inLayout: true},
};
- let result = computeResult({helper});
- expect(result.event.blankness).toBe(0.2);
+ let blankness = computeResult({helper});
+ expect(blankness).toBe(0.2);
FillRateHelper.setSampleRate(0);
- result = computeResult({helper});
- expect(result).toBeNull();
+ helper = new FillRateHelper(getFrameMetrics);
+ blankness = computeResult({helper});
+ expect(blankness).toBe(0);
});
it('can handle multiple listeners and unsubscribe', function() {
const listeners = [jest.fn(), jest.fn(), jest.fn()];
const subscriptions = listeners.map(
- (listener) => FillRateHelper.addFillRateExceededListener(listener)
+ (listener) => FillRateHelper.addListener(listener)
);
subscriptions[1].remove();
const helper = new FillRateHelper(getFrameMetrics);
rowFramesGlobal = {
+ header: {y: 0, height: 0, inLayout: true},
a: {y: 0, height: 40, inLayout: true},
b: {y: 40, height: 40, inLayout: true},
};
- const result = computeResult({helper});
- expect(result.event.blankness).toBe(0.2);
- expect(listeners[0]).toBeCalledWith(result);
+ const blankness = computeResult({helper});
+ expect(blankness).toBe(0.2);
+ helper.deactivateAndFlush();
+ const info0 = listeners[0].mock.calls[0][0];
+ expect(info0.pixels_blank / info0.pixels_sampled).toBe(blankness);
expect(listeners[1]).not.toBeCalled();
- expect(listeners[2]).toBeCalledWith(result);
+ const info1 = listeners[2].mock.calls[0][0];
+ expect(info1.pixels_blank / info1.pixels_sampled).toBe(blankness);
});
});
diff --git a/Libraries/Lists/__tests__/SectionList-test.js b/Libraries/Lists/__tests__/SectionList-test.js
index 0e49c1deb..4499fc15c 100644
--- a/Libraries/Lists/__tests__/SectionList-test.js
+++ b/Libraries/Lists/__tests__/SectionList-test.js
@@ -21,7 +21,7 @@ describe('SectionList', () => {
const component = ReactTestRenderer.create(
}
+ renderItem={({item}) => }
/>
);
expect(component).toMatchSnapshot();
@@ -30,7 +30,7 @@ describe('SectionList', () => {
const component = ReactTestRenderer.create(
}
+ renderItem={({item}) => }
renderSectionHeader={() => null}
/>
);
@@ -39,29 +39,41 @@ describe('SectionList', () => {
it('renders all the bells and whistles', () => {
const component = ReactTestRenderer.create(
}
- ListFooterComponent={() => }
- ListHeaderComponent={() => }
- SectionSeparatorComponent={() => }
+ ItemSeparatorComponent={(props) => }
+ ListFooterComponent={(props) => }
+ ListHeaderComponent={(props) => }
+ SectionSeparatorComponent={(props) => }
sections={[
{
- renderItem: ({item}) => ,
- key: '1st Section',
+ renderItem: (props) => ,
+ key: 's1',
keyExtractor: (item, index) => item.id,
- ItemSeparatorComponent: () => ,
+ ItemSeparatorComponent: (props) => ,
data: [{id: 'i1s1'}, {id: 'i2s1'}],
},
{
- key: '2nd Section',
+ key: 's2',
data: [{key: 'i1s2'}, {key: 'i2s2'}],
},
+ {
+ key: 's3',
+ data: [{key: 'i1s3'}, {key: 'i2s3'}],
+ },
]}
refreshing={false}
onRefresh={jest.fn()}
- renderItem={({item}) => }
- renderSectionHeader={({section}) => }
+ renderItem={(props) => }
+ renderSectionHeader={(props) => }
+ renderSectionFooter={(props) => }
/>
);
expect(component).toMatchSnapshot();
});
});
+
+function propStr(props) {
+ return Object.keys(props).map(k => {
+ const propObj = props[k] || {};
+ return `${k}:${propObj.key || propObj.id || props[k]}`;
+ }).join(',');
+}
diff --git a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap
index 9d6860f5b..161fb54c4 100644
--- a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap
+++ b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap
@@ -36,9 +36,11 @@ exports[`FlatList renders all the bells and whistles 1`] = `
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
+ onMomentumScrollEnd={[Function]}
onRefresh={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
+ onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
refreshControl={
@@ -99,7 +101,7 @@ exports[`SectionList renders all the bells and whistles 1`] = `
"id": "i2s1",
},
],
- "key": "1st Section",
+ "key": "s1",
"keyExtractor": [Function],
"renderItem": [Function],
},
@@ -112,7 +114,18 @@ exports[`SectionList renders all the bells and whistles 1`] = `
"key": "i2s2",
},
],
- "key": "2nd Section",
+ "key": "s2",
+ },
+ Object {
+ "data": Array [
+ Object {
+ "key": "i1s3",
+ },
+ Object {
+ "key": "i2s3",
+ },
+ ],
+ "key": "s3",
},
]
}
@@ -126,9 +139,11 @@ exports[`SectionList renders all the bells and whistles 1`] = `
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
+ onMomentumScrollEnd={[Function]}
onRefresh={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
+ onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
refreshControl={
-
+
-
-
+
+
-
+
+
-
-
-
+
+
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -266,8 +353,10 @@ exports[`SectionList renders empty list 1`] = `
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
+ onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
+ onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
diff --git a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap
index 2ff341ec4..a1a5bb623 100644
--- a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap
+++ b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap
@@ -36,9 +36,11 @@ exports[`VirtualizedList renders all the bells and whistles 1`] = `
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
+ onMomentumScrollEnd={[Function]}
onRefresh={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
+ onScrollEndDrag={[Function]}
refreshControl={