From 28aaa8880809bb25db2a6c51fedf01a82064965c Mon Sep 17 00:00:00 2001 From: Spencer Ahrens Date: Tue, 25 Apr 2017 14:44:00 -0700 Subject: [PATCH] Many improvements Summary: These got smashed together with some weird rebase snafu. They are pretty intertwined anyway so the value of separate commits is minimal (e.g. separate commits would not revert cleanly anyway). == [lists] better fill rate logging (previously D4907958) After looking through some production data, I think this will address all the issues we're seeing. Now: - Header/Footer getting no longer counted as blank. - Avoid floating point for Scuba. - Compare actual time of blankness, not just samples. - Include both "any" vs. "mostly" blank (similar to 1 and 4 frame drops). - Include events where there is no blankness so we have a baseline. - Remove events with too few samples **Test Plan: ** A bunch of scrolling in FlatListExample T17384966 == [Lists] Update SectionSeparatorItem docs (previously D4909526) Forgot to update the language here when we modified the behavior with the introduction of separator highlighting support. ** Test Plan: ** nope. == [Lists] Add renderSectionFooter prop to SectionList (previously D4923353) Handy for things like "see more" links and such. The logic here is to render the footer last, *after* the bottom section separator. This is to preserve the highlighting behavior of the section separator by keeping it adjacent to the items. **Test Plan: ** Added to snapshot test and example: {F66635525} {F66635526} == [SectionList] Add a bunch more info for rendering items and separators (previously D4923663) This extra info can be helpful for rending more complex patterns. **Test Plan: ** Made snapshot test more comprehensive and inspected the output. == [Lists] reduce render churn (previously D4924639) I don't think the velocity based leadFactor is helping and might actually be hurting because it causes a lot of churn in the items we render. Instead, this diff introduces fillPreference which biases the window expansion in the direction of scroll, but doesn't actually affect the final bounds of the window at all, so items that are already rendered are more likely to stay rendered. **Test Plan: ** Played around in debug mode and watched the overlay - seems better. Also tests all pass. T16621861 == [Lists] Add initialScrollIndex prop Makes it easy to load a VirtualizedList at a location in the middle of the content without wasting time rendering initial rows that aren't relevant, for example when opening an infinite calendar view to "today". **Test Plan: ** With debug overlay, set `initialScrollIndex={52}` prop in `FlatListExample` and and see it immediately render a full screen of items with item 52 aligned at the top of the screen. Note no initial items are mounted per debug overlay. Scroll around a bunch and everything else seems to work as normal. No SectionList impl since `getItemLayout` isn't easy to use there. T17091314 Reviewed By: bvaughn Differential Revision: D4907958 fbshipit-source-id: 8b9f1f542f9b240f1e317f3fd7e31c9376e8670e --- Examples/UIExplorer/js/SectionListExample.js | 39 +++- Libraries/Lists/FillRateHelper.js | 212 +++++++++++------- Libraries/Lists/FlatList.js | 7 + Libraries/Lists/SectionList.js | 21 +- Libraries/Lists/VirtualizeUtils.js | 13 +- Libraries/Lists/VirtualizedList.js | 94 +++++--- Libraries/Lists/VirtualizedSectionList.js | 53 ++++- .../Lists/__tests__/FillRateHelper-test.js | 69 +++--- Libraries/Lists/__tests__/SectionList-test.js | 36 ++- .../__snapshots__/FlatList-test.js.snap | 8 + .../__snapshots__/SectionList-test.js.snap | 133 +++++++++-- .../VirtualizedList-test.js.snap | 10 + 12 files changed, 493 insertions(+), 202 deletions(-) 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={() =>