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={() =>