mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Reconnect VirtualizedList Source History 1/2 (Revert D41745930)
Summary: This change reverts D41745930 (https://github.com/facebook/react-native/commit/2e3dbe9c2fbff52448e2d5a7c1e4c96b1016cf25) as part of a stack to splice back source history which was lost (Git registered the file moves as additions). It is expected this diff will individually fail. The entire stack should be applied at once. Changelog: [Internal] Reviewed By: hoxyq Differential Revision: D43068113 fbshipit-source-id: c8398629fe5dcc1ca4bf02f550adc00c78a8487a
This commit is contained in:
committed by
Facebook GitHub Bot
parent
d9f2491a71
commit
ebaa00e327
@@ -10,7 +10,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import type {RenderItemProps} from '@react-native/virtualized-lists';
|
||||
import type {RenderItemProps} from '../Lists/VirtualizedList';
|
||||
|
||||
const ScrollView = require('../Components/ScrollView/ScrollView');
|
||||
const TouchableHighlight = require('../Components/Touchable/TouchableHighlight');
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const {InteractionManager} = require('react-native');
|
||||
const InteractionManager = require('./InteractionManager');
|
||||
|
||||
/**
|
||||
* A simple class for batching up invocations of a low-pri callback. A timeout is set to run the
|
||||
+4
@@ -10,6 +10,10 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
jest
|
||||
.mock('../../vendor/core/ErrorUtils')
|
||||
.mock('../../BatchedBridge/BatchedBridge');
|
||||
|
||||
function expectToBeCalledOnce(fn) {
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
}
|
||||
@@ -10,10 +10,244 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import {typeof FillRateHelper as FillRateHelperType} from '@react-native/virtualized-lists';
|
||||
import type {FrameMetricProps} from './VirtualizedListProps';
|
||||
|
||||
const FillRateHelper: FillRateHelperType =
|
||||
require('@react-native/virtualized-lists').FillRateHelper;
|
||||
export type FillRateInfo = Info;
|
||||
|
||||
class Info {
|
||||
any_blank_count: number = 0;
|
||||
any_blank_ms: number = 0;
|
||||
any_blank_speed_sum: number = 0;
|
||||
mostly_blank_count: number = 0;
|
||||
mostly_blank_ms: number = 0;
|
||||
pixels_blank: number = 0;
|
||||
pixels_sampled: number = 0;
|
||||
pixels_scrolled: number = 0;
|
||||
total_time_spent: number = 0;
|
||||
sample_count: number = 0;
|
||||
}
|
||||
|
||||
type FrameMetrics = {
|
||||
inLayout?: boolean,
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
};
|
||||
|
||||
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.
|
||||
* By default the sampling rate is set to zero and this will do nothing. If you want to collect
|
||||
* samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`.
|
||||
*
|
||||
* Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with
|
||||
* `SceneTracker.getActiveScene` to determine the context of the events.
|
||||
*/
|
||||
class FillRateHelper {
|
||||
_anyBlankStartTime: ?number = null;
|
||||
_enabled = false;
|
||||
_getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics;
|
||||
_info: Info = new Info();
|
||||
_mostlyBlankStartTime: ?number = null;
|
||||
_samplesStartTime: ?number = null;
|
||||
|
||||
static addListener(callback: FillRateInfo => void): {
|
||||
remove: () => void,
|
||||
...
|
||||
} {
|
||||
if (_sampleRate === null) {
|
||||
console.warn('Call `FillRateHelper.setSampleRate` before `addListener`.');
|
||||
}
|
||||
_listeners.push(callback);
|
||||
return {
|
||||
remove: () => {
|
||||
_listeners = _listeners.filter(listener => callback !== listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static setSampleRate(sampleRate: number) {
|
||||
_sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
static setMinSampleCount(minSampleCount: number) {
|
||||
_minSampleCount = minSampleCount;
|
||||
}
|
||||
|
||||
constructor(
|
||||
getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics,
|
||||
) {
|
||||
this._getFrameMetrics = getFrameMetrics;
|
||||
this._enabled = (_sampleRate || 0) > Math.random();
|
||||
this._resetData();
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this._enabled && this._samplesStartTime == null) {
|
||||
DEBUG && console.debug('FillRateHelper: activate');
|
||||
this._samplesStartTime = global.performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
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 = global.performance.now() - 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) {
|
||||
// $FlowFixMe[prop-missing]
|
||||
derived[key] = Math.round(1000 * derived[key]) / 1000;
|
||||
}
|
||||
console.debug('FillRateHelper deactivateAndFlush: ', {derived, info});
|
||||
}
|
||||
_listeners.forEach(listener => listener(info));
|
||||
this._resetData();
|
||||
}
|
||||
|
||||
computeBlankness(
|
||||
props: {
|
||||
...FrameMetricProps,
|
||||
initialNumToRender?: ?number,
|
||||
...
|
||||
},
|
||||
cellsAroundViewport: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
scrollMetrics: {
|
||||
dOffset: number,
|
||||
offset: number,
|
||||
velocity: number,
|
||||
visibleLength: number,
|
||||
...
|
||||
},
|
||||
): number {
|
||||
if (
|
||||
!this._enabled ||
|
||||
props.getItemCount(props.data) === 0 ||
|
||||
cellsAroundViewport.last < cellsAroundViewport.first ||
|
||||
this._samplesStartTime == null
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
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 = global.performance.now();
|
||||
if (this._anyBlankStartTime != null) {
|
||||
this._info.any_blank_ms += now - this._anyBlankStartTime;
|
||||
}
|
||||
this._anyBlankStartTime = null;
|
||||
if (this._mostlyBlankStartTime != null) {
|
||||
this._info.mostly_blank_ms += now - this._mostlyBlankStartTime;
|
||||
}
|
||||
this._mostlyBlankStartTime = null;
|
||||
|
||||
let blankTop = 0;
|
||||
let first = cellsAroundViewport.first;
|
||||
let firstFrame = this._getFrameMetrics(first, props);
|
||||
while (
|
||||
first <= cellsAroundViewport.last &&
|
||||
(!firstFrame || !firstFrame.inLayout)
|
||||
) {
|
||||
firstFrame = this._getFrameMetrics(first, props);
|
||||
first++;
|
||||
}
|
||||
// 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;
|
||||
let last = cellsAroundViewport.last;
|
||||
let lastFrame = this._getFrameMetrics(last, props);
|
||||
while (
|
||||
last >= cellsAroundViewport.first &&
|
||||
(!lastFrame || !lastFrame.inLayout)
|
||||
) {
|
||||
lastFrame = this._getFrameMetrics(last, props);
|
||||
last--;
|
||||
}
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
const pixels_blank = Math.round(blankTop + blankBottom);
|
||||
const blankness = pixels_blank / visibleLength;
|
||||
if (blankness > 0) {
|
||||
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++;
|
||||
}
|
||||
} else if (scrollSpeed < 0.01 || Math.abs(dOffset) < 1) {
|
||||
this.deactivateAndFlush();
|
||||
}
|
||||
return blankness;
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
_resetData() {
|
||||
this._anyBlankStartTime = null;
|
||||
this._info = new Info();
|
||||
this._mostlyBlankStartTime = null;
|
||||
this._samplesStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type {FillRateInfo} from '@react-native/virtualized-lists';
|
||||
module.exports = FillRateHelper;
|
||||
|
||||
Vendored
+1
-1
@@ -12,7 +12,7 @@ import type {
|
||||
ListRenderItem,
|
||||
ViewToken,
|
||||
VirtualizedListProps,
|
||||
} from '@react-native/virtualized-lists';
|
||||
} from './VirtualizedList';
|
||||
import type {ScrollViewComponent} from '../Components/ScrollView/ScrollView';
|
||||
import {StyleProp} from '../StyleSheet/StyleSheet';
|
||||
import {ViewStyle} from '../StyleSheet/StyleSheetTypes';
|
||||
|
||||
@@ -11,17 +11,14 @@
|
||||
import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent';
|
||||
import type {ViewStyleProp} from '../StyleSheet/StyleSheet';
|
||||
import type {
|
||||
RenderItemProps,
|
||||
RenderItemType,
|
||||
ViewabilityConfigCallbackPair,
|
||||
ViewToken,
|
||||
} from '@react-native/virtualized-lists';
|
||||
} from './ViewabilityHelper';
|
||||
import type {RenderItemProps, RenderItemType} from './VirtualizedList';
|
||||
|
||||
import {type ScrollResponderType} from '../Components/ScrollView/ScrollView';
|
||||
import {
|
||||
VirtualizedList,
|
||||
keyExtractor as defaultKeyExtractor,
|
||||
} from '@react-native/virtualized-lists';
|
||||
import VirtualizedList from './VirtualizedList';
|
||||
import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
const View = require('../Components/View/View');
|
||||
|
||||
@@ -14,13 +14,8 @@ const View = require('../Components/View/View');
|
||||
import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent';
|
||||
import {type ScrollResponderType} from '../Components/ScrollView/ScrollView';
|
||||
import type {ViewStyleProp} from '../StyleSheet/StyleSheet';
|
||||
import type {
|
||||
RenderItemType,
|
||||
RenderItemProps,
|
||||
ViewToken,
|
||||
ViewabilityConfigCallbackPair,
|
||||
} from '@react-native/virtualized-lists';
|
||||
import {typeof VirtualizedList} from '@react-native/virtualized-lists';
|
||||
import type {RenderItemType} from './VirtualizedList';
|
||||
import typeof VirtualizedList from './VirtualizedList';
|
||||
|
||||
type RequiredProps<ItemT> = {|
|
||||
/**
|
||||
|
||||
Vendored
+1
-1
@@ -11,7 +11,7 @@ import type * as React from 'react';
|
||||
import type {
|
||||
ListRenderItemInfo,
|
||||
VirtualizedListWithoutRenderItemProps,
|
||||
} from '@react-native/virtualized-lists';
|
||||
} from './VirtualizedList';
|
||||
import type {
|
||||
ScrollView,
|
||||
ScrollViewProps,
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
|
||||
import type {ScrollResponderType} from '../Components/ScrollView/ScrollView';
|
||||
import type {
|
||||
Props as VirtualizedSectionListProps,
|
||||
ScrollToLocationParamsType,
|
||||
SectionBase as _SectionBase,
|
||||
VirtualizedSectionListProps,
|
||||
} from '@react-native/virtualized-lists';
|
||||
} from './VirtualizedSectionList';
|
||||
|
||||
import Platform from '../Utilities/Platform';
|
||||
import {VirtualizedSectionList} from '@react-native/virtualized-lists';
|
||||
import VirtualizedSectionList from './VirtualizedSectionList';
|
||||
import * as React from 'react';
|
||||
|
||||
type Item = any;
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
|
||||
import type {ScrollResponderType} from '../Components/ScrollView/ScrollView';
|
||||
import type {
|
||||
Props as VirtualizedSectionListProps,
|
||||
ScrollToLocationParamsType,
|
||||
SectionBase as _SectionBase,
|
||||
VirtualizedSectionListProps,
|
||||
} from '@react-native/virtualized-lists';
|
||||
} from './VirtualizedSectionList';
|
||||
import type {AbstractComponent, Element, ElementRef} from 'react';
|
||||
|
||||
import Platform from '../Utilities/Platform';
|
||||
import {VirtualizedSectionList} from '@react-native/virtualized-lists';
|
||||
import VirtualizedSectionList from './VirtualizedSectionList';
|
||||
import React, {forwardRef, useImperativeHandle, useRef} from 'react';
|
||||
|
||||
type Item = any;
|
||||
|
||||
@@ -10,15 +10,351 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
export type {
|
||||
ViewToken,
|
||||
ViewabilityConfig,
|
||||
ViewabilityConfigCallbackPair,
|
||||
} from '@react-native/virtualized-lists';
|
||||
import type {FrameMetricProps} from './VirtualizedListProps';
|
||||
|
||||
import {typeof ViewabilityHelper as ViewabilityHelperType} from '@react-native/virtualized-lists';
|
||||
const invariant = require('invariant');
|
||||
|
||||
const ViewabilityHelper: ViewabilityHelperType =
|
||||
require('@react-native/virtualized-lists').ViewabilityHelper;
|
||||
export type ViewToken = {
|
||||
item: any,
|
||||
key: string,
|
||||
index: ?number,
|
||||
isViewable: boolean,
|
||||
section?: any,
|
||||
...
|
||||
};
|
||||
|
||||
export type ViewabilityConfigCallbackPair = {
|
||||
viewabilityConfig: ViewabilityConfig,
|
||||
onViewableItemsChanged: (info: {
|
||||
viewableItems: Array<ViewToken>,
|
||||
changed: Array<ViewToken>,
|
||||
...
|
||||
}) => void,
|
||||
...
|
||||
};
|
||||
|
||||
export type ViewabilityConfig = {|
|
||||
/**
|
||||
* Minimum amount of time (in milliseconds) that an item must be physically viewable before the
|
||||
* viewability callback will be fired. A high number means that scrolling through content without
|
||||
* stopping will not mark the content as viewable.
|
||||
*/
|
||||
minimumViewTime?: number,
|
||||
|
||||
/**
|
||||
* Percent of viewport that must be covered for a partially occluded item to count as
|
||||
* "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means
|
||||
* that a single pixel in the viewport makes the item viewable, and a value of 100 means that
|
||||
* an item must be either entirely visible or cover the entire viewport to count as viewable.
|
||||
*/
|
||||
viewAreaCoveragePercentThreshold?: number,
|
||||
|
||||
/**
|
||||
* Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible,
|
||||
* rather than the fraction of the viewable area it covers.
|
||||
*/
|
||||
itemVisiblePercentThreshold?: number,
|
||||
|
||||
/**
|
||||
* Nothing is considered viewable until the user scrolls or `recordInteraction` is called after
|
||||
* render.
|
||||
*/
|
||||
waitForInteraction?: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A Utility class for calculating viewable items based on current metrics like scroll position and
|
||||
* layout.
|
||||
*
|
||||
* An item is said to be in a "viewable" state when any of the following
|
||||
* is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction`
|
||||
* is true):
|
||||
*
|
||||
* - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
|
||||
* visible in the view area >= `itemVisiblePercentThreshold`.
|
||||
* - Entirely visible on screen
|
||||
*/
|
||||
class ViewabilityHelper {
|
||||
_config: ViewabilityConfig;
|
||||
_hasInteracted: boolean = false;
|
||||
_timers: Set<number> = new Set();
|
||||
_viewableIndices: Array<number> = [];
|
||||
_viewableItems: Map<string, ViewToken> = new Map();
|
||||
|
||||
constructor(
|
||||
config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0},
|
||||
) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup, e.g. on unmount. Clears any pending timers.
|
||||
*/
|
||||
dispose() {
|
||||
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
|
||||
* comment suppresses an error found when Flow v0.63 was deployed. To see
|
||||
* the error delete this comment and run Flow. */
|
||||
this._timers.forEach(clearTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which items are viewable based on the current metrics and config.
|
||||
*/
|
||||
computeViewableItems(
|
||||
props: FrameMetricProps,
|
||||
scrollOffset: number,
|
||||
viewportHeight: number,
|
||||
getFrameMetrics: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => ?{
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
// Optional optimization to reduce the scan size
|
||||
renderRange?: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
): Array<number> {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
const {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} =
|
||||
this._config;
|
||||
const viewAreaMode = viewAreaCoveragePercentThreshold != null;
|
||||
const viewablePercentThreshold = viewAreaMode
|
||||
? viewAreaCoveragePercentThreshold
|
||||
: itemVisiblePercentThreshold;
|
||||
invariant(
|
||||
viewablePercentThreshold != null &&
|
||||
(itemVisiblePercentThreshold != null) !==
|
||||
(viewAreaCoveragePercentThreshold != null),
|
||||
'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold',
|
||||
);
|
||||
const viewableIndices = [];
|
||||
if (itemCount === 0) {
|
||||
return viewableIndices;
|
||||
}
|
||||
let firstVisible = -1;
|
||||
const {first, last} = renderRange || {first: 0, last: itemCount - 1};
|
||||
if (last >= itemCount) {
|
||||
console.warn(
|
||||
'Invalid render range computing viewability ' +
|
||||
JSON.stringify({renderRange, itemCount}),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
for (let idx = first; idx <= last; idx++) {
|
||||
const metrics = getFrameMetrics(idx, props);
|
||||
if (!metrics) {
|
||||
continue;
|
||||
}
|
||||
const top = metrics.offset - scrollOffset;
|
||||
const bottom = top + metrics.length;
|
||||
if (top < viewportHeight && bottom > 0) {
|
||||
firstVisible = idx;
|
||||
if (
|
||||
_isViewable(
|
||||
viewAreaMode,
|
||||
viewablePercentThreshold,
|
||||
top,
|
||||
bottom,
|
||||
viewportHeight,
|
||||
metrics.length,
|
||||
)
|
||||
) {
|
||||
viewableIndices.push(idx);
|
||||
}
|
||||
} else if (firstVisible >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return viewableIndices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Figures out which items are viewable and how that has changed from before and calls
|
||||
* `onViewableItemsChanged` as appropriate.
|
||||
*/
|
||||
onUpdate(
|
||||
props: FrameMetricProps,
|
||||
scrollOffset: number,
|
||||
viewportHeight: number,
|
||||
getFrameMetrics: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => ?{
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
createViewToken: (
|
||||
index: number,
|
||||
isViewable: boolean,
|
||||
props: FrameMetricProps,
|
||||
) => ViewToken,
|
||||
onViewableItemsChanged: ({
|
||||
viewableItems: Array<ViewToken>,
|
||||
changed: Array<ViewToken>,
|
||||
...
|
||||
}) => void,
|
||||
// Optional optimization to reduce the scan size
|
||||
renderRange?: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
): void {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
if (
|
||||
(this._config.waitForInteraction && !this._hasInteracted) ||
|
||||
itemCount === 0 ||
|
||||
!getFrameMetrics(0, props)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let viewableIndices: Array<number> = [];
|
||||
if (itemCount) {
|
||||
viewableIndices = this.computeViewableItems(
|
||||
props,
|
||||
scrollOffset,
|
||||
viewportHeight,
|
||||
getFrameMetrics,
|
||||
renderRange,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this._viewableIndices.length === viewableIndices.length &&
|
||||
this._viewableIndices.every((v, ii) => v === viewableIndices[ii])
|
||||
) {
|
||||
// We might get a lot of scroll events where visibility doesn't change and we don't want to do
|
||||
// extra work in those cases.
|
||||
return;
|
||||
}
|
||||
this._viewableIndices = viewableIndices;
|
||||
if (this._config.minimumViewTime) {
|
||||
const handle: TimeoutID = setTimeout(() => {
|
||||
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
|
||||
* comment suppresses an error found when Flow v0.63 was deployed. To
|
||||
* see the error delete this comment and run Flow. */
|
||||
this._timers.delete(handle);
|
||||
this._onUpdateSync(
|
||||
props,
|
||||
viewableIndices,
|
||||
onViewableItemsChanged,
|
||||
createViewToken,
|
||||
);
|
||||
}, this._config.minimumViewTime);
|
||||
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
|
||||
* comment suppresses an error found when Flow v0.63 was deployed. To see
|
||||
* the error delete this comment and run Flow. */
|
||||
this._timers.add(handle);
|
||||
} else {
|
||||
this._onUpdateSync(
|
||||
props,
|
||||
viewableIndices,
|
||||
onViewableItemsChanged,
|
||||
createViewToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* clean-up cached _viewableIndices to evaluate changed items on next update
|
||||
*/
|
||||
resetViewableIndices() {
|
||||
this._viewableIndices = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that an interaction has happened even if there has been no scroll.
|
||||
*/
|
||||
recordInteraction() {
|
||||
this._hasInteracted = true;
|
||||
}
|
||||
|
||||
_onUpdateSync(
|
||||
props: FrameMetricProps,
|
||||
viewableIndicesToCheck: Array<number>,
|
||||
onViewableItemsChanged: ({
|
||||
changed: Array<ViewToken>,
|
||||
viewableItems: Array<ViewToken>,
|
||||
...
|
||||
}) => void,
|
||||
createViewToken: (
|
||||
index: number,
|
||||
isViewable: boolean,
|
||||
props: FrameMetricProps,
|
||||
) => ViewToken,
|
||||
) {
|
||||
// Filter out indices that have gone out of view since this call was scheduled.
|
||||
viewableIndicesToCheck = viewableIndicesToCheck.filter(ii =>
|
||||
this._viewableIndices.includes(ii),
|
||||
);
|
||||
const prevItems = this._viewableItems;
|
||||
const nextItems = new Map(
|
||||
viewableIndicesToCheck.map(ii => {
|
||||
const viewable = createViewToken(ii, true, props);
|
||||
return [viewable.key, viewable];
|
||||
}),
|
||||
);
|
||||
|
||||
const changed = [];
|
||||
for (const [key, viewable] of nextItems) {
|
||||
if (!prevItems.has(key)) {
|
||||
changed.push(viewable);
|
||||
}
|
||||
}
|
||||
for (const [key, viewable] of prevItems) {
|
||||
if (!nextItems.has(key)) {
|
||||
changed.push({...viewable, isViewable: false});
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
this._viewableItems = nextItems;
|
||||
onViewableItemsChanged({
|
||||
viewableItems: Array.from(nextItems.values()),
|
||||
changed,
|
||||
viewabilityConfig: this._config,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _isViewable(
|
||||
viewAreaMode: boolean,
|
||||
viewablePercentThreshold: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
viewportHeight: number,
|
||||
itemLength: number,
|
||||
): boolean {
|
||||
if (_isEntirelyVisible(top, bottom, viewportHeight)) {
|
||||
return true;
|
||||
} else {
|
||||
const pixels = _getPixelsVisible(top, bottom, viewportHeight);
|
||||
const percent =
|
||||
100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
|
||||
return percent >= viewablePercentThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
function _getPixelsVisible(
|
||||
top: number,
|
||||
bottom: number,
|
||||
viewportHeight: number,
|
||||
): number {
|
||||
const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
|
||||
return Math.max(0, visibleHeight);
|
||||
}
|
||||
|
||||
function _isEntirelyVisible(
|
||||
top: number,
|
||||
bottom: number,
|
||||
viewportHeight: number,
|
||||
): boolean {
|
||||
return top >= 0 && bottom <= viewportHeight && bottom > top;
|
||||
}
|
||||
|
||||
module.exports = ViewabilityHelper;
|
||||
|
||||
@@ -10,9 +10,249 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import {typeof keyExtractor as KeyExtractorType} from '@react-native/virtualized-lists';
|
||||
import type {FrameMetricProps} from './VirtualizedListProps';
|
||||
|
||||
const keyExtractor: KeyExtractorType =
|
||||
require('@react-native/virtualized-lists').keyExtractor;
|
||||
/**
|
||||
* Used to find the indices of the frames that overlap the given offsets. Useful for finding the
|
||||
* items that bound different windows of content, such as the visible area or the buffered overscan
|
||||
* area.
|
||||
*/
|
||||
export function elementsThatOverlapOffsets(
|
||||
offsets: Array<number>,
|
||||
props: FrameMetricProps,
|
||||
getFrameMetrics: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => {
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
zoomScale: number = 1,
|
||||
): Array<number> {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
const result = [];
|
||||
for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) {
|
||||
const currentOffset = offsets[offsetIndex];
|
||||
let left = 0;
|
||||
let right = itemCount - 1;
|
||||
|
||||
module.exports = {keyExtractor};
|
||||
while (left <= right) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = left + ((right - left) >>> 1);
|
||||
const frame = getFrameMetrics(mid, props);
|
||||
const scaledOffsetStart = frame.offset * zoomScale;
|
||||
const scaledOffsetEnd = (frame.offset + frame.length) * zoomScale;
|
||||
|
||||
// We want the first frame that contains the offset, with inclusive bounds. Thus, for the
|
||||
// first frame the scaledOffsetStart is inclusive, while for other frames it is exclusive.
|
||||
if (
|
||||
(mid === 0 && currentOffset < scaledOffsetStart) ||
|
||||
(mid !== 0 && currentOffset <= scaledOffsetStart)
|
||||
) {
|
||||
right = mid - 1;
|
||||
} else if (currentOffset > scaledOffsetEnd) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
result[offsetIndex] = mid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the number of elements in the `next` range that are new compared to the `prev` range.
|
||||
* Handy for calculating how many new items will be rendered when the render window changes so we
|
||||
* can restrict the number of new items render at once so that content can appear on the screen
|
||||
* faster.
|
||||
*/
|
||||
export function newRangeCount(
|
||||
prev: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
next: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
): number {
|
||||
return (
|
||||
next.last -
|
||||
next.first +
|
||||
1 -
|
||||
Math.max(
|
||||
0,
|
||||
1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom logic for determining which items should be rendered given the current frame and scroll
|
||||
* metrics, as well as the previous render state. The algorithm may evolve over time, but generally
|
||||
* prioritizes the visible area first, then expands that with overscan regions ahead and behind,
|
||||
* biased in the direction of scroll.
|
||||
*/
|
||||
export function computeWindowedRenderLimits(
|
||||
props: FrameMetricProps,
|
||||
maxToRenderPerBatch: number,
|
||||
windowSize: number,
|
||||
prev: {
|
||||
first: number,
|
||||
last: number,
|
||||
},
|
||||
getFrameMetricsApprox: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => {
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
scrollMetrics: {
|
||||
dt: number,
|
||||
offset: number,
|
||||
velocity: number,
|
||||
visibleLength: number,
|
||||
zoomScale: number,
|
||||
...
|
||||
},
|
||||
): {
|
||||
first: number,
|
||||
last: number,
|
||||
} {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
if (itemCount === 0) {
|
||||
return {first: 0, last: -1};
|
||||
}
|
||||
const {offset, velocity, visibleLength, zoomScale = 1} = scrollMetrics;
|
||||
|
||||
// Start with visible area, then compute maximum overscan region by expanding from there, biased
|
||||
// in the direction of scroll. Total overscan area is capped, which should cap memory consumption
|
||||
// too.
|
||||
const visibleBegin = Math.max(0, offset);
|
||||
const visibleEnd = visibleBegin + visibleLength;
|
||||
const overscanLength = (windowSize - 1) * visibleLength;
|
||||
|
||||
// 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);
|
||||
|
||||
const lastItemOffset =
|
||||
getFrameMetricsApprox(itemCount - 1, props).offset * zoomScale;
|
||||
if (lastItemOffset < overscanBegin) {
|
||||
// Entire list is before our overscan window
|
||||
return {
|
||||
first: Math.max(0, itemCount - 1 - maxToRenderPerBatch),
|
||||
last: itemCount - 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the indices that correspond to the items at the render boundaries we're targeting.
|
||||
let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets(
|
||||
[overscanBegin, visibleBegin, visibleEnd, overscanEnd],
|
||||
props,
|
||||
getFrameMetricsApprox,
|
||||
zoomScale,
|
||||
);
|
||||
overscanFirst = overscanFirst == null ? 0 : overscanFirst;
|
||||
first = first == null ? Math.max(0, overscanFirst) : first;
|
||||
overscanLast = overscanLast == null ? itemCount - 1 : overscanLast;
|
||||
last =
|
||||
last == null
|
||||
? Math.min(overscanLast, first + maxToRenderPerBatch - 1)
|
||||
: last;
|
||||
const visible = {first, last};
|
||||
|
||||
// We want to limit the number of new cells we're rendering per batch so that we can fill the
|
||||
// content on the screen quickly. If we rendered the entire overscan window at once, the user
|
||||
// could be staring at white space for a long time waiting for a bunch of offscreen content to
|
||||
// render.
|
||||
let newCellCount = newRangeCount(prev, visible);
|
||||
|
||||
while (true) {
|
||||
if (first <= overscanFirst && last >= overscanLast) {
|
||||
// If we fill the entire overscan range, we're done.
|
||||
break;
|
||||
}
|
||||
const maxNewCells = newCellCount >= maxToRenderPerBatch;
|
||||
const firstWillAddMore = first <= prev.first || first > prev.last;
|
||||
const firstShouldIncrement =
|
||||
first > overscanFirst && (!maxNewCells || !firstWillAddMore);
|
||||
const lastWillAddMore = last >= prev.last || last < prev.first;
|
||||
const lastShouldIncrement =
|
||||
last < overscanLast && (!maxNewCells || !lastWillAddMore);
|
||||
if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) {
|
||||
// We only want to stop if we've hit maxNewCells AND we cannot increment first or last
|
||||
// without rendering new items. This let's us preserve as many already rendered items as
|
||||
// possible, reducing render churn and keeping the rendered overscan range as large as
|
||||
// possible.
|
||||
break;
|
||||
}
|
||||
if (
|
||||
firstShouldIncrement &&
|
||||
!(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore)
|
||||
) {
|
||||
if (firstWillAddMore) {
|
||||
newCellCount++;
|
||||
}
|
||||
first--;
|
||||
}
|
||||
if (
|
||||
lastShouldIncrement &&
|
||||
!(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore)
|
||||
) {
|
||||
if (lastWillAddMore) {
|
||||
newCellCount++;
|
||||
}
|
||||
last++;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!(
|
||||
last >= first &&
|
||||
first >= 0 &&
|
||||
last < itemCount &&
|
||||
first >= overscanFirst &&
|
||||
last <= overscanLast &&
|
||||
first <= visible.first &&
|
||||
last >= visible.last
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Bad window calculation ' +
|
||||
JSON.stringify({
|
||||
first,
|
||||
last,
|
||||
itemCount,
|
||||
overscanFirst,
|
||||
overscanLast,
|
||||
visible,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {first, last};
|
||||
}
|
||||
|
||||
export function keyExtractor(item: any, index: number): string {
|
||||
if (typeof item === 'object' && item?.key != null) {
|
||||
return item.key;
|
||||
}
|
||||
if (typeof item === 'object' && item?.id != null) {
|
||||
return item.id;
|
||||
}
|
||||
return String(index);
|
||||
}
|
||||
|
||||
Vendored
+6
-6
@@ -8,15 +8,15 @@
|
||||
*/
|
||||
|
||||
import type * as React from 'react';
|
||||
import type {LayoutChangeEvent} from '../../types';
|
||||
import {StyleProp} from '../StyleSheet/StyleSheet';
|
||||
import {ViewStyle} from '../StyleSheet/StyleSheetTypes';
|
||||
import type {
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
ScrollViewProps,
|
||||
LayoutChangeEvent,
|
||||
View,
|
||||
ScrollResponderMixin,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
ScrollViewProps,
|
||||
} from '../Components/ScrollView/ScrollView';
|
||||
import type {View} from '../Components/View/View';
|
||||
|
||||
export interface ViewToken {
|
||||
item: any;
|
||||
+1939
-10
File diff suppressed because it is too large
Load Diff
+4
-6
@@ -8,15 +8,13 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
|
||||
import type {
|
||||
FocusEvent,
|
||||
LayoutEvent,
|
||||
} from 'react-native/Libraries/Types/CoreEventTypes';
|
||||
import type {ViewStyleProp} from '../StyleSheet/StyleSheet';
|
||||
import type {FocusEvent, LayoutEvent} from '../Types/CoreEventTypes';
|
||||
import type FillRateHelper from './FillRateHelper';
|
||||
import type {RenderItemType} from './VirtualizedListProps';
|
||||
|
||||
import {View, StyleSheet} from 'react-native';
|
||||
import View from '../Components/View/View';
|
||||
import StyleSheet from '../StyleSheet/StyleSheet';
|
||||
import {VirtualizedListCellContextProvider} from './VirtualizedListContext.js';
|
||||
import invariant from 'invariant';
|
||||
import * as React from 'react';
|
||||
@@ -4,15 +4,113 @@
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
* @flow strict-local
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import typeof VirtualizedList from './VirtualizedList';
|
||||
|
||||
import {typeof VirtualizedListContextResetter as VirtualizedListContextResetterType} from '@react-native/virtualized-lists';
|
||||
import * as React from 'react';
|
||||
import {useContext, useMemo} from 'react';
|
||||
|
||||
const VirtualizedListContextResetter: VirtualizedListContextResetterType =
|
||||
require('@react-native/virtualized-lists').VirtualizedListContextResetter;
|
||||
type Context = $ReadOnly<{
|
||||
cellKey: ?string,
|
||||
getScrollMetrics: () => {
|
||||
contentLength: number,
|
||||
dOffset: number,
|
||||
dt: number,
|
||||
offset: number,
|
||||
timestamp: number,
|
||||
velocity: number,
|
||||
visibleLength: number,
|
||||
zoomScale: number,
|
||||
},
|
||||
horizontal: ?boolean,
|
||||
getOutermostParentListRef: () => React.ElementRef<VirtualizedList>,
|
||||
registerAsNestedChild: ({
|
||||
cellKey: string,
|
||||
ref: React.ElementRef<VirtualizedList>,
|
||||
}) => void,
|
||||
unregisterAsNestedChild: ({
|
||||
ref: React.ElementRef<VirtualizedList>,
|
||||
}) => void,
|
||||
}>;
|
||||
|
||||
module.exports = {VirtualizedListContextResetter};
|
||||
export const VirtualizedListContext: React.Context<?Context> =
|
||||
React.createContext(null);
|
||||
if (__DEV__) {
|
||||
VirtualizedListContext.displayName = 'VirtualizedListContext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the context. Intended for use by portal-like components (e.g. Modal).
|
||||
*/
|
||||
export function VirtualizedListContextResetter({
|
||||
children,
|
||||
}: {
|
||||
children: React.Node,
|
||||
}): React.Node {
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={null}>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context with memoization. Intended to be used by `VirtualizedList`.
|
||||
*/
|
||||
export function VirtualizedListContextProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.Node,
|
||||
value: Context,
|
||||
}): React.Node {
|
||||
// Avoid setting a newly created context object if the values are identical.
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
cellKey: null,
|
||||
getScrollMetrics: value.getScrollMetrics,
|
||||
horizontal: value.horizontal,
|
||||
getOutermostParentListRef: value.getOutermostParentListRef,
|
||||
registerAsNestedChild: value.registerAsNestedChild,
|
||||
unregisterAsNestedChild: value.unregisterAsNestedChild,
|
||||
}),
|
||||
[
|
||||
value.getScrollMetrics,
|
||||
value.horizontal,
|
||||
value.getOutermostParentListRef,
|
||||
value.registerAsNestedChild,
|
||||
value.unregisterAsNestedChild,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={context}>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the `cellKey`. Intended to be used by `VirtualizedList` for each cell.
|
||||
*/
|
||||
export function VirtualizedListCellContextProvider({
|
||||
cellKey,
|
||||
children,
|
||||
}: {
|
||||
cellKey: string,
|
||||
children: React.Node,
|
||||
}): React.Node {
|
||||
// Avoid setting a newly created context object if the values are identical.
|
||||
const currContext = useContext(VirtualizedListContext);
|
||||
const context = useMemo(
|
||||
() => (currContext == null ? null : {...currContext, cellKey}),
|
||||
[currContext, cellKey],
|
||||
);
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={context}>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {typeof ScrollView} from 'react-native';
|
||||
import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
|
||||
import typeof ScrollView from '../Components/ScrollView/ScrollView';
|
||||
import type {ViewStyleProp} from '../StyleSheet/StyleSheet';
|
||||
import type {
|
||||
ViewabilityConfig,
|
||||
ViewabilityConfigCallbackPair,
|
||||
@@ -8,15 +8,610 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import type {ViewToken} from './ViewabilityHelper';
|
||||
|
||||
import {typeof VirtualizedSectionList as VirtualizedSectionListType} from '@react-native/virtualized-lists';
|
||||
import View from '../Components/View/View';
|
||||
import VirtualizedList from './VirtualizedList';
|
||||
import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils';
|
||||
import invariant from 'invariant';
|
||||
import * as React from 'react';
|
||||
|
||||
const VirtualizedSectionList: VirtualizedSectionListType =
|
||||
require('@react-native/virtualized-lists').VirtualizedSectionList;
|
||||
type Item = any;
|
||||
|
||||
export type {
|
||||
SectionBase,
|
||||
ScrollToLocationParamsType,
|
||||
} from '@react-native/virtualized-lists';
|
||||
module.exports = VirtualizedSectionList;
|
||||
export type SectionBase<SectionItemT> = {
|
||||
/**
|
||||
* The data for rendering items in this section.
|
||||
*/
|
||||
data: $ReadOnlyArray<SectionItemT>,
|
||||
/**
|
||||
* Optional key to keep track of section re-ordering. If you don't plan on re-ordering sections,
|
||||
* the array index will be used by default.
|
||||
*/
|
||||
key?: string,
|
||||
// Optional props will override list-wide props just for this section.
|
||||
renderItem?: ?(info: {
|
||||
item: SectionItemT,
|
||||
index: number,
|
||||
section: SectionBase<SectionItemT>,
|
||||
separators: {
|
||||
highlight: () => void,
|
||||
unhighlight: () => void,
|
||||
updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
|
||||
...
|
||||
},
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
ItemSeparatorComponent?: ?React.ComponentType<any>,
|
||||
keyExtractor?: (item: SectionItemT, index?: ?number) => string,
|
||||
...
|
||||
};
|
||||
|
||||
type RequiredProps<SectionT: SectionBase<any>> = {|
|
||||
sections: $ReadOnlyArray<SectionT>,
|
||||
|};
|
||||
|
||||
type OptionalProps<SectionT: SectionBase<any>> = {|
|
||||
/**
|
||||
* Default renderer for every item in every section.
|
||||
*/
|
||||
renderItem?: (info: {
|
||||
item: Item,
|
||||
index: number,
|
||||
section: SectionT,
|
||||
separators: {
|
||||
highlight: () => void,
|
||||
unhighlight: () => void,
|
||||
updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
|
||||
...
|
||||
},
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
/**
|
||||
* Rendered at the top of each section. These stick to the top of the `ScrollView` by default on
|
||||
* iOS. See `stickySectionHeadersEnabled`.
|
||||
*/
|
||||
renderSectionHeader?: ?(info: {
|
||||
section: SectionT,
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
/**
|
||||
* Rendered at the bottom of each section.
|
||||
*/
|
||||
renderSectionFooter?: ?(info: {
|
||||
section: SectionT,
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
/**
|
||||
* 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?: ?React.ComponentType<any>,
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
stickySectionHeadersEnabled?: boolean,
|
||||
onEndReached?: ?({distanceFromEnd: number, ...}) => void,
|
||||
|};
|
||||
|
||||
type VirtualizedListProps = React.ElementConfig<typeof VirtualizedList>;
|
||||
|
||||
export type Props<SectionT> = {|
|
||||
...RequiredProps<SectionT>,
|
||||
...OptionalProps<SectionT>,
|
||||
...$Diff<
|
||||
VirtualizedListProps,
|
||||
{
|
||||
renderItem: $PropertyType<VirtualizedListProps, 'renderItem'>,
|
||||
data: $PropertyType<VirtualizedListProps, 'data'>,
|
||||
...
|
||||
},
|
||||
>,
|
||||
|};
|
||||
export type ScrollToLocationParamsType = {|
|
||||
animated?: ?boolean,
|
||||
itemIndex: number,
|
||||
sectionIndex: number,
|
||||
viewOffset?: number,
|
||||
viewPosition?: number,
|
||||
|};
|
||||
|
||||
type State = {childProps: VirtualizedListProps, ...};
|
||||
|
||||
/**
|
||||
* Right now this just flattens everything into one list and uses VirtualizedList under the
|
||||
* hood. The only operation that might not scale well is concatting the data arrays of all the
|
||||
* sections when new props are received, which should be plenty fast for up to ~10,000 items.
|
||||
*/
|
||||
class VirtualizedSectionList<
|
||||
SectionT: SectionBase<any>,
|
||||
> extends React.PureComponent<Props<SectionT>, State> {
|
||||
scrollToLocation(params: ScrollToLocationParamsType) {
|
||||
let index = params.itemIndex;
|
||||
for (let i = 0; i < params.sectionIndex; i++) {
|
||||
index += this.props.getItemCount(this.props.sections[i].data) + 2;
|
||||
}
|
||||
let viewOffset = params.viewOffset || 0;
|
||||
if (this._listRef == null) {
|
||||
return;
|
||||
}
|
||||
if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) {
|
||||
const frame = this._listRef.__getFrameMetricsApprox(
|
||||
index - params.itemIndex,
|
||||
this._listRef.props,
|
||||
);
|
||||
viewOffset += frame.length;
|
||||
}
|
||||
const toIndexParams = {
|
||||
...params,
|
||||
viewOffset,
|
||||
index,
|
||||
};
|
||||
// $FlowFixMe[incompatible-use]
|
||||
this._listRef.scrollToIndex(toIndexParams);
|
||||
}
|
||||
|
||||
getListRef(): ?React.ElementRef<typeof VirtualizedList> {
|
||||
return this._listRef;
|
||||
}
|
||||
|
||||
render(): React.Node {
|
||||
const {
|
||||
ItemSeparatorComponent, // don't pass through, rendered with renderItem
|
||||
SectionSeparatorComponent,
|
||||
renderItem: _renderItem,
|
||||
renderSectionFooter,
|
||||
renderSectionHeader,
|
||||
sections: _sections,
|
||||
stickySectionHeadersEnabled,
|
||||
...passThroughProps
|
||||
} = this.props;
|
||||
|
||||
const listHeaderOffset = this.props.ListHeaderComponent ? 1 : 0;
|
||||
|
||||
const stickyHeaderIndices = this.props.stickySectionHeadersEnabled
|
||||
? ([]: Array<number>)
|
||||
: undefined;
|
||||
|
||||
let itemCount = 0;
|
||||
for (const section of this.props.sections) {
|
||||
// Track the section header indices
|
||||
if (stickyHeaderIndices != null) {
|
||||
stickyHeaderIndices.push(itemCount + listHeaderOffset);
|
||||
}
|
||||
|
||||
// Add two for the section header and footer.
|
||||
itemCount += 2;
|
||||
itemCount += this.props.getItemCount(section.data);
|
||||
}
|
||||
const renderItem = this._renderItem(itemCount);
|
||||
|
||||
return (
|
||||
<VirtualizedList
|
||||
{...passThroughProps}
|
||||
keyExtractor={this._keyExtractor}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
renderItem={renderItem}
|
||||
data={this.props.sections}
|
||||
getItem={(sections, index) =>
|
||||
this._getItem(this.props, sections, index)
|
||||
}
|
||||
getItemCount={() => itemCount}
|
||||
onViewableItemsChanged={
|
||||
this.props.onViewableItemsChanged
|
||||
? this._onViewableItemsChanged
|
||||
: undefined
|
||||
}
|
||||
ref={this._captureRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_getItem(
|
||||
props: Props<SectionT>,
|
||||
sections: ?$ReadOnlyArray<Item>,
|
||||
index: number,
|
||||
): ?Item {
|
||||
if (!sections) {
|
||||
return null;
|
||||
}
|
||||
let itemIdx = index - 1;
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const sectionData = section.data;
|
||||
const itemCount = props.getItemCount(sectionData);
|
||||
if (itemIdx === -1 || itemIdx === itemCount) {
|
||||
// We intend for there to be overflow by one on both ends of the list.
|
||||
// This will be for headers and footers. When returning a header or footer
|
||||
// item the section itself is the item.
|
||||
return section;
|
||||
} else if (itemIdx < itemCount) {
|
||||
// If we are in the bounds of the list's data then return the item.
|
||||
return props.getItem(sectionData, itemIdx);
|
||||
} else {
|
||||
itemIdx -= itemCount + 2; // Add two for the header and footer
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// $FlowFixMe[missing-local-annot]
|
||||
_keyExtractor = (item: Item, index: number) => {
|
||||
const info = this._subExtractor(index);
|
||||
return (info && info.key) || String(index);
|
||||
};
|
||||
|
||||
_subExtractor(index: number): ?{
|
||||
section: SectionT,
|
||||
// Key of the section or combined key for section + item
|
||||
key: string,
|
||||
// Relative index within the section
|
||||
index: ?number,
|
||||
// True if this is the section header
|
||||
header?: ?boolean,
|
||||
leadingItem?: ?Item,
|
||||
leadingSection?: ?SectionT,
|
||||
trailingItem?: ?Item,
|
||||
trailingSection?: ?SectionT,
|
||||
...
|
||||
} {
|
||||
let itemIndex = index;
|
||||
const {getItem, getItemCount, keyExtractor, sections} = this.props;
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const sectionData = section.data;
|
||||
const key = section.key || String(i);
|
||||
itemIndex -= 1; // The section adds an item for the header
|
||||
if (itemIndex >= getItemCount(sectionData) + 1) {
|
||||
itemIndex -= getItemCount(sectionData) + 1; // The section adds an item for the footer.
|
||||
} else if (itemIndex === -1) {
|
||||
return {
|
||||
section,
|
||||
key: key + ':header',
|
||||
index: null,
|
||||
header: true,
|
||||
trailingSection: sections[i + 1],
|
||||
};
|
||||
} else if (itemIndex === getItemCount(sectionData)) {
|
||||
return {
|
||||
section,
|
||||
key: key + ':footer',
|
||||
index: null,
|
||||
header: false,
|
||||
trailingSection: sections[i + 1],
|
||||
};
|
||||
} else {
|
||||
const extractor =
|
||||
section.keyExtractor || keyExtractor || defaultKeyExtractor;
|
||||
return {
|
||||
section,
|
||||
key:
|
||||
key + ':' + extractor(getItem(sectionData, itemIndex), itemIndex),
|
||||
index: itemIndex,
|
||||
leadingItem: getItem(sectionData, itemIndex - 1),
|
||||
leadingSection: sections[i - 1],
|
||||
trailingItem: getItem(sectionData, itemIndex + 1),
|
||||
trailingSection: sections[i + 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_convertViewable = (viewable: ViewToken): ?ViewToken => {
|
||||
invariant(viewable.index != null, 'Received a broken ViewToken');
|
||||
const info = this._subExtractor(viewable.index);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const keyExtractorWithNullableIndex = info.section.keyExtractor;
|
||||
const keyExtractorWithNonNullableIndex =
|
||||
this.props.keyExtractor || defaultKeyExtractor;
|
||||
const key =
|
||||
keyExtractorWithNullableIndex != null
|
||||
? keyExtractorWithNullableIndex(viewable.item, info.index)
|
||||
: keyExtractorWithNonNullableIndex(viewable.item, info.index ?? 0);
|
||||
|
||||
return {
|
||||
...viewable,
|
||||
index: info.index,
|
||||
key,
|
||||
section: info.section,
|
||||
};
|
||||
};
|
||||
|
||||
_onViewableItemsChanged = ({
|
||||
viewableItems,
|
||||
changed,
|
||||
}: {
|
||||
viewableItems: Array<ViewToken>,
|
||||
changed: Array<ViewToken>,
|
||||
...
|
||||
}) => {
|
||||
const onViewableItemsChanged = this.props.onViewableItemsChanged;
|
||||
if (onViewableItemsChanged != null) {
|
||||
onViewableItemsChanged({
|
||||
viewableItems: viewableItems
|
||||
.map(this._convertViewable, this)
|
||||
.filter(Boolean),
|
||||
changed: changed.map(this._convertViewable, this).filter(Boolean),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_renderItem =
|
||||
(listItemCount: number): $FlowFixMe =>
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
({item, index}: {item: Item, index: number, ...}) => {
|
||||
const info = this._subExtractor(index);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const infoIndex = info.index;
|
||||
if (infoIndex == null) {
|
||||
const {section} = info;
|
||||
if (info.header === true) {
|
||||
const {renderSectionHeader} = this.props;
|
||||
return renderSectionHeader ? renderSectionHeader({section}) : null;
|
||||
} else {
|
||||
const {renderSectionFooter} = this.props;
|
||||
return renderSectionFooter ? renderSectionFooter({section}) : null;
|
||||
}
|
||||
} else {
|
||||
const renderItem = info.section.renderItem || this.props.renderItem;
|
||||
const SeparatorComponent = this._getSeparatorComponent(
|
||||
index,
|
||||
info,
|
||||
listItemCount,
|
||||
);
|
||||
invariant(renderItem, 'no renderItem!');
|
||||
return (
|
||||
<ItemWithSeparator
|
||||
SeparatorComponent={SeparatorComponent}
|
||||
LeadingSeparatorComponent={
|
||||
infoIndex === 0 ? this.props.SectionSeparatorComponent : undefined
|
||||
}
|
||||
cellKey={info.key}
|
||||
index={infoIndex}
|
||||
item={item}
|
||||
leadingItem={info.leadingItem}
|
||||
leadingSection={info.leadingSection}
|
||||
prevCellKey={(this._subExtractor(index - 1) || {}).key}
|
||||
// Callback to provide updateHighlight for this item
|
||||
setSelfHighlightCallback={this._setUpdateHighlightFor}
|
||||
setSelfUpdatePropsCallback={this._setUpdatePropsFor}
|
||||
// Provide child ability to set highlight/updateProps for previous item using prevCellKey
|
||||
updateHighlightFor={this._updateHighlightFor}
|
||||
updatePropsFor={this._updatePropsFor}
|
||||
renderItem={renderItem}
|
||||
section={info.section}
|
||||
trailingItem={info.trailingItem}
|
||||
trailingSection={info.trailingSection}
|
||||
inverted={!!this.props.inverted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_updatePropsFor = (cellKey: string, value: any) => {
|
||||
const updateProps = this._updatePropsMap[cellKey];
|
||||
if (updateProps != null) {
|
||||
updateProps(value);
|
||||
}
|
||||
};
|
||||
|
||||
_updateHighlightFor = (cellKey: string, value: boolean) => {
|
||||
const updateHighlight = this._updateHighlightMap[cellKey];
|
||||
if (updateHighlight != null) {
|
||||
updateHighlight(value);
|
||||
}
|
||||
};
|
||||
|
||||
_setUpdateHighlightFor = (
|
||||
cellKey: string,
|
||||
updateHighlightFn: ?(boolean) => void,
|
||||
) => {
|
||||
if (updateHighlightFn != null) {
|
||||
this._updateHighlightMap[cellKey] = updateHighlightFn;
|
||||
} else {
|
||||
// $FlowFixMe[prop-missing]
|
||||
delete this._updateHighlightFor[cellKey];
|
||||
}
|
||||
};
|
||||
|
||||
_setUpdatePropsFor = (cellKey: string, updatePropsFn: ?(boolean) => void) => {
|
||||
if (updatePropsFn != null) {
|
||||
this._updatePropsMap[cellKey] = updatePropsFn;
|
||||
} else {
|
||||
delete this._updatePropsMap[cellKey];
|
||||
}
|
||||
};
|
||||
|
||||
_getSeparatorComponent(
|
||||
index: number,
|
||||
info?: ?Object,
|
||||
listItemCount: number,
|
||||
): ?React.ComponentType<any> {
|
||||
info = info || this._subExtractor(index);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const ItemSeparatorComponent =
|
||||
info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent;
|
||||
const {SectionSeparatorComponent} = this.props;
|
||||
const isLastItemInList = index === listItemCount - 1;
|
||||
const isLastItemInSection =
|
||||
info.index === this.props.getItemCount(info.section.data) - 1;
|
||||
if (SectionSeparatorComponent && isLastItemInSection) {
|
||||
return SectionSeparatorComponent;
|
||||
}
|
||||
if (ItemSeparatorComponent && !isLastItemInSection && !isLastItemInList) {
|
||||
return ItemSeparatorComponent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_updateHighlightMap: {[string]: (boolean) => void} = {};
|
||||
_updatePropsMap: {[string]: void | (boolean => void)} = {};
|
||||
_listRef: ?React.ElementRef<typeof VirtualizedList>;
|
||||
_captureRef = (ref: null | React$ElementRef<Class<VirtualizedList>>) => {
|
||||
this._listRef = ref;
|
||||
};
|
||||
}
|
||||
|
||||
type ItemWithSeparatorCommonProps = $ReadOnly<{|
|
||||
leadingItem: ?Item,
|
||||
leadingSection: ?Object,
|
||||
section: Object,
|
||||
trailingItem: ?Item,
|
||||
trailingSection: ?Object,
|
||||
|}>;
|
||||
|
||||
type ItemWithSeparatorProps = $ReadOnly<{|
|
||||
...ItemWithSeparatorCommonProps,
|
||||
LeadingSeparatorComponent: ?React.ComponentType<any>,
|
||||
SeparatorComponent: ?React.ComponentType<any>,
|
||||
cellKey: string,
|
||||
index: number,
|
||||
item: Item,
|
||||
setSelfHighlightCallback: (
|
||||
cellKey: string,
|
||||
updateFn: ?(boolean) => void,
|
||||
) => void,
|
||||
setSelfUpdatePropsCallback: (
|
||||
cellKey: string,
|
||||
updateFn: ?(boolean) => void,
|
||||
) => void,
|
||||
prevCellKey?: ?string,
|
||||
updateHighlightFor: (prevCellKey: string, value: boolean) => void,
|
||||
updatePropsFor: (prevCellKey: string, value: Object) => void,
|
||||
renderItem: Function,
|
||||
inverted: boolean,
|
||||
|}>;
|
||||
|
||||
function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node {
|
||||
const {
|
||||
LeadingSeparatorComponent,
|
||||
// this is the trailing separator and is associated with this item
|
||||
SeparatorComponent,
|
||||
cellKey,
|
||||
prevCellKey,
|
||||
setSelfHighlightCallback,
|
||||
updateHighlightFor,
|
||||
setSelfUpdatePropsCallback,
|
||||
updatePropsFor,
|
||||
item,
|
||||
index,
|
||||
section,
|
||||
inverted,
|
||||
} = props;
|
||||
|
||||
const [leadingSeparatorHiglighted, setLeadingSeparatorHighlighted] =
|
||||
React.useState(false);
|
||||
|
||||
const [separatorHighlighted, setSeparatorHighlighted] = React.useState(false);
|
||||
|
||||
const [leadingSeparatorProps, setLeadingSeparatorProps] = React.useState({
|
||||
leadingItem: props.leadingItem,
|
||||
leadingSection: props.leadingSection,
|
||||
section: props.section,
|
||||
trailingItem: props.item,
|
||||
trailingSection: props.trailingSection,
|
||||
});
|
||||
const [separatorProps, setSeparatorProps] = React.useState({
|
||||
leadingItem: props.item,
|
||||
leadingSection: props.leadingSection,
|
||||
section: props.section,
|
||||
trailingItem: props.trailingItem,
|
||||
trailingSection: props.trailingSection,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelfHighlightCallback(cellKey, setSeparatorHighlighted);
|
||||
// $FlowFixMe[incompatible-call]
|
||||
setSelfUpdatePropsCallback(cellKey, setSeparatorProps);
|
||||
|
||||
return () => {
|
||||
setSelfUpdatePropsCallback(cellKey, null);
|
||||
setSelfHighlightCallback(cellKey, null);
|
||||
};
|
||||
}, [
|
||||
cellKey,
|
||||
setSelfHighlightCallback,
|
||||
setSeparatorProps,
|
||||
setSelfUpdatePropsCallback,
|
||||
]);
|
||||
|
||||
const separators = {
|
||||
highlight: () => {
|
||||
setLeadingSeparatorHighlighted(true);
|
||||
setSeparatorHighlighted(true);
|
||||
if (prevCellKey != null) {
|
||||
updateHighlightFor(prevCellKey, true);
|
||||
}
|
||||
},
|
||||
unhighlight: () => {
|
||||
setLeadingSeparatorHighlighted(false);
|
||||
setSeparatorHighlighted(false);
|
||||
if (prevCellKey != null) {
|
||||
updateHighlightFor(prevCellKey, false);
|
||||
}
|
||||
},
|
||||
updateProps: (
|
||||
select: 'leading' | 'trailing',
|
||||
newProps: $Shape<ItemWithSeparatorCommonProps>,
|
||||
) => {
|
||||
if (select === 'leading') {
|
||||
if (LeadingSeparatorComponent != null) {
|
||||
setLeadingSeparatorProps({...leadingSeparatorProps, ...newProps});
|
||||
} else if (prevCellKey != null) {
|
||||
// update the previous item's separator
|
||||
updatePropsFor(prevCellKey, {...leadingSeparatorProps, ...newProps});
|
||||
}
|
||||
} else if (select === 'trailing' && SeparatorComponent != null) {
|
||||
setSeparatorProps({...separatorProps, ...newProps});
|
||||
}
|
||||
},
|
||||
};
|
||||
const element = props.renderItem({
|
||||
item,
|
||||
index,
|
||||
section,
|
||||
separators,
|
||||
});
|
||||
const leadingSeparator = LeadingSeparatorComponent != null && (
|
||||
<LeadingSeparatorComponent
|
||||
highlighted={leadingSeparatorHiglighted}
|
||||
{...leadingSeparatorProps}
|
||||
/>
|
||||
);
|
||||
const separator = SeparatorComponent != null && (
|
||||
<SeparatorComponent
|
||||
highlighted={separatorHighlighted}
|
||||
{...separatorProps}
|
||||
/>
|
||||
);
|
||||
return leadingSeparator || separator ? (
|
||||
<View>
|
||||
{inverted === false ? leadingSeparator : separator}
|
||||
{element}
|
||||
{inverted === false ? separator : leadingSeparator}
|
||||
</View>
|
||||
) : (
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
/* $FlowFixMe[class-object-subtyping] added when improving typing for this
|
||||
* parameters */
|
||||
// $FlowFixMe[method-unbinding]
|
||||
module.exports = (VirtualizedSectionList: React.AbstractComponent<
|
||||
React.ElementConfig<typeof VirtualizedSectionList>,
|
||||
$ReadOnly<{
|
||||
getListRef: () => ?React.ElementRef<typeof VirtualizedList>,
|
||||
scrollToLocation: (params: ScrollToLocationParamsType) => void,
|
||||
...
|
||||
}>,
|
||||
>);
|
||||
|
||||
@@ -13,11 +13,11 @@ import type {RootTag} from '../ReactNative/RootTag';
|
||||
import type {DirectEventHandler} from '../Types/CodegenTypes';
|
||||
|
||||
import NativeEventEmitter from '../EventEmitter/NativeEventEmitter';
|
||||
import {VirtualizedListContextResetter} from '../Lists/VirtualizedListContext.js';
|
||||
import {type EventSubscription} from '../vendor/emitter/EventEmitter';
|
||||
import ModalInjection from './ModalInjection';
|
||||
import NativeModalManager from './NativeModalManager';
|
||||
import RCTModalHostView from './RCTModalHostViewNativeComponent';
|
||||
import {VirtualizedListContextResetter} from '@react-native/virtualized-lists';
|
||||
|
||||
const ScrollView = require('../Components/ScrollView/ScrollView');
|
||||
const View = require('../Components/View/View');
|
||||
|
||||
@@ -15,8 +15,8 @@ import type {ReactTestRenderer as ReactTestRendererType} from 'react-test-render
|
||||
const Switch = require('../Components/Switch/Switch').default;
|
||||
const TextInput = require('../Components/TextInput/TextInput');
|
||||
const View = require('../Components/View/View');
|
||||
const VirtualizedList = require('../Lists/VirtualizedList').default;
|
||||
const Text = require('../Text/Text');
|
||||
const {VirtualizedList} = require('@react-native/virtualized-lists');
|
||||
const React = require('react');
|
||||
const ShallowRenderer = require('react-shallow-renderer');
|
||||
const ReactTestRenderer = require('react-test-renderer');
|
||||
|
||||
@@ -191,7 +191,7 @@ module.exports = {
|
||||
return require('./Libraries/Components/View/View');
|
||||
},
|
||||
get VirtualizedList(): VirtualizedList {
|
||||
return require('./Libraries/Lists/VirtualizedList');
|
||||
return require('./Libraries/Lists/VirtualizedList').default;
|
||||
},
|
||||
get VirtualizedSectionList(): VirtualizedSectionList {
|
||||
return require('./Libraries/Lists/VirtualizedSectionList');
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"@react-native/gradle-plugin": "^0.72.2",
|
||||
"@react-native/js-polyfills": "^0.72.0",
|
||||
"@react-native/normalize-colors": "^0.72.0",
|
||||
"@react-native/virtualized-lists": "0.72.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"anser": "^1.4.9",
|
||||
"base64-js": "^1.1.2",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type {AnimatedComponentType} from 'react-native/Libraries/Animated/createAnimatedComponent';
|
||||
import typeof FlatListType from 'react-native/Libraries/Lists/FlatList';
|
||||
import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList';
|
||||
import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedListProps';
|
||||
|
||||
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
import type {ViewToken} from 'react-native/Libraries/Lists/ViewabilityHelper';
|
||||
import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList';
|
||||
import type {ViewToken} from '../../../../../Libraries/Lists/ViewabilityHelper';
|
||||
import type {RenderItemProps} from '../../../../../Libraries/Lists/VirtualizedListProps';
|
||||
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
|
||||
|
||||
import RNTesterPage from '../../components/RNTesterPage';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import type {ViewToken} from 'react-native/Libraries/Lists/ViewabilityHelper';
|
||||
import type {ViewToken} from '../../../../../Libraries/Lists/ViewabilityHelper';
|
||||
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
|
||||
|
||||
import BaseFlatListExample from './BaseFlatListExample';
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import type {FrameMetricProps} from './VirtualizedListProps';
|
||||
|
||||
export type FillRateInfo = Info;
|
||||
|
||||
class Info {
|
||||
any_blank_count: number = 0;
|
||||
any_blank_ms: number = 0;
|
||||
any_blank_speed_sum: number = 0;
|
||||
mostly_blank_count: number = 0;
|
||||
mostly_blank_ms: number = 0;
|
||||
pixels_blank: number = 0;
|
||||
pixels_sampled: number = 0;
|
||||
pixels_scrolled: number = 0;
|
||||
total_time_spent: number = 0;
|
||||
sample_count: number = 0;
|
||||
}
|
||||
|
||||
type FrameMetrics = {
|
||||
inLayout?: boolean,
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
};
|
||||
|
||||
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.
|
||||
* By default the sampling rate is set to zero and this will do nothing. If you want to collect
|
||||
* samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`.
|
||||
*
|
||||
* Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with
|
||||
* `SceneTracker.getActiveScene` to determine the context of the events.
|
||||
*/
|
||||
class FillRateHelper {
|
||||
_anyBlankStartTime: ?number = null;
|
||||
_enabled = false;
|
||||
_getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics;
|
||||
_info: Info = new Info();
|
||||
_mostlyBlankStartTime: ?number = null;
|
||||
_samplesStartTime: ?number = null;
|
||||
|
||||
static addListener(callback: FillRateInfo => void): {
|
||||
remove: () => void,
|
||||
...
|
||||
} {
|
||||
if (_sampleRate === null) {
|
||||
console.warn('Call `FillRateHelper.setSampleRate` before `addListener`.');
|
||||
}
|
||||
_listeners.push(callback);
|
||||
return {
|
||||
remove: () => {
|
||||
_listeners = _listeners.filter(listener => callback !== listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static setSampleRate(sampleRate: number) {
|
||||
_sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
static setMinSampleCount(minSampleCount: number) {
|
||||
_minSampleCount = minSampleCount;
|
||||
}
|
||||
|
||||
constructor(
|
||||
getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics,
|
||||
) {
|
||||
this._getFrameMetrics = getFrameMetrics;
|
||||
this._enabled = (_sampleRate || 0) > Math.random();
|
||||
this._resetData();
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this._enabled && this._samplesStartTime == null) {
|
||||
DEBUG && console.debug('FillRateHelper: activate');
|
||||
this._samplesStartTime = global.performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
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 = global.performance.now() - 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) {
|
||||
// $FlowFixMe[prop-missing]
|
||||
derived[key] = Math.round(1000 * derived[key]) / 1000;
|
||||
}
|
||||
console.debug('FillRateHelper deactivateAndFlush: ', {derived, info});
|
||||
}
|
||||
_listeners.forEach(listener => listener(info));
|
||||
this._resetData();
|
||||
}
|
||||
|
||||
computeBlankness(
|
||||
props: {
|
||||
...FrameMetricProps,
|
||||
initialNumToRender?: ?number,
|
||||
...
|
||||
},
|
||||
cellsAroundViewport: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
scrollMetrics: {
|
||||
dOffset: number,
|
||||
offset: number,
|
||||
velocity: number,
|
||||
visibleLength: number,
|
||||
...
|
||||
},
|
||||
): number {
|
||||
if (
|
||||
!this._enabled ||
|
||||
props.getItemCount(props.data) === 0 ||
|
||||
cellsAroundViewport.last < cellsAroundViewport.first ||
|
||||
this._samplesStartTime == null
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
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 = global.performance.now();
|
||||
if (this._anyBlankStartTime != null) {
|
||||
this._info.any_blank_ms += now - this._anyBlankStartTime;
|
||||
}
|
||||
this._anyBlankStartTime = null;
|
||||
if (this._mostlyBlankStartTime != null) {
|
||||
this._info.mostly_blank_ms += now - this._mostlyBlankStartTime;
|
||||
}
|
||||
this._mostlyBlankStartTime = null;
|
||||
|
||||
let blankTop = 0;
|
||||
let first = cellsAroundViewport.first;
|
||||
let firstFrame = this._getFrameMetrics(first, props);
|
||||
while (
|
||||
first <= cellsAroundViewport.last &&
|
||||
(!firstFrame || !firstFrame.inLayout)
|
||||
) {
|
||||
firstFrame = this._getFrameMetrics(first, props);
|
||||
first++;
|
||||
}
|
||||
// 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;
|
||||
let last = cellsAroundViewport.last;
|
||||
let lastFrame = this._getFrameMetrics(last, props);
|
||||
while (
|
||||
last >= cellsAroundViewport.first &&
|
||||
(!lastFrame || !lastFrame.inLayout)
|
||||
) {
|
||||
lastFrame = this._getFrameMetrics(last, props);
|
||||
last--;
|
||||
}
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
const pixels_blank = Math.round(blankTop + blankBottom);
|
||||
const blankness = pixels_blank / visibleLength;
|
||||
if (blankness > 0) {
|
||||
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++;
|
||||
}
|
||||
} else if (scrollSpeed < 0.01 || Math.abs(dOffset) < 1) {
|
||||
this.deactivateAndFlush();
|
||||
}
|
||||
return blankness;
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
_resetData() {
|
||||
this._anyBlankStartTime = null;
|
||||
this._info = new Info();
|
||||
this._mostlyBlankStartTime = null;
|
||||
this._samplesStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FillRateHelper;
|
||||
@@ -1,360 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import type {FrameMetricProps} from './VirtualizedListProps';
|
||||
|
||||
const invariant = require('invariant');
|
||||
|
||||
export type ViewToken = {
|
||||
item: any,
|
||||
key: string,
|
||||
index: ?number,
|
||||
isViewable: boolean,
|
||||
section?: any,
|
||||
...
|
||||
};
|
||||
|
||||
export type ViewabilityConfigCallbackPair = {
|
||||
viewabilityConfig: ViewabilityConfig,
|
||||
onViewableItemsChanged: (info: {
|
||||
viewableItems: Array<ViewToken>,
|
||||
changed: Array<ViewToken>,
|
||||
...
|
||||
}) => void,
|
||||
...
|
||||
};
|
||||
|
||||
export type ViewabilityConfig = {|
|
||||
/**
|
||||
* Minimum amount of time (in milliseconds) that an item must be physically viewable before the
|
||||
* viewability callback will be fired. A high number means that scrolling through content without
|
||||
* stopping will not mark the content as viewable.
|
||||
*/
|
||||
minimumViewTime?: number,
|
||||
|
||||
/**
|
||||
* Percent of viewport that must be covered for a partially occluded item to count as
|
||||
* "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means
|
||||
* that a single pixel in the viewport makes the item viewable, and a value of 100 means that
|
||||
* an item must be either entirely visible or cover the entire viewport to count as viewable.
|
||||
*/
|
||||
viewAreaCoveragePercentThreshold?: number,
|
||||
|
||||
/**
|
||||
* Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible,
|
||||
* rather than the fraction of the viewable area it covers.
|
||||
*/
|
||||
itemVisiblePercentThreshold?: number,
|
||||
|
||||
/**
|
||||
* Nothing is considered viewable until the user scrolls or `recordInteraction` is called after
|
||||
* render.
|
||||
*/
|
||||
waitForInteraction?: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A Utility class for calculating viewable items based on current metrics like scroll position and
|
||||
* layout.
|
||||
*
|
||||
* An item is said to be in a "viewable" state when any of the following
|
||||
* is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction`
|
||||
* is true):
|
||||
*
|
||||
* - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
|
||||
* visible in the view area >= `itemVisiblePercentThreshold`.
|
||||
* - Entirely visible on screen
|
||||
*/
|
||||
class ViewabilityHelper {
|
||||
_config: ViewabilityConfig;
|
||||
_hasInteracted: boolean = false;
|
||||
_timers: Set<number> = new Set();
|
||||
_viewableIndices: Array<number> = [];
|
||||
_viewableItems: Map<string, ViewToken> = new Map();
|
||||
|
||||
constructor(
|
||||
config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0},
|
||||
) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup, e.g. on unmount. Clears any pending timers.
|
||||
*/
|
||||
dispose() {
|
||||
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
|
||||
* comment suppresses an error found when Flow v0.63 was deployed. To see
|
||||
* the error delete this comment and run Flow. */
|
||||
this._timers.forEach(clearTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which items are viewable based on the current metrics and config.
|
||||
*/
|
||||
computeViewableItems(
|
||||
props: FrameMetricProps,
|
||||
scrollOffset: number,
|
||||
viewportHeight: number,
|
||||
getFrameMetrics: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => ?{
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
// Optional optimization to reduce the scan size
|
||||
renderRange?: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
): Array<number> {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
const {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} =
|
||||
this._config;
|
||||
const viewAreaMode = viewAreaCoveragePercentThreshold != null;
|
||||
const viewablePercentThreshold = viewAreaMode
|
||||
? viewAreaCoveragePercentThreshold
|
||||
: itemVisiblePercentThreshold;
|
||||
invariant(
|
||||
viewablePercentThreshold != null &&
|
||||
(itemVisiblePercentThreshold != null) !==
|
||||
(viewAreaCoveragePercentThreshold != null),
|
||||
'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold',
|
||||
);
|
||||
const viewableIndices = [];
|
||||
if (itemCount === 0) {
|
||||
return viewableIndices;
|
||||
}
|
||||
let firstVisible = -1;
|
||||
const {first, last} = renderRange || {first: 0, last: itemCount - 1};
|
||||
if (last >= itemCount) {
|
||||
console.warn(
|
||||
'Invalid render range computing viewability ' +
|
||||
JSON.stringify({renderRange, itemCount}),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
for (let idx = first; idx <= last; idx++) {
|
||||
const metrics = getFrameMetrics(idx, props);
|
||||
if (!metrics) {
|
||||
continue;
|
||||
}
|
||||
const top = metrics.offset - scrollOffset;
|
||||
const bottom = top + metrics.length;
|
||||
if (top < viewportHeight && bottom > 0) {
|
||||
firstVisible = idx;
|
||||
if (
|
||||
_isViewable(
|
||||
viewAreaMode,
|
||||
viewablePercentThreshold,
|
||||
top,
|
||||
bottom,
|
||||
viewportHeight,
|
||||
metrics.length,
|
||||
)
|
||||
) {
|
||||
viewableIndices.push(idx);
|
||||
}
|
||||
} else if (firstVisible >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return viewableIndices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Figures out which items are viewable and how that has changed from before and calls
|
||||
* `onViewableItemsChanged` as appropriate.
|
||||
*/
|
||||
onUpdate(
|
||||
props: FrameMetricProps,
|
||||
scrollOffset: number,
|
||||
viewportHeight: number,
|
||||
getFrameMetrics: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => ?{
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
createViewToken: (
|
||||
index: number,
|
||||
isViewable: boolean,
|
||||
props: FrameMetricProps,
|
||||
) => ViewToken,
|
||||
onViewableItemsChanged: ({
|
||||
viewableItems: Array<ViewToken>,
|
||||
changed: Array<ViewToken>,
|
||||
...
|
||||
}) => void,
|
||||
// Optional optimization to reduce the scan size
|
||||
renderRange?: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
): void {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
if (
|
||||
(this._config.waitForInteraction && !this._hasInteracted) ||
|
||||
itemCount === 0 ||
|
||||
!getFrameMetrics(0, props)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let viewableIndices: Array<number> = [];
|
||||
if (itemCount) {
|
||||
viewableIndices = this.computeViewableItems(
|
||||
props,
|
||||
scrollOffset,
|
||||
viewportHeight,
|
||||
getFrameMetrics,
|
||||
renderRange,
|
||||
);
|
||||
}
|
||||
if (
|
||||
this._viewableIndices.length === viewableIndices.length &&
|
||||
this._viewableIndices.every((v, ii) => v === viewableIndices[ii])
|
||||
) {
|
||||
// We might get a lot of scroll events where visibility doesn't change and we don't want to do
|
||||
// extra work in those cases.
|
||||
return;
|
||||
}
|
||||
this._viewableIndices = viewableIndices;
|
||||
if (this._config.minimumViewTime) {
|
||||
const handle: TimeoutID = setTimeout(() => {
|
||||
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
|
||||
* comment suppresses an error found when Flow v0.63 was deployed. To
|
||||
* see the error delete this comment and run Flow. */
|
||||
this._timers.delete(handle);
|
||||
this._onUpdateSync(
|
||||
props,
|
||||
viewableIndices,
|
||||
onViewableItemsChanged,
|
||||
createViewToken,
|
||||
);
|
||||
}, this._config.minimumViewTime);
|
||||
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
|
||||
* comment suppresses an error found when Flow v0.63 was deployed. To see
|
||||
* the error delete this comment and run Flow. */
|
||||
this._timers.add(handle);
|
||||
} else {
|
||||
this._onUpdateSync(
|
||||
props,
|
||||
viewableIndices,
|
||||
onViewableItemsChanged,
|
||||
createViewToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* clean-up cached _viewableIndices to evaluate changed items on next update
|
||||
*/
|
||||
resetViewableIndices() {
|
||||
this._viewableIndices = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that an interaction has happened even if there has been no scroll.
|
||||
*/
|
||||
recordInteraction() {
|
||||
this._hasInteracted = true;
|
||||
}
|
||||
|
||||
_onUpdateSync(
|
||||
props: FrameMetricProps,
|
||||
viewableIndicesToCheck: Array<number>,
|
||||
onViewableItemsChanged: ({
|
||||
changed: Array<ViewToken>,
|
||||
viewableItems: Array<ViewToken>,
|
||||
...
|
||||
}) => void,
|
||||
createViewToken: (
|
||||
index: number,
|
||||
isViewable: boolean,
|
||||
props: FrameMetricProps,
|
||||
) => ViewToken,
|
||||
) {
|
||||
// Filter out indices that have gone out of view since this call was scheduled.
|
||||
viewableIndicesToCheck = viewableIndicesToCheck.filter(ii =>
|
||||
this._viewableIndices.includes(ii),
|
||||
);
|
||||
const prevItems = this._viewableItems;
|
||||
const nextItems = new Map(
|
||||
viewableIndicesToCheck.map(ii => {
|
||||
const viewable = createViewToken(ii, true, props);
|
||||
return [viewable.key, viewable];
|
||||
}),
|
||||
);
|
||||
|
||||
const changed = [];
|
||||
for (const [key, viewable] of nextItems) {
|
||||
if (!prevItems.has(key)) {
|
||||
changed.push(viewable);
|
||||
}
|
||||
}
|
||||
for (const [key, viewable] of prevItems) {
|
||||
if (!nextItems.has(key)) {
|
||||
changed.push({...viewable, isViewable: false});
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
this._viewableItems = nextItems;
|
||||
onViewableItemsChanged({
|
||||
viewableItems: Array.from(nextItems.values()),
|
||||
changed,
|
||||
viewabilityConfig: this._config,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _isViewable(
|
||||
viewAreaMode: boolean,
|
||||
viewablePercentThreshold: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
viewportHeight: number,
|
||||
itemLength: number,
|
||||
): boolean {
|
||||
if (_isEntirelyVisible(top, bottom, viewportHeight)) {
|
||||
return true;
|
||||
} else {
|
||||
const pixels = _getPixelsVisible(top, bottom, viewportHeight);
|
||||
const percent =
|
||||
100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
|
||||
return percent >= viewablePercentThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
function _getPixelsVisible(
|
||||
top: number,
|
||||
bottom: number,
|
||||
viewportHeight: number,
|
||||
): number {
|
||||
const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
|
||||
return Math.max(0, visibleHeight);
|
||||
}
|
||||
|
||||
function _isEntirelyVisible(
|
||||
top: number,
|
||||
bottom: number,
|
||||
viewportHeight: number,
|
||||
): boolean {
|
||||
return top >= 0 && bottom <= viewportHeight && bottom > top;
|
||||
}
|
||||
|
||||
module.exports = ViewabilityHelper;
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
* @format
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import type {FrameMetricProps} from './VirtualizedListProps';
|
||||
|
||||
/**
|
||||
* Used to find the indices of the frames that overlap the given offsets. Useful for finding the
|
||||
* items that bound different windows of content, such as the visible area or the buffered overscan
|
||||
* area.
|
||||
*/
|
||||
export function elementsThatOverlapOffsets(
|
||||
offsets: Array<number>,
|
||||
props: FrameMetricProps,
|
||||
getFrameMetrics: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => {
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
zoomScale: number = 1,
|
||||
): Array<number> {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
const result = [];
|
||||
for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) {
|
||||
const currentOffset = offsets[offsetIndex];
|
||||
let left = 0;
|
||||
let right = itemCount - 1;
|
||||
|
||||
while (left <= right) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = left + ((right - left) >>> 1);
|
||||
const frame = getFrameMetrics(mid, props);
|
||||
const scaledOffsetStart = frame.offset * zoomScale;
|
||||
const scaledOffsetEnd = (frame.offset + frame.length) * zoomScale;
|
||||
|
||||
// We want the first frame that contains the offset, with inclusive bounds. Thus, for the
|
||||
// first frame the scaledOffsetStart is inclusive, while for other frames it is exclusive.
|
||||
if (
|
||||
(mid === 0 && currentOffset < scaledOffsetStart) ||
|
||||
(mid !== 0 && currentOffset <= scaledOffsetStart)
|
||||
) {
|
||||
right = mid - 1;
|
||||
} else if (currentOffset > scaledOffsetEnd) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
result[offsetIndex] = mid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the number of elements in the `next` range that are new compared to the `prev` range.
|
||||
* Handy for calculating how many new items will be rendered when the render window changes so we
|
||||
* can restrict the number of new items render at once so that content can appear on the screen
|
||||
* faster.
|
||||
*/
|
||||
export function newRangeCount(
|
||||
prev: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
next: {
|
||||
first: number,
|
||||
last: number,
|
||||
...
|
||||
},
|
||||
): number {
|
||||
return (
|
||||
next.last -
|
||||
next.first +
|
||||
1 -
|
||||
Math.max(
|
||||
0,
|
||||
1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom logic for determining which items should be rendered given the current frame and scroll
|
||||
* metrics, as well as the previous render state. The algorithm may evolve over time, but generally
|
||||
* prioritizes the visible area first, then expands that with overscan regions ahead and behind,
|
||||
* biased in the direction of scroll.
|
||||
*/
|
||||
export function computeWindowedRenderLimits(
|
||||
props: FrameMetricProps,
|
||||
maxToRenderPerBatch: number,
|
||||
windowSize: number,
|
||||
prev: {
|
||||
first: number,
|
||||
last: number,
|
||||
},
|
||||
getFrameMetricsApprox: (
|
||||
index: number,
|
||||
props: FrameMetricProps,
|
||||
) => {
|
||||
length: number,
|
||||
offset: number,
|
||||
...
|
||||
},
|
||||
scrollMetrics: {
|
||||
dt: number,
|
||||
offset: number,
|
||||
velocity: number,
|
||||
visibleLength: number,
|
||||
zoomScale: number,
|
||||
...
|
||||
},
|
||||
): {
|
||||
first: number,
|
||||
last: number,
|
||||
} {
|
||||
const itemCount = props.getItemCount(props.data);
|
||||
if (itemCount === 0) {
|
||||
return {first: 0, last: -1};
|
||||
}
|
||||
const {offset, velocity, visibleLength, zoomScale = 1} = scrollMetrics;
|
||||
|
||||
// Start with visible area, then compute maximum overscan region by expanding from there, biased
|
||||
// in the direction of scroll. Total overscan area is capped, which should cap memory consumption
|
||||
// too.
|
||||
const visibleBegin = Math.max(0, offset);
|
||||
const visibleEnd = visibleBegin + visibleLength;
|
||||
const overscanLength = (windowSize - 1) * visibleLength;
|
||||
|
||||
// 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);
|
||||
|
||||
const lastItemOffset =
|
||||
getFrameMetricsApprox(itemCount - 1, props).offset * zoomScale;
|
||||
if (lastItemOffset < overscanBegin) {
|
||||
// Entire list is before our overscan window
|
||||
return {
|
||||
first: Math.max(0, itemCount - 1 - maxToRenderPerBatch),
|
||||
last: itemCount - 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the indices that correspond to the items at the render boundaries we're targeting.
|
||||
let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets(
|
||||
[overscanBegin, visibleBegin, visibleEnd, overscanEnd],
|
||||
props,
|
||||
getFrameMetricsApprox,
|
||||
zoomScale,
|
||||
);
|
||||
overscanFirst = overscanFirst == null ? 0 : overscanFirst;
|
||||
first = first == null ? Math.max(0, overscanFirst) : first;
|
||||
overscanLast = overscanLast == null ? itemCount - 1 : overscanLast;
|
||||
last =
|
||||
last == null
|
||||
? Math.min(overscanLast, first + maxToRenderPerBatch - 1)
|
||||
: last;
|
||||
const visible = {first, last};
|
||||
|
||||
// We want to limit the number of new cells we're rendering per batch so that we can fill the
|
||||
// content on the screen quickly. If we rendered the entire overscan window at once, the user
|
||||
// could be staring at white space for a long time waiting for a bunch of offscreen content to
|
||||
// render.
|
||||
let newCellCount = newRangeCount(prev, visible);
|
||||
|
||||
while (true) {
|
||||
if (first <= overscanFirst && last >= overscanLast) {
|
||||
// If we fill the entire overscan range, we're done.
|
||||
break;
|
||||
}
|
||||
const maxNewCells = newCellCount >= maxToRenderPerBatch;
|
||||
const firstWillAddMore = first <= prev.first || first > prev.last;
|
||||
const firstShouldIncrement =
|
||||
first > overscanFirst && (!maxNewCells || !firstWillAddMore);
|
||||
const lastWillAddMore = last >= prev.last || last < prev.first;
|
||||
const lastShouldIncrement =
|
||||
last < overscanLast && (!maxNewCells || !lastWillAddMore);
|
||||
if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) {
|
||||
// We only want to stop if we've hit maxNewCells AND we cannot increment first or last
|
||||
// without rendering new items. This let's us preserve as many already rendered items as
|
||||
// possible, reducing render churn and keeping the rendered overscan range as large as
|
||||
// possible.
|
||||
break;
|
||||
}
|
||||
if (
|
||||
firstShouldIncrement &&
|
||||
!(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore)
|
||||
) {
|
||||
if (firstWillAddMore) {
|
||||
newCellCount++;
|
||||
}
|
||||
first--;
|
||||
}
|
||||
if (
|
||||
lastShouldIncrement &&
|
||||
!(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore)
|
||||
) {
|
||||
if (lastWillAddMore) {
|
||||
newCellCount++;
|
||||
}
|
||||
last++;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!(
|
||||
last >= first &&
|
||||
first >= 0 &&
|
||||
last < itemCount &&
|
||||
first >= overscanFirst &&
|
||||
last <= overscanLast &&
|
||||
first <= visible.first &&
|
||||
last >= visible.last
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Bad window calculation ' +
|
||||
JSON.stringify({
|
||||
first,
|
||||
last,
|
||||
itemCount,
|
||||
overscanFirst,
|
||||
overscanLast,
|
||||
visible,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {first, last};
|
||||
}
|
||||
|
||||
export function keyExtractor(item: any, index: number): string {
|
||||
if (typeof item === 'object' && item?.key != null) {
|
||||
return item.key;
|
||||
}
|
||||
if (typeof item === 'object' && item?.id != null) {
|
||||
return item.id;
|
||||
}
|
||||
return String(index);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow strict-local
|
||||
* @format
|
||||
*/
|
||||
|
||||
import typeof VirtualizedList from './VirtualizedList';
|
||||
|
||||
import * as React from 'react';
|
||||
import {useContext, useMemo} from 'react';
|
||||
|
||||
type Context = $ReadOnly<{
|
||||
cellKey: ?string,
|
||||
getScrollMetrics: () => {
|
||||
contentLength: number,
|
||||
dOffset: number,
|
||||
dt: number,
|
||||
offset: number,
|
||||
timestamp: number,
|
||||
velocity: number,
|
||||
visibleLength: number,
|
||||
zoomScale: number,
|
||||
},
|
||||
horizontal: ?boolean,
|
||||
getOutermostParentListRef: () => React.ElementRef<VirtualizedList>,
|
||||
registerAsNestedChild: ({
|
||||
cellKey: string,
|
||||
ref: React.ElementRef<VirtualizedList>,
|
||||
}) => void,
|
||||
unregisterAsNestedChild: ({
|
||||
ref: React.ElementRef<VirtualizedList>,
|
||||
}) => void,
|
||||
}>;
|
||||
|
||||
export const VirtualizedListContext: React.Context<?Context> =
|
||||
React.createContext(null);
|
||||
if (__DEV__) {
|
||||
VirtualizedListContext.displayName = 'VirtualizedListContext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the context. Intended for use by portal-like components (e.g. Modal).
|
||||
*/
|
||||
export function VirtualizedListContextResetter({
|
||||
children,
|
||||
}: {
|
||||
children: React.Node,
|
||||
}): React.Node {
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={null}>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context with memoization. Intended to be used by `VirtualizedList`.
|
||||
*/
|
||||
export function VirtualizedListContextProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.Node,
|
||||
value: Context,
|
||||
}): React.Node {
|
||||
// Avoid setting a newly created context object if the values are identical.
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
cellKey: null,
|
||||
getScrollMetrics: value.getScrollMetrics,
|
||||
horizontal: value.horizontal,
|
||||
getOutermostParentListRef: value.getOutermostParentListRef,
|
||||
registerAsNestedChild: value.registerAsNestedChild,
|
||||
unregisterAsNestedChild: value.unregisterAsNestedChild,
|
||||
}),
|
||||
[
|
||||
value.getScrollMetrics,
|
||||
value.horizontal,
|
||||
value.getOutermostParentListRef,
|
||||
value.registerAsNestedChild,
|
||||
value.unregisterAsNestedChild,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={context}>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the `cellKey`. Intended to be used by `VirtualizedList` for each cell.
|
||||
*/
|
||||
export function VirtualizedListCellContextProvider({
|
||||
cellKey,
|
||||
children,
|
||||
}: {
|
||||
cellKey: string,
|
||||
children: React.Node,
|
||||
}): React.Node {
|
||||
// Avoid setting a newly created context object if the values are identical.
|
||||
const currContext = useContext(VirtualizedListContext);
|
||||
const context = useMemo(
|
||||
() => (currContext == null ? null : {...currContext, cellKey}),
|
||||
[currContext, cellKey],
|
||||
);
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={context}>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {ViewToken} from './ViewabilityHelper';
|
||||
|
||||
import {View} from 'react-native';
|
||||
import VirtualizedList from './VirtualizedList';
|
||||
import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils';
|
||||
import invariant from 'invariant';
|
||||
import * as React from 'react';
|
||||
|
||||
type Item = any;
|
||||
|
||||
export type SectionBase<SectionItemT> = {
|
||||
/**
|
||||
* The data for rendering items in this section.
|
||||
*/
|
||||
data: $ReadOnlyArray<SectionItemT>,
|
||||
/**
|
||||
* Optional key to keep track of section re-ordering. If you don't plan on re-ordering sections,
|
||||
* the array index will be used by default.
|
||||
*/
|
||||
key?: string,
|
||||
// Optional props will override list-wide props just for this section.
|
||||
renderItem?: ?(info: {
|
||||
item: SectionItemT,
|
||||
index: number,
|
||||
section: SectionBase<SectionItemT>,
|
||||
separators: {
|
||||
highlight: () => void,
|
||||
unhighlight: () => void,
|
||||
updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
|
||||
...
|
||||
},
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
ItemSeparatorComponent?: ?React.ComponentType<any>,
|
||||
keyExtractor?: (item: SectionItemT, index?: ?number) => string,
|
||||
...
|
||||
};
|
||||
|
||||
type RequiredProps<SectionT: SectionBase<any>> = {|
|
||||
sections: $ReadOnlyArray<SectionT>,
|
||||
|};
|
||||
|
||||
type OptionalProps<SectionT: SectionBase<any>> = {|
|
||||
/**
|
||||
* Default renderer for every item in every section.
|
||||
*/
|
||||
renderItem?: (info: {
|
||||
item: Item,
|
||||
index: number,
|
||||
section: SectionT,
|
||||
separators: {
|
||||
highlight: () => void,
|
||||
unhighlight: () => void,
|
||||
updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
|
||||
...
|
||||
},
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
/**
|
||||
* Rendered at the top of each section. These stick to the top of the `ScrollView` by default on
|
||||
* iOS. See `stickySectionHeadersEnabled`.
|
||||
*/
|
||||
renderSectionHeader?: ?(info: {
|
||||
section: SectionT,
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
/**
|
||||
* Rendered at the bottom of each section.
|
||||
*/
|
||||
renderSectionFooter?: ?(info: {
|
||||
section: SectionT,
|
||||
...
|
||||
}) => null | React.Element<any>,
|
||||
/**
|
||||
* 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?: ?React.ComponentType<any>,
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
stickySectionHeadersEnabled?: boolean,
|
||||
onEndReached?: ?({distanceFromEnd: number, ...}) => void,
|
||||
|};
|
||||
|
||||
type VirtualizedListProps = React.ElementConfig<typeof VirtualizedList>;
|
||||
|
||||
export type Props<SectionT> = {|
|
||||
...RequiredProps<SectionT>,
|
||||
...OptionalProps<SectionT>,
|
||||
...$Diff<
|
||||
VirtualizedListProps,
|
||||
{
|
||||
renderItem: $PropertyType<VirtualizedListProps, 'renderItem'>,
|
||||
data: $PropertyType<VirtualizedListProps, 'data'>,
|
||||
...
|
||||
},
|
||||
>,
|
||||
|};
|
||||
export type ScrollToLocationParamsType = {|
|
||||
animated?: ?boolean,
|
||||
itemIndex: number,
|
||||
sectionIndex: number,
|
||||
viewOffset?: number,
|
||||
viewPosition?: number,
|
||||
|};
|
||||
|
||||
type State = {childProps: VirtualizedListProps, ...};
|
||||
|
||||
/**
|
||||
* Right now this just flattens everything into one list and uses VirtualizedList under the
|
||||
* hood. The only operation that might not scale well is concatting the data arrays of all the
|
||||
* sections when new props are received, which should be plenty fast for up to ~10,000 items.
|
||||
*/
|
||||
class VirtualizedSectionList<
|
||||
SectionT: SectionBase<any>,
|
||||
> extends React.PureComponent<Props<SectionT>, State> {
|
||||
scrollToLocation(params: ScrollToLocationParamsType) {
|
||||
let index = params.itemIndex;
|
||||
for (let i = 0; i < params.sectionIndex; i++) {
|
||||
index += this.props.getItemCount(this.props.sections[i].data) + 2;
|
||||
}
|
||||
let viewOffset = params.viewOffset || 0;
|
||||
if (this._listRef == null) {
|
||||
return;
|
||||
}
|
||||
if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) {
|
||||
const frame = this._listRef.__getFrameMetricsApprox(
|
||||
index - params.itemIndex,
|
||||
this._listRef.props,
|
||||
);
|
||||
viewOffset += frame.length;
|
||||
}
|
||||
const toIndexParams = {
|
||||
...params,
|
||||
viewOffset,
|
||||
index,
|
||||
};
|
||||
// $FlowFixMe[incompatible-use]
|
||||
this._listRef.scrollToIndex(toIndexParams);
|
||||
}
|
||||
|
||||
getListRef(): ?React.ElementRef<typeof VirtualizedList> {
|
||||
return this._listRef;
|
||||
}
|
||||
|
||||
render(): React.Node {
|
||||
const {
|
||||
ItemSeparatorComponent, // don't pass through, rendered with renderItem
|
||||
SectionSeparatorComponent,
|
||||
renderItem: _renderItem,
|
||||
renderSectionFooter,
|
||||
renderSectionHeader,
|
||||
sections: _sections,
|
||||
stickySectionHeadersEnabled,
|
||||
...passThroughProps
|
||||
} = this.props;
|
||||
|
||||
const listHeaderOffset = this.props.ListHeaderComponent ? 1 : 0;
|
||||
|
||||
const stickyHeaderIndices = this.props.stickySectionHeadersEnabled
|
||||
? ([]: Array<number>)
|
||||
: undefined;
|
||||
|
||||
let itemCount = 0;
|
||||
for (const section of this.props.sections) {
|
||||
// Track the section header indices
|
||||
if (stickyHeaderIndices != null) {
|
||||
stickyHeaderIndices.push(itemCount + listHeaderOffset);
|
||||
}
|
||||
|
||||
// Add two for the section header and footer.
|
||||
itemCount += 2;
|
||||
itemCount += this.props.getItemCount(section.data);
|
||||
}
|
||||
const renderItem = this._renderItem(itemCount);
|
||||
|
||||
return (
|
||||
<VirtualizedList
|
||||
{...passThroughProps}
|
||||
keyExtractor={this._keyExtractor}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
renderItem={renderItem}
|
||||
data={this.props.sections}
|
||||
getItem={(sections, index) =>
|
||||
this._getItem(this.props, sections, index)
|
||||
}
|
||||
getItemCount={() => itemCount}
|
||||
onViewableItemsChanged={
|
||||
this.props.onViewableItemsChanged
|
||||
? this._onViewableItemsChanged
|
||||
: undefined
|
||||
}
|
||||
ref={this._captureRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_getItem(
|
||||
props: Props<SectionT>,
|
||||
sections: ?$ReadOnlyArray<Item>,
|
||||
index: number,
|
||||
): ?Item {
|
||||
if (!sections) {
|
||||
return null;
|
||||
}
|
||||
let itemIdx = index - 1;
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const sectionData = section.data;
|
||||
const itemCount = props.getItemCount(sectionData);
|
||||
if (itemIdx === -1 || itemIdx === itemCount) {
|
||||
// We intend for there to be overflow by one on both ends of the list.
|
||||
// This will be for headers and footers. When returning a header or footer
|
||||
// item the section itself is the item.
|
||||
return section;
|
||||
} else if (itemIdx < itemCount) {
|
||||
// If we are in the bounds of the list's data then return the item.
|
||||
return props.getItem(sectionData, itemIdx);
|
||||
} else {
|
||||
itemIdx -= itemCount + 2; // Add two for the header and footer
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// $FlowFixMe[missing-local-annot]
|
||||
_keyExtractor = (item: Item, index: number) => {
|
||||
const info = this._subExtractor(index);
|
||||
return (info && info.key) || String(index);
|
||||
};
|
||||
|
||||
_subExtractor(index: number): ?{
|
||||
section: SectionT,
|
||||
// Key of the section or combined key for section + item
|
||||
key: string,
|
||||
// Relative index within the section
|
||||
index: ?number,
|
||||
// True if this is the section header
|
||||
header?: ?boolean,
|
||||
leadingItem?: ?Item,
|
||||
leadingSection?: ?SectionT,
|
||||
trailingItem?: ?Item,
|
||||
trailingSection?: ?SectionT,
|
||||
...
|
||||
} {
|
||||
let itemIndex = index;
|
||||
const {getItem, getItemCount, keyExtractor, sections} = this.props;
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const sectionData = section.data;
|
||||
const key = section.key || String(i);
|
||||
itemIndex -= 1; // The section adds an item for the header
|
||||
if (itemIndex >= getItemCount(sectionData) + 1) {
|
||||
itemIndex -= getItemCount(sectionData) + 1; // The section adds an item for the footer.
|
||||
} else if (itemIndex === -1) {
|
||||
return {
|
||||
section,
|
||||
key: key + ':header',
|
||||
index: null,
|
||||
header: true,
|
||||
trailingSection: sections[i + 1],
|
||||
};
|
||||
} else if (itemIndex === getItemCount(sectionData)) {
|
||||
return {
|
||||
section,
|
||||
key: key + ':footer',
|
||||
index: null,
|
||||
header: false,
|
||||
trailingSection: sections[i + 1],
|
||||
};
|
||||
} else {
|
||||
const extractor =
|
||||
section.keyExtractor || keyExtractor || defaultKeyExtractor;
|
||||
return {
|
||||
section,
|
||||
key:
|
||||
key + ':' + extractor(getItem(sectionData, itemIndex), itemIndex),
|
||||
index: itemIndex,
|
||||
leadingItem: getItem(sectionData, itemIndex - 1),
|
||||
leadingSection: sections[i - 1],
|
||||
trailingItem: getItem(sectionData, itemIndex + 1),
|
||||
trailingSection: sections[i + 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_convertViewable = (viewable: ViewToken): ?ViewToken => {
|
||||
invariant(viewable.index != null, 'Received a broken ViewToken');
|
||||
const info = this._subExtractor(viewable.index);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const keyExtractorWithNullableIndex = info.section.keyExtractor;
|
||||
const keyExtractorWithNonNullableIndex =
|
||||
this.props.keyExtractor || defaultKeyExtractor;
|
||||
const key =
|
||||
keyExtractorWithNullableIndex != null
|
||||
? keyExtractorWithNullableIndex(viewable.item, info.index)
|
||||
: keyExtractorWithNonNullableIndex(viewable.item, info.index ?? 0);
|
||||
|
||||
return {
|
||||
...viewable,
|
||||
index: info.index,
|
||||
key,
|
||||
section: info.section,
|
||||
};
|
||||
};
|
||||
|
||||
_onViewableItemsChanged = ({
|
||||
viewableItems,
|
||||
changed,
|
||||
}: {
|
||||
viewableItems: Array<ViewToken>,
|
||||
changed: Array<ViewToken>,
|
||||
...
|
||||
}) => {
|
||||
const onViewableItemsChanged = this.props.onViewableItemsChanged;
|
||||
if (onViewableItemsChanged != null) {
|
||||
onViewableItemsChanged({
|
||||
viewableItems: viewableItems
|
||||
.map(this._convertViewable, this)
|
||||
.filter(Boolean),
|
||||
changed: changed.map(this._convertViewable, this).filter(Boolean),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_renderItem =
|
||||
(listItemCount: number): $FlowFixMe =>
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
({item, index}: {item: Item, index: number, ...}) => {
|
||||
const info = this._subExtractor(index);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const infoIndex = info.index;
|
||||
if (infoIndex == null) {
|
||||
const {section} = info;
|
||||
if (info.header === true) {
|
||||
const {renderSectionHeader} = this.props;
|
||||
return renderSectionHeader ? renderSectionHeader({section}) : null;
|
||||
} else {
|
||||
const {renderSectionFooter} = this.props;
|
||||
return renderSectionFooter ? renderSectionFooter({section}) : null;
|
||||
}
|
||||
} else {
|
||||
const renderItem = info.section.renderItem || this.props.renderItem;
|
||||
const SeparatorComponent = this._getSeparatorComponent(
|
||||
index,
|
||||
info,
|
||||
listItemCount,
|
||||
);
|
||||
invariant(renderItem, 'no renderItem!');
|
||||
return (
|
||||
<ItemWithSeparator
|
||||
SeparatorComponent={SeparatorComponent}
|
||||
LeadingSeparatorComponent={
|
||||
infoIndex === 0 ? this.props.SectionSeparatorComponent : undefined
|
||||
}
|
||||
cellKey={info.key}
|
||||
index={infoIndex}
|
||||
item={item}
|
||||
leadingItem={info.leadingItem}
|
||||
leadingSection={info.leadingSection}
|
||||
prevCellKey={(this._subExtractor(index - 1) || {}).key}
|
||||
// Callback to provide updateHighlight for this item
|
||||
setSelfHighlightCallback={this._setUpdateHighlightFor}
|
||||
setSelfUpdatePropsCallback={this._setUpdatePropsFor}
|
||||
// Provide child ability to set highlight/updateProps for previous item using prevCellKey
|
||||
updateHighlightFor={this._updateHighlightFor}
|
||||
updatePropsFor={this._updatePropsFor}
|
||||
renderItem={renderItem}
|
||||
section={info.section}
|
||||
trailingItem={info.trailingItem}
|
||||
trailingSection={info.trailingSection}
|
||||
inverted={!!this.props.inverted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_updatePropsFor = (cellKey: string, value: any) => {
|
||||
const updateProps = this._updatePropsMap[cellKey];
|
||||
if (updateProps != null) {
|
||||
updateProps(value);
|
||||
}
|
||||
};
|
||||
|
||||
_updateHighlightFor = (cellKey: string, value: boolean) => {
|
||||
const updateHighlight = this._updateHighlightMap[cellKey];
|
||||
if (updateHighlight != null) {
|
||||
updateHighlight(value);
|
||||
}
|
||||
};
|
||||
|
||||
_setUpdateHighlightFor = (
|
||||
cellKey: string,
|
||||
updateHighlightFn: ?(boolean) => void,
|
||||
) => {
|
||||
if (updateHighlightFn != null) {
|
||||
this._updateHighlightMap[cellKey] = updateHighlightFn;
|
||||
} else {
|
||||
// $FlowFixMe[prop-missing]
|
||||
delete this._updateHighlightFor[cellKey];
|
||||
}
|
||||
};
|
||||
|
||||
_setUpdatePropsFor = (cellKey: string, updatePropsFn: ?(boolean) => void) => {
|
||||
if (updatePropsFn != null) {
|
||||
this._updatePropsMap[cellKey] = updatePropsFn;
|
||||
} else {
|
||||
delete this._updatePropsMap[cellKey];
|
||||
}
|
||||
};
|
||||
|
||||
_getSeparatorComponent(
|
||||
index: number,
|
||||
info?: ?Object,
|
||||
listItemCount: number,
|
||||
): ?React.ComponentType<any> {
|
||||
info = info || this._subExtractor(index);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
const ItemSeparatorComponent =
|
||||
info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent;
|
||||
const {SectionSeparatorComponent} = this.props;
|
||||
const isLastItemInList = index === listItemCount - 1;
|
||||
const isLastItemInSection =
|
||||
info.index === this.props.getItemCount(info.section.data) - 1;
|
||||
if (SectionSeparatorComponent && isLastItemInSection) {
|
||||
return SectionSeparatorComponent;
|
||||
}
|
||||
if (ItemSeparatorComponent && !isLastItemInSection && !isLastItemInList) {
|
||||
return ItemSeparatorComponent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_updateHighlightMap: {[string]: (boolean) => void} = {};
|
||||
_updatePropsMap: {[string]: void | (boolean => void)} = {};
|
||||
_listRef: ?React.ElementRef<typeof VirtualizedList>;
|
||||
_captureRef = (ref: null | React$ElementRef<Class<VirtualizedList>>) => {
|
||||
this._listRef = ref;
|
||||
};
|
||||
}
|
||||
|
||||
type ItemWithSeparatorCommonProps = $ReadOnly<{|
|
||||
leadingItem: ?Item,
|
||||
leadingSection: ?Object,
|
||||
section: Object,
|
||||
trailingItem: ?Item,
|
||||
trailingSection: ?Object,
|
||||
|}>;
|
||||
|
||||
type ItemWithSeparatorProps = $ReadOnly<{|
|
||||
...ItemWithSeparatorCommonProps,
|
||||
LeadingSeparatorComponent: ?React.ComponentType<any>,
|
||||
SeparatorComponent: ?React.ComponentType<any>,
|
||||
cellKey: string,
|
||||
index: number,
|
||||
item: Item,
|
||||
setSelfHighlightCallback: (
|
||||
cellKey: string,
|
||||
updateFn: ?(boolean) => void,
|
||||
) => void,
|
||||
setSelfUpdatePropsCallback: (
|
||||
cellKey: string,
|
||||
updateFn: ?(boolean) => void,
|
||||
) => void,
|
||||
prevCellKey?: ?string,
|
||||
updateHighlightFor: (prevCellKey: string, value: boolean) => void,
|
||||
updatePropsFor: (prevCellKey: string, value: Object) => void,
|
||||
renderItem: Function,
|
||||
inverted: boolean,
|
||||
|}>;
|
||||
|
||||
function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node {
|
||||
const {
|
||||
LeadingSeparatorComponent,
|
||||
// this is the trailing separator and is associated with this item
|
||||
SeparatorComponent,
|
||||
cellKey,
|
||||
prevCellKey,
|
||||
setSelfHighlightCallback,
|
||||
updateHighlightFor,
|
||||
setSelfUpdatePropsCallback,
|
||||
updatePropsFor,
|
||||
item,
|
||||
index,
|
||||
section,
|
||||
inverted,
|
||||
} = props;
|
||||
|
||||
const [leadingSeparatorHiglighted, setLeadingSeparatorHighlighted] =
|
||||
React.useState(false);
|
||||
|
||||
const [separatorHighlighted, setSeparatorHighlighted] = React.useState(false);
|
||||
|
||||
const [leadingSeparatorProps, setLeadingSeparatorProps] = React.useState({
|
||||
leadingItem: props.leadingItem,
|
||||
leadingSection: props.leadingSection,
|
||||
section: props.section,
|
||||
trailingItem: props.item,
|
||||
trailingSection: props.trailingSection,
|
||||
});
|
||||
const [separatorProps, setSeparatorProps] = React.useState({
|
||||
leadingItem: props.item,
|
||||
leadingSection: props.leadingSection,
|
||||
section: props.section,
|
||||
trailingItem: props.trailingItem,
|
||||
trailingSection: props.trailingSection,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelfHighlightCallback(cellKey, setSeparatorHighlighted);
|
||||
// $FlowFixMe[incompatible-call]
|
||||
setSelfUpdatePropsCallback(cellKey, setSeparatorProps);
|
||||
|
||||
return () => {
|
||||
setSelfUpdatePropsCallback(cellKey, null);
|
||||
setSelfHighlightCallback(cellKey, null);
|
||||
};
|
||||
}, [
|
||||
cellKey,
|
||||
setSelfHighlightCallback,
|
||||
setSeparatorProps,
|
||||
setSelfUpdatePropsCallback,
|
||||
]);
|
||||
|
||||
const separators = {
|
||||
highlight: () => {
|
||||
setLeadingSeparatorHighlighted(true);
|
||||
setSeparatorHighlighted(true);
|
||||
if (prevCellKey != null) {
|
||||
updateHighlightFor(prevCellKey, true);
|
||||
}
|
||||
},
|
||||
unhighlight: () => {
|
||||
setLeadingSeparatorHighlighted(false);
|
||||
setSeparatorHighlighted(false);
|
||||
if (prevCellKey != null) {
|
||||
updateHighlightFor(prevCellKey, false);
|
||||
}
|
||||
},
|
||||
updateProps: (
|
||||
select: 'leading' | 'trailing',
|
||||
newProps: $Shape<ItemWithSeparatorCommonProps>,
|
||||
) => {
|
||||
if (select === 'leading') {
|
||||
if (LeadingSeparatorComponent != null) {
|
||||
setLeadingSeparatorProps({...leadingSeparatorProps, ...newProps});
|
||||
} else if (prevCellKey != null) {
|
||||
// update the previous item's separator
|
||||
updatePropsFor(prevCellKey, {...leadingSeparatorProps, ...newProps});
|
||||
}
|
||||
} else if (select === 'trailing' && SeparatorComponent != null) {
|
||||
setSeparatorProps({...separatorProps, ...newProps});
|
||||
}
|
||||
},
|
||||
};
|
||||
const element = props.renderItem({
|
||||
item,
|
||||
index,
|
||||
section,
|
||||
separators,
|
||||
});
|
||||
const leadingSeparator = LeadingSeparatorComponent != null && (
|
||||
<LeadingSeparatorComponent
|
||||
highlighted={leadingSeparatorHiglighted}
|
||||
{...leadingSeparatorProps}
|
||||
/>
|
||||
);
|
||||
const separator = SeparatorComponent != null && (
|
||||
<SeparatorComponent
|
||||
highlighted={separatorHighlighted}
|
||||
{...separatorProps}
|
||||
/>
|
||||
);
|
||||
return leadingSeparator || separator ? (
|
||||
<View>
|
||||
{inverted === false ? leadingSeparator : separator}
|
||||
{element}
|
||||
{inverted === false ? separator : leadingSeparator}
|
||||
</View>
|
||||
) : (
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
/* $FlowFixMe[class-object-subtyping] added when improving typing for this
|
||||
* parameters */
|
||||
// $FlowFixMe[method-unbinding]
|
||||
module.exports = (VirtualizedSectionList: React.AbstractComponent<
|
||||
React.ElementConfig<typeof VirtualizedSectionList>,
|
||||
$ReadOnly<{
|
||||
getListRef: () => ?React.ElementRef<typeof VirtualizedList>,
|
||||
scrollToLocation: (params: ScrollToLocationParamsType) => void,
|
||||
...
|
||||
}>,
|
||||
>);
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
* @flow strict
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Intentional info-level logging for clear separation from ad-hoc console debug logging.
|
||||
*/
|
||||
function infoLog(...args: Array<mixed>): void {
|
||||
return console.log(...args);
|
||||
}
|
||||
|
||||
module.exports = infoLog;
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
export * from './Lists/VirtualizedList';
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
* @flow
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import {keyExtractor} from './Lists/VirtualizeUtils';
|
||||
|
||||
import typeof VirtualizedList from './Lists/VirtualizedList';
|
||||
import typeof VirtualizedSectionList from './Lists/VirtualizedSectionList';
|
||||
import {typeof VirtualizedListContextResetter} from './Lists/VirtualizedListContext';
|
||||
import typeof ViewabilityHelper from './Lists/ViewabilityHelper';
|
||||
import typeof FillRateHelper from './Lists/FillRateHelper';
|
||||
|
||||
export type {
|
||||
ViewToken,
|
||||
ViewabilityConfig,
|
||||
ViewabilityConfigCallbackPair,
|
||||
} from './Lists/ViewabilityHelper';
|
||||
export type {
|
||||
RenderItemProps,
|
||||
RenderItemType,
|
||||
Separators,
|
||||
} from './Lists/VirtualizedListProps';
|
||||
export type {
|
||||
Props as VirtualizedSectionListProps,
|
||||
ScrollToLocationParamsType,
|
||||
SectionBase,
|
||||
} from './Lists/VirtualizedSectionList';
|
||||
export type {FillRateInfo} from './Lists/FillRateHelper';
|
||||
|
||||
module.exports = {
|
||||
keyExtractor,
|
||||
|
||||
get VirtualizedList(): VirtualizedList {
|
||||
return require('./Lists/VirtualizedList');
|
||||
},
|
||||
get VirtualizedSectionList(): VirtualizedSectionList {
|
||||
return require('./Lists/VirtualizedSectionList');
|
||||
},
|
||||
get VirtualizedListContextResetter(): VirtualizedListContextResetter {
|
||||
const VirtualizedListContext = require('./Lists/VirtualizedListContext');
|
||||
return VirtualizedListContext.VirtualizedListContextResetter;
|
||||
},
|
||||
get ViewabilityHelper(): ViewabilityHelper {
|
||||
return require('./Lists/ViewabilityHelper');
|
||||
},
|
||||
get FillRateHelper(): FillRateHelper {
|
||||
return require('./Lists/FillRateHelper');
|
||||
},
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@react-native/virtualized-lists",
|
||||
"version": "0.72.0",
|
||||
"description": "Virtualized lists for React Native.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:facebook/react-native.git",
|
||||
"directory": "packages/virtualized-lists"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-test-renderer": "18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "*",
|
||||
"react-test-renderer": "18.2.0"
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -115,7 +115,7 @@ export * from '../Libraries/LayoutAnimation/LayoutAnimation';
|
||||
export * from '../Libraries/Linking/Linking';
|
||||
export * from '../Libraries/Lists/FlatList';
|
||||
export * from '../Libraries/Lists/SectionList';
|
||||
export * from '@react-native/virtualized-lists';
|
||||
export * from '../Libraries/Lists/VirtualizedList';
|
||||
export * from '../Libraries/LogBox/LogBox';
|
||||
export * from '../Libraries/Modal/Modal';
|
||||
export * as Systrace from '../Libraries/Performance/Systrace';
|
||||
|
||||
Reference in New Issue
Block a user