@react-native-tvos/virtualized-lists 0.73.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @format
8
+ * @flow strict-local
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const {InteractionManager} = require('react-native');
14
+
15
+ /**
16
+ * A simple class for batching up invocations of a low-pri callback. A timeout is set to run the
17
+ * callback once after a delay, no matter how many times it's scheduled. Once the delay is reached,
18
+ * InteractionManager.runAfterInteractions is used to invoke the callback after any hi-pri
19
+ * interactions are done running.
20
+ *
21
+ * Make sure to cleanup with dispose(). Example:
22
+ *
23
+ * class Widget extends React.Component {
24
+ * _batchedSave: new Batchinator(() => this._saveState, 1000);
25
+ * _saveSate() {
26
+ * // save this.state to disk
27
+ * }
28
+ * componentDidUpdate() {
29
+ * this._batchedSave.schedule();
30
+ * }
31
+ * componentWillUnmount() {
32
+ * this._batchedSave.dispose();
33
+ * }
34
+ * ...
35
+ * }
36
+ */
37
+ class Batchinator {
38
+ _callback: () => void;
39
+ _delay: number;
40
+ _taskHandle: ?{cancel: () => void, ...};
41
+ constructor(callback: () => void, delayMS: number) {
42
+ this._delay = delayMS;
43
+ this._callback = callback;
44
+ }
45
+ /*
46
+ * Cleanup any pending tasks.
47
+ *
48
+ * By default, if there is a pending task the callback is run immediately. Set the option abort to
49
+ * true to not call the callback if it was pending.
50
+ */
51
+ dispose(options: {abort: boolean, ...} = {abort: false}) {
52
+ if (this._taskHandle) {
53
+ this._taskHandle.cancel();
54
+ if (!options.abort) {
55
+ this._callback();
56
+ }
57
+ this._taskHandle = null;
58
+ }
59
+ }
60
+ schedule() {
61
+ if (this._taskHandle) {
62
+ return;
63
+ }
64
+ const timeoutHandle = setTimeout(() => {
65
+ this._taskHandle = InteractionManager.runAfterInteractions(() => {
66
+ // Note that we clear the handle before invoking the callback so that if the callback calls
67
+ // schedule again, it will actually schedule another task.
68
+ this._taskHandle = null;
69
+ this._callback();
70
+ });
71
+ }, this._delay);
72
+ this._taskHandle = {cancel: () => clearTimeout(timeoutHandle)};
73
+ }
74
+ }
75
+
76
+ module.exports = Batchinator;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow strict
8
+ * @format
9
+ */
10
+
11
+ import invariant from 'invariant';
12
+
13
+ export type CellRegion = {
14
+ first: number,
15
+ last: number,
16
+ isSpacer: boolean,
17
+ };
18
+
19
+ export class CellRenderMask {
20
+ _numCells: number;
21
+ _regions: Array<CellRegion>;
22
+
23
+ constructor(numCells: number) {
24
+ invariant(
25
+ numCells >= 0,
26
+ 'CellRenderMask must contain a non-negative number os cells',
27
+ );
28
+
29
+ this._numCells = numCells;
30
+
31
+ if (numCells === 0) {
32
+ this._regions = [];
33
+ } else {
34
+ this._regions = [
35
+ {
36
+ first: 0,
37
+ last: numCells - 1,
38
+ isSpacer: true,
39
+ },
40
+ ];
41
+ }
42
+ }
43
+
44
+ enumerateRegions(): $ReadOnlyArray<CellRegion> {
45
+ return this._regions;
46
+ }
47
+
48
+ addCells(cells: {first: number, last: number}): void {
49
+ invariant(
50
+ cells.first >= 0 &&
51
+ cells.first < this._numCells &&
52
+ cells.last >= -1 &&
53
+ cells.last < this._numCells &&
54
+ cells.last >= cells.first - 1,
55
+ 'CellRenderMask.addCells called with invalid cell range',
56
+ );
57
+
58
+ // VirtualizedList uses inclusive ranges, where zero-count states are
59
+ // possible. E.g. [0, -1] for no cells, starting at 0.
60
+ if (cells.last < cells.first) {
61
+ return;
62
+ }
63
+
64
+ const [firstIntersect, firstIntersectIdx] = this._findRegion(cells.first);
65
+ const [lastIntersect, lastIntersectIdx] = this._findRegion(cells.last);
66
+
67
+ // Fast-path if the cells to add are already all present in the mask. We
68
+ // will otherwise need to do some mutation.
69
+ if (firstIntersectIdx === lastIntersectIdx && !firstIntersect.isSpacer) {
70
+ return;
71
+ }
72
+
73
+ // We need to replace the existing covered regions with 1-3 new regions
74
+ // depending whether we need to split spacers out of overlapping regions.
75
+ const newLeadRegion: Array<CellRegion> = [];
76
+ const newTailRegion: Array<CellRegion> = [];
77
+ const newMainRegion: CellRegion = {
78
+ ...cells,
79
+ isSpacer: false,
80
+ };
81
+
82
+ if (firstIntersect.first < newMainRegion.first) {
83
+ if (firstIntersect.isSpacer) {
84
+ newLeadRegion.push({
85
+ first: firstIntersect.first,
86
+ last: newMainRegion.first - 1,
87
+ isSpacer: true,
88
+ });
89
+ } else {
90
+ newMainRegion.first = firstIntersect.first;
91
+ }
92
+ }
93
+
94
+ if (lastIntersect.last > newMainRegion.last) {
95
+ if (lastIntersect.isSpacer) {
96
+ newTailRegion.push({
97
+ first: newMainRegion.last + 1,
98
+ last: lastIntersect.last,
99
+ isSpacer: true,
100
+ });
101
+ } else {
102
+ newMainRegion.last = lastIntersect.last;
103
+ }
104
+ }
105
+
106
+ const replacementRegions: Array<CellRegion> = [
107
+ ...newLeadRegion,
108
+ newMainRegion,
109
+ ...newTailRegion,
110
+ ];
111
+ const numRegionsToDelete = lastIntersectIdx - firstIntersectIdx + 1;
112
+ this._regions.splice(
113
+ firstIntersectIdx,
114
+ numRegionsToDelete,
115
+ ...replacementRegions,
116
+ );
117
+ }
118
+
119
+ numCells(): number {
120
+ return this._numCells;
121
+ }
122
+
123
+ equals(other: CellRenderMask): boolean {
124
+ return (
125
+ this._numCells === other._numCells &&
126
+ this._regions.length === other._regions.length &&
127
+ this._regions.every(
128
+ (region, i) =>
129
+ region.first === other._regions[i].first &&
130
+ region.last === other._regions[i].last &&
131
+ region.isSpacer === other._regions[i].isSpacer,
132
+ )
133
+ );
134
+ }
135
+
136
+ _findRegion(cellIdx: number): [CellRegion, number] {
137
+ let firstIdx = 0;
138
+ let lastIdx = this._regions.length - 1;
139
+
140
+ while (firstIdx <= lastIdx) {
141
+ const middleIdx = Math.floor((firstIdx + lastIdx) / 2);
142
+ const middleRegion = this._regions[middleIdx];
143
+
144
+ if (cellIdx >= middleRegion.first && cellIdx <= middleRegion.last) {
145
+ return [middleRegion, middleIdx];
146
+ } else if (cellIdx < middleRegion.first) {
147
+ lastIdx = middleIdx - 1;
148
+ } else if (cellIdx > middleRegion.last) {
149
+ firstIdx = middleIdx + 1;
150
+ }
151
+ }
152
+
153
+ invariant(false, `A region was not found containing cellIdx ${cellIdx}`);
154
+ }
155
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow strict
8
+ * @format
9
+ */
10
+
11
+ import invariant from 'invariant';
12
+
13
+ export default class ChildListCollection<TList> {
14
+ _cellKeyToChildren: Map<string, Set<TList>> = new Map();
15
+ _childrenToCellKey: Map<TList, string> = new Map();
16
+
17
+ add(list: TList, cellKey: string): void {
18
+ invariant(
19
+ !this._childrenToCellKey.has(list),
20
+ 'Trying to add already present child list',
21
+ );
22
+
23
+ const cellLists = this._cellKeyToChildren.get(cellKey) ?? new Set();
24
+ cellLists.add(list);
25
+ this._cellKeyToChildren.set(cellKey, cellLists);
26
+
27
+ this._childrenToCellKey.set(list, cellKey);
28
+ }
29
+
30
+ remove(list: TList): void {
31
+ const cellKey = this._childrenToCellKey.get(list);
32
+ invariant(cellKey != null, 'Trying to remove non-present child list');
33
+ this._childrenToCellKey.delete(list);
34
+
35
+ const cellLists = this._cellKeyToChildren.get(cellKey);
36
+ invariant(cellLists, '_cellKeyToChildren should contain cellKey');
37
+ cellLists.delete(list);
38
+
39
+ if (cellLists.size === 0) {
40
+ this._cellKeyToChildren.delete(cellKey);
41
+ }
42
+ }
43
+
44
+ forEach(fn: TList => void): void {
45
+ for (const listSet of this._cellKeyToChildren.values()) {
46
+ for (const list of listSet) {
47
+ fn(list);
48
+ }
49
+ }
50
+ }
51
+
52
+ forEachInCell(cellKey: string, fn: TList => void): void {
53
+ const listSet = this._cellKeyToChildren.get(cellKey) ?? [];
54
+ for (const list of listSet) {
55
+ fn(list);
56
+ }
57
+ }
58
+
59
+ anyInCell(cellKey: string, fn: TList => boolean): boolean {
60
+ const listSet = this._cellKeyToChildren.get(cellKey) ?? [];
61
+ for (const list of listSet) {
62
+ if (fn(list)) {
63
+ return true;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+
69
+ size(): number {
70
+ return this._childrenToCellKey.size;
71
+ }
72
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ * @format
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import type {CellMetricProps} from './ListMetricsAggregator';
14
+ import ListMetricsAggregator from './ListMetricsAggregator';
15
+
16
+ export type FillRateInfo = Info;
17
+
18
+ class Info {
19
+ any_blank_count: number = 0;
20
+ any_blank_ms: number = 0;
21
+ any_blank_speed_sum: number = 0;
22
+ mostly_blank_count: number = 0;
23
+ mostly_blank_ms: number = 0;
24
+ pixels_blank: number = 0;
25
+ pixels_sampled: number = 0;
26
+ pixels_scrolled: number = 0;
27
+ total_time_spent: number = 0;
28
+ sample_count: number = 0;
29
+ }
30
+
31
+ const DEBUG = false;
32
+
33
+ let _listeners: Array<(Info) => void> = [];
34
+ let _minSampleCount = 10;
35
+ let _sampleRate = DEBUG ? 1 : null;
36
+
37
+ /**
38
+ * A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded.
39
+ * By default the sampling rate is set to zero and this will do nothing. If you want to collect
40
+ * samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`.
41
+ *
42
+ * Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with
43
+ * `SceneTracker.getActiveScene` to determine the context of the events.
44
+ */
45
+ class FillRateHelper {
46
+ _anyBlankStartTime: ?number = null;
47
+ _enabled = false;
48
+ _listMetrics: ListMetricsAggregator;
49
+ _info: Info = new Info();
50
+ _mostlyBlankStartTime: ?number = null;
51
+ _samplesStartTime: ?number = null;
52
+
53
+ static addListener(callback: FillRateInfo => void): {
54
+ remove: () => void,
55
+ ...
56
+ } {
57
+ if (_sampleRate === null) {
58
+ console.warn('Call `FillRateHelper.setSampleRate` before `addListener`.');
59
+ }
60
+ _listeners.push(callback);
61
+ return {
62
+ remove: () => {
63
+ _listeners = _listeners.filter(listener => callback !== listener);
64
+ },
65
+ };
66
+ }
67
+
68
+ static setSampleRate(sampleRate: number) {
69
+ _sampleRate = sampleRate;
70
+ }
71
+
72
+ static setMinSampleCount(minSampleCount: number) {
73
+ _minSampleCount = minSampleCount;
74
+ }
75
+
76
+ constructor(listMetrics: ListMetricsAggregator) {
77
+ this._listMetrics = listMetrics;
78
+ this._enabled = (_sampleRate || 0) > Math.random();
79
+ this._resetData();
80
+ }
81
+
82
+ activate() {
83
+ if (this._enabled && this._samplesStartTime == null) {
84
+ DEBUG && console.debug('FillRateHelper: activate');
85
+ this._samplesStartTime = global.performance.now();
86
+ }
87
+ }
88
+
89
+ deactivateAndFlush() {
90
+ if (!this._enabled) {
91
+ return;
92
+ }
93
+ const start = this._samplesStartTime; // const for flow
94
+ if (start == null) {
95
+ DEBUG &&
96
+ console.debug('FillRateHelper: bail on deactivate with no start time');
97
+ return;
98
+ }
99
+ if (this._info.sample_count < _minSampleCount) {
100
+ // Don't bother with under-sampled events.
101
+ this._resetData();
102
+ return;
103
+ }
104
+ const total_time_spent = global.performance.now() - start;
105
+ const info: any = {
106
+ ...this._info,
107
+ total_time_spent,
108
+ };
109
+ if (DEBUG) {
110
+ const derived = {
111
+ avg_blankness: this._info.pixels_blank / this._info.pixels_sampled,
112
+ avg_speed: this._info.pixels_scrolled / (total_time_spent / 1000),
113
+ avg_speed_when_any_blank:
114
+ this._info.any_blank_speed_sum / this._info.any_blank_count,
115
+ any_blank_per_min:
116
+ this._info.any_blank_count / (total_time_spent / 1000 / 60),
117
+ any_blank_time_frac: this._info.any_blank_ms / total_time_spent,
118
+ mostly_blank_per_min:
119
+ this._info.mostly_blank_count / (total_time_spent / 1000 / 60),
120
+ mostly_blank_time_frac: this._info.mostly_blank_ms / total_time_spent,
121
+ };
122
+ for (const key in derived) {
123
+ // $FlowFixMe[prop-missing]
124
+ derived[key] = Math.round(1000 * derived[key]) / 1000;
125
+ }
126
+ console.debug('FillRateHelper deactivateAndFlush: ', {derived, info});
127
+ }
128
+ _listeners.forEach(listener => listener(info));
129
+ this._resetData();
130
+ }
131
+
132
+ computeBlankness(
133
+ props: {
134
+ ...CellMetricProps,
135
+ initialNumToRender?: ?number,
136
+ ...
137
+ },
138
+ cellsAroundViewport: {
139
+ first: number,
140
+ last: number,
141
+ ...
142
+ },
143
+ scrollMetrics: {
144
+ dOffset: number,
145
+ offset: number,
146
+ velocity: number,
147
+ visibleLength: number,
148
+ ...
149
+ },
150
+ ): number {
151
+ if (
152
+ !this._enabled ||
153
+ props.getItemCount(props.data) === 0 ||
154
+ cellsAroundViewport.last < cellsAroundViewport.first ||
155
+ this._samplesStartTime == null
156
+ ) {
157
+ return 0;
158
+ }
159
+ const {dOffset, offset, velocity, visibleLength} = scrollMetrics;
160
+
161
+ // Denominator metrics that we track for all events - most of the time there is no blankness and
162
+ // we want to capture that.
163
+ this._info.sample_count++;
164
+ this._info.pixels_sampled += Math.round(visibleLength);
165
+ this._info.pixels_scrolled += Math.round(Math.abs(dOffset));
166
+ const scrollSpeed = Math.round(Math.abs(velocity) * 1000); // px / sec
167
+
168
+ // Whether blank now or not, record the elapsed time blank if we were blank last time.
169
+ const now = global.performance.now();
170
+ if (this._anyBlankStartTime != null) {
171
+ this._info.any_blank_ms += now - this._anyBlankStartTime;
172
+ }
173
+ this._anyBlankStartTime = null;
174
+ if (this._mostlyBlankStartTime != null) {
175
+ this._info.mostly_blank_ms += now - this._mostlyBlankStartTime;
176
+ }
177
+ this._mostlyBlankStartTime = null;
178
+
179
+ let blankTop = 0;
180
+ let first = cellsAroundViewport.first;
181
+ let firstFrame = this._listMetrics.getCellMetrics(first, props);
182
+ while (
183
+ first <= cellsAroundViewport.last &&
184
+ (!firstFrame || !firstFrame.isMounted)
185
+ ) {
186
+ firstFrame = this._listMetrics.getCellMetrics(first, props);
187
+ first++;
188
+ }
189
+ // Only count blankTop if we aren't rendering the first item, otherwise we will count the header
190
+ // as blank.
191
+ if (firstFrame && first > 0) {
192
+ blankTop = Math.min(
193
+ visibleLength,
194
+ Math.max(0, firstFrame.offset - offset),
195
+ );
196
+ }
197
+ let blankBottom = 0;
198
+ let last = cellsAroundViewport.last;
199
+ let lastFrame = this._listMetrics.getCellMetrics(last, props);
200
+ while (
201
+ last >= cellsAroundViewport.first &&
202
+ (!lastFrame || !lastFrame.isMounted)
203
+ ) {
204
+ lastFrame = this._listMetrics.getCellMetrics(last, props);
205
+ last--;
206
+ }
207
+ // Only count blankBottom if we aren't rendering the last item, otherwise we will count the
208
+ // footer as blank.
209
+ if (lastFrame && last < props.getItemCount(props.data) - 1) {
210
+ const bottomEdge = lastFrame.offset + lastFrame.length;
211
+ blankBottom = Math.min(
212
+ visibleLength,
213
+ Math.max(0, offset + visibleLength - bottomEdge),
214
+ );
215
+ }
216
+ const pixels_blank = Math.round(blankTop + blankBottom);
217
+ const blankness = pixels_blank / visibleLength;
218
+ if (blankness > 0) {
219
+ this._anyBlankStartTime = now;
220
+ this._info.any_blank_speed_sum += scrollSpeed;
221
+ this._info.any_blank_count++;
222
+ this._info.pixels_blank += pixels_blank;
223
+ if (blankness > 0.5) {
224
+ this._mostlyBlankStartTime = now;
225
+ this._info.mostly_blank_count++;
226
+ }
227
+ } else if (scrollSpeed < 0.01 || Math.abs(dOffset) < 1) {
228
+ this.deactivateAndFlush();
229
+ }
230
+ return blankness;
231
+ }
232
+
233
+ enabled(): boolean {
234
+ return this._enabled;
235
+ }
236
+
237
+ _resetData() {
238
+ this._anyBlankStartTime = null;
239
+ this._info = new Info();
240
+ this._mostlyBlankStartTime = null;
241
+ this._samplesStartTime = null;
242
+ }
243
+ }
244
+
245
+ module.exports = FillRateHelper;