@dreamstack-us/section-flow 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/index.cjs +2281 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +866 -0
- package/dist/index.d.ts +866 -0
- package/dist/index.js +2217 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2217 @@
|
|
|
1
|
+
import { createContext, forwardRef, memo, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Animated, RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/constants.ts
|
|
6
|
+
/**
|
|
7
|
+
* Default configuration values for SectionFlow
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_ESTIMATED_ITEM_SIZE = 50;
|
|
10
|
+
const DEFAULT_ESTIMATED_HEADER_SIZE = 40;
|
|
11
|
+
const DEFAULT_ESTIMATED_FOOTER_SIZE = 0;
|
|
12
|
+
const DEFAULT_DRAW_DISTANCE = 250;
|
|
13
|
+
const DEFAULT_MAX_POOL_SIZE = 10;
|
|
14
|
+
const DEFAULT_ITEM_TYPE = "default";
|
|
15
|
+
const SECTION_HEADER_TYPE = "__section_header__";
|
|
16
|
+
const SECTION_FOOTER_TYPE = "__section_footer__";
|
|
17
|
+
const DEFAULT_VIEWABILITY_CONFIG = {
|
|
18
|
+
minimumViewTime: 250,
|
|
19
|
+
viewAreaCoveragePercentThreshold: 0,
|
|
20
|
+
itemVisiblePercentThreshold: 50,
|
|
21
|
+
waitForInteraction: false
|
|
22
|
+
};
|
|
23
|
+
const SCROLL_VELOCITY_THRESHOLD = 2;
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/core/LayoutCache.ts
|
|
27
|
+
var LayoutCacheImpl = class {
|
|
28
|
+
cache = new Map();
|
|
29
|
+
typeStats = new Map();
|
|
30
|
+
get(key) {
|
|
31
|
+
return this.cache.get(key);
|
|
32
|
+
}
|
|
33
|
+
set(key, layout) {
|
|
34
|
+
this.cache.set(key, layout);
|
|
35
|
+
}
|
|
36
|
+
has(key) {
|
|
37
|
+
return this.cache.has(key);
|
|
38
|
+
}
|
|
39
|
+
delete(key) {
|
|
40
|
+
this.cache.delete(key);
|
|
41
|
+
}
|
|
42
|
+
clear() {
|
|
43
|
+
this.cache.clear();
|
|
44
|
+
this.typeStats.clear();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Invalidate all cached layouts from a given flat index onwards.
|
|
48
|
+
* Used when items are inserted/removed and positions need recalculation.
|
|
49
|
+
*/
|
|
50
|
+
invalidateFrom(flatIndex, keyToIndexMap) {
|
|
51
|
+
for (const [key, index] of keyToIndexMap) if (index >= flatIndex) this.cache.delete(key);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get the average measured size for items of a given type.
|
|
55
|
+
* Used for predicting unmeasured item sizes.
|
|
56
|
+
*/
|
|
57
|
+
getAverageSize(itemType) {
|
|
58
|
+
const stats = this.typeStats.get(itemType);
|
|
59
|
+
if (!stats || stats.count === 0) return void 0;
|
|
60
|
+
return stats.totalSize / stats.count;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Record a measurement for computing type averages.
|
|
64
|
+
*/
|
|
65
|
+
recordMeasurement(itemType, size) {
|
|
66
|
+
let stats = this.typeStats.get(itemType);
|
|
67
|
+
if (!stats) {
|
|
68
|
+
stats = {
|
|
69
|
+
totalSize: 0,
|
|
70
|
+
count: 0
|
|
71
|
+
};
|
|
72
|
+
this.typeStats.set(itemType, stats);
|
|
73
|
+
}
|
|
74
|
+
stats.totalSize += size;
|
|
75
|
+
stats.count += 1;
|
|
76
|
+
}
|
|
77
|
+
get size() {
|
|
78
|
+
return this.cache.size;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Factory function to create a LayoutCache instance.
|
|
83
|
+
*/
|
|
84
|
+
function createLayoutCache() {
|
|
85
|
+
return new LayoutCacheImpl();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/core/LinearLayoutPositioner.ts
|
|
90
|
+
/**
|
|
91
|
+
* LinearLayoutPositioner implements the LayoutPositioner interface for
|
|
92
|
+
* standard vertical or horizontal list layouts.
|
|
93
|
+
*
|
|
94
|
+
* It computes absolute positions for each item based on:
|
|
95
|
+
* 1. Measured sizes from LayoutCache (when available)
|
|
96
|
+
* 2. Estimated sizes (when not yet measured)
|
|
97
|
+
* 3. Type-specific size predictions
|
|
98
|
+
*/
|
|
99
|
+
var LinearLayoutPositioner = class {
|
|
100
|
+
flattenedData = [];
|
|
101
|
+
computedLayouts = new Map();
|
|
102
|
+
totalContentSize = {
|
|
103
|
+
width: 0,
|
|
104
|
+
height: 0
|
|
105
|
+
};
|
|
106
|
+
layoutsValid = false;
|
|
107
|
+
constructor(layoutCache, getItemType, options = {}) {
|
|
108
|
+
this.layoutCache = layoutCache;
|
|
109
|
+
this.getItemType = getItemType;
|
|
110
|
+
this.horizontal = options.horizontal ?? false;
|
|
111
|
+
this.estimatedItemSize = options.estimatedItemSize ?? DEFAULT_ESTIMATED_ITEM_SIZE;
|
|
112
|
+
this.estimatedHeaderSize = options.estimatedHeaderSize ?? DEFAULT_ESTIMATED_HEADER_SIZE;
|
|
113
|
+
this.estimatedFooterSize = options.estimatedFooterSize ?? DEFAULT_ESTIMATED_FOOTER_SIZE;
|
|
114
|
+
this.containerWidth = options.containerWidth ?? 0;
|
|
115
|
+
this.containerHeight = options.containerHeight ?? 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Update the flattened data and invalidate layouts.
|
|
119
|
+
*/
|
|
120
|
+
setData(flattenedData) {
|
|
121
|
+
this.flattenedData = flattenedData;
|
|
122
|
+
this.invalidateAll();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Update container dimensions.
|
|
126
|
+
*/
|
|
127
|
+
setContainerSize(width, height) {
|
|
128
|
+
if (this.containerWidth !== width || this.containerHeight !== height) {
|
|
129
|
+
this.containerWidth = width;
|
|
130
|
+
this.containerHeight = height;
|
|
131
|
+
this.invalidateAll();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get the estimated size for an item based on its type.
|
|
136
|
+
*/
|
|
137
|
+
getEstimatedSize(flatIndex) {
|
|
138
|
+
const item = this.flattenedData[flatIndex];
|
|
139
|
+
if (!item) return this.estimatedItemSize;
|
|
140
|
+
const itemType = this.getItemType(flatIndex);
|
|
141
|
+
const avgSize = this.layoutCache.getAverageSize(itemType);
|
|
142
|
+
if (avgSize !== void 0) return avgSize;
|
|
143
|
+
if (item.type === "section-header") return this.estimatedHeaderSize;
|
|
144
|
+
if (item.type === "section-footer") return this.estimatedFooterSize;
|
|
145
|
+
return this.estimatedItemSize;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get the actual or estimated size for an item.
|
|
149
|
+
*/
|
|
150
|
+
getItemSize(flatIndex) {
|
|
151
|
+
const item = this.flattenedData[flatIndex];
|
|
152
|
+
if (!item) return this.estimatedItemSize;
|
|
153
|
+
const cached = this.layoutCache.get(item.key);
|
|
154
|
+
if (cached) return this.horizontal ? cached.width : cached.height;
|
|
155
|
+
return this.getEstimatedSize(flatIndex);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Compute layouts for all items if not already computed.
|
|
159
|
+
*/
|
|
160
|
+
ensureLayoutsComputed() {
|
|
161
|
+
if (this.layoutsValid) return;
|
|
162
|
+
this.computedLayouts.clear();
|
|
163
|
+
let offset = 0;
|
|
164
|
+
const crossAxisSize = this.horizontal ? this.containerHeight : this.containerWidth;
|
|
165
|
+
for (let i = 0; i < this.flattenedData.length; i++) {
|
|
166
|
+
const size = this.getItemSize(i);
|
|
167
|
+
const layout = this.horizontal ? {
|
|
168
|
+
x: offset,
|
|
169
|
+
y: 0,
|
|
170
|
+
width: size,
|
|
171
|
+
height: crossAxisSize
|
|
172
|
+
} : {
|
|
173
|
+
x: 0,
|
|
174
|
+
y: offset,
|
|
175
|
+
width: crossAxisSize,
|
|
176
|
+
height: size
|
|
177
|
+
};
|
|
178
|
+
this.computedLayouts.set(i, layout);
|
|
179
|
+
offset += size;
|
|
180
|
+
}
|
|
181
|
+
this.totalContentSize = this.horizontal ? {
|
|
182
|
+
width: offset,
|
|
183
|
+
height: crossAxisSize
|
|
184
|
+
} : {
|
|
185
|
+
width: crossAxisSize,
|
|
186
|
+
height: offset
|
|
187
|
+
};
|
|
188
|
+
this.layoutsValid = true;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get the layout for a specific index.
|
|
192
|
+
*/
|
|
193
|
+
getLayoutForIndex(index) {
|
|
194
|
+
this.ensureLayoutsComputed();
|
|
195
|
+
return this.computedLayouts.get(index) ?? {
|
|
196
|
+
x: 0,
|
|
197
|
+
y: 0,
|
|
198
|
+
width: 0,
|
|
199
|
+
height: 0
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Get the total content size.
|
|
204
|
+
*/
|
|
205
|
+
getContentSize() {
|
|
206
|
+
this.ensureLayoutsComputed();
|
|
207
|
+
return this.totalContentSize;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Get the range of visible indices for the given scroll position.
|
|
211
|
+
* Uses binary search for efficiency with large lists.
|
|
212
|
+
*/
|
|
213
|
+
getVisibleRange(scrollOffset, viewportSize, overscan) {
|
|
214
|
+
this.ensureLayoutsComputed();
|
|
215
|
+
if (this.flattenedData.length === 0) return {
|
|
216
|
+
startIndex: 0,
|
|
217
|
+
endIndex: -1
|
|
218
|
+
};
|
|
219
|
+
const startOffset = Math.max(0, scrollOffset - overscan);
|
|
220
|
+
const endOffset = scrollOffset + viewportSize + overscan;
|
|
221
|
+
const startIndex = this.binarySearchStart(startOffset);
|
|
222
|
+
const endIndex = this.binarySearchEnd(endOffset);
|
|
223
|
+
return {
|
|
224
|
+
startIndex: Math.max(0, startIndex),
|
|
225
|
+
endIndex: Math.min(this.flattenedData.length - 1, endIndex)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Binary search to find first item that ends after the given offset.
|
|
230
|
+
*/
|
|
231
|
+
binarySearchStart(offset) {
|
|
232
|
+
let low = 0;
|
|
233
|
+
let high = this.flattenedData.length - 1;
|
|
234
|
+
while (low < high) {
|
|
235
|
+
const mid = Math.floor((low + high) / 2);
|
|
236
|
+
const layout = this.computedLayouts.get(mid);
|
|
237
|
+
if (!layout) {
|
|
238
|
+
low = mid + 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const itemEnd = this.horizontal ? layout.x + layout.width : layout.y + layout.height;
|
|
242
|
+
if (itemEnd <= offset) low = mid + 1;
|
|
243
|
+
else high = mid;
|
|
244
|
+
}
|
|
245
|
+
return low;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Binary search to find last item that starts before the given offset.
|
|
249
|
+
*/
|
|
250
|
+
binarySearchEnd(offset) {
|
|
251
|
+
let low = 0;
|
|
252
|
+
let high = this.flattenedData.length - 1;
|
|
253
|
+
while (low < high) {
|
|
254
|
+
const mid = Math.ceil((low + high) / 2);
|
|
255
|
+
const layout = this.computedLayouts.get(mid);
|
|
256
|
+
if (!layout) {
|
|
257
|
+
high = mid - 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const itemStart = this.horizontal ? layout.x : layout.y;
|
|
261
|
+
if (itemStart >= offset) high = mid - 1;
|
|
262
|
+
else low = mid;
|
|
263
|
+
}
|
|
264
|
+
return high;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Update the layout for a specific index after measurement.
|
|
268
|
+
*/
|
|
269
|
+
updateItemLayout(index, layout) {
|
|
270
|
+
const item = this.flattenedData[index];
|
|
271
|
+
if (!item) return;
|
|
272
|
+
const itemType = this.getItemType(index);
|
|
273
|
+
const size = this.horizontal ? layout.width : layout.height;
|
|
274
|
+
this.layoutCache.recordMeasurement(itemType, size);
|
|
275
|
+
this.layoutCache.set(item.key, layout);
|
|
276
|
+
this.invalidateFrom(index);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Invalidate layouts from a given index.
|
|
280
|
+
*/
|
|
281
|
+
invalidateFrom(index) {
|
|
282
|
+
for (let i = index; i < this.flattenedData.length; i++) this.computedLayouts.delete(i);
|
|
283
|
+
this.layoutsValid = false;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Invalidate all layouts.
|
|
287
|
+
*/
|
|
288
|
+
invalidateAll() {
|
|
289
|
+
this.computedLayouts.clear();
|
|
290
|
+
this.layoutsValid = false;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Get the index of the item at or just before the given offset.
|
|
294
|
+
*/
|
|
295
|
+
getIndexForOffset(offset) {
|
|
296
|
+
this.ensureLayoutsComputed();
|
|
297
|
+
if (this.flattenedData.length === 0) return 0;
|
|
298
|
+
return this.binarySearchStart(offset);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Get the total number of items.
|
|
302
|
+
*/
|
|
303
|
+
getTotalItemCount() {
|
|
304
|
+
return this.flattenedData.length;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Factory function to create a LinearLayoutPositioner.
|
|
309
|
+
*/
|
|
310
|
+
function createLayoutPositioner(layoutCache, getItemType, options) {
|
|
311
|
+
return new LinearLayoutPositioner(layoutCache, getItemType, options);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/core/SectionLayoutManager.ts
|
|
316
|
+
var SectionLayoutManagerImpl = class {
|
|
317
|
+
sections = [];
|
|
318
|
+
flattenedData = [];
|
|
319
|
+
collapsedSections = new Set();
|
|
320
|
+
sectionBoundaries = new Map();
|
|
321
|
+
flatIndexToSection = new Map();
|
|
322
|
+
constructor(layoutCache, options = {}) {
|
|
323
|
+
this.layoutCache = layoutCache;
|
|
324
|
+
this.horizontal = options.horizontal ?? false;
|
|
325
|
+
this.hasSectionFooters = options.hasSectionFooters ?? false;
|
|
326
|
+
this.layoutPositioner = new LinearLayoutPositioner(layoutCache, (flatIndex) => this.getItemTypeForIndex(flatIndex), {
|
|
327
|
+
horizontal: this.horizontal,
|
|
328
|
+
estimatedItemSize: options.estimatedItemSize,
|
|
329
|
+
estimatedHeaderSize: options.estimatedHeaderSize,
|
|
330
|
+
estimatedFooterSize: options.estimatedFooterSize
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Get the item type for a flat index (used by layout positioner).
|
|
335
|
+
*/
|
|
336
|
+
getItemTypeForIndex(flatIndex) {
|
|
337
|
+
const item = this.flattenedData[flatIndex];
|
|
338
|
+
if (!item) return "default";
|
|
339
|
+
if (item.type === "section-header") return "__section_header__";
|
|
340
|
+
if (item.type === "section-footer") return "__section_footer__";
|
|
341
|
+
return "default";
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Update sections and compute flattened data.
|
|
345
|
+
*/
|
|
346
|
+
updateData(sections, collapsedSections) {
|
|
347
|
+
this.sections = sections;
|
|
348
|
+
this.collapsedSections = collapsedSections;
|
|
349
|
+
this.flattenedData = [];
|
|
350
|
+
this.sectionBoundaries.clear();
|
|
351
|
+
this.flatIndexToSection.clear();
|
|
352
|
+
let flatIndex = 0;
|
|
353
|
+
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
|
|
354
|
+
const section = sections[sectionIndex];
|
|
355
|
+
const isCollapsed = collapsedSections.has(section.key);
|
|
356
|
+
const boundary = {
|
|
357
|
+
sectionIndex,
|
|
358
|
+
sectionKey: section.key,
|
|
359
|
+
headerFlatIndex: flatIndex,
|
|
360
|
+
firstItemFlatIndex: -1,
|
|
361
|
+
lastItemFlatIndex: -1,
|
|
362
|
+
footerFlatIndex: null,
|
|
363
|
+
itemCount: 0
|
|
364
|
+
};
|
|
365
|
+
this.flattenedData.push({
|
|
366
|
+
type: "section-header",
|
|
367
|
+
key: `header-${section.key}`,
|
|
368
|
+
sectionKey: section.key,
|
|
369
|
+
sectionIndex,
|
|
370
|
+
itemIndex: -1,
|
|
371
|
+
item: null,
|
|
372
|
+
section
|
|
373
|
+
});
|
|
374
|
+
this.flatIndexToSection.set(flatIndex, boundary);
|
|
375
|
+
flatIndex++;
|
|
376
|
+
if (!isCollapsed) {
|
|
377
|
+
boundary.firstItemFlatIndex = flatIndex;
|
|
378
|
+
for (let itemIndex = 0; itemIndex < section.data.length; itemIndex++) {
|
|
379
|
+
this.flattenedData.push({
|
|
380
|
+
type: "item",
|
|
381
|
+
key: `item-${section.key}-${itemIndex}`,
|
|
382
|
+
sectionKey: section.key,
|
|
383
|
+
sectionIndex,
|
|
384
|
+
itemIndex,
|
|
385
|
+
item: section.data[itemIndex],
|
|
386
|
+
section
|
|
387
|
+
});
|
|
388
|
+
this.flatIndexToSection.set(flatIndex, boundary);
|
|
389
|
+
flatIndex++;
|
|
390
|
+
}
|
|
391
|
+
boundary.lastItemFlatIndex = flatIndex - 1;
|
|
392
|
+
boundary.itemCount = section.data.length;
|
|
393
|
+
if (this.hasSectionFooters) {
|
|
394
|
+
boundary.footerFlatIndex = flatIndex;
|
|
395
|
+
this.flattenedData.push({
|
|
396
|
+
type: "section-footer",
|
|
397
|
+
key: `footer-${section.key}`,
|
|
398
|
+
sectionKey: section.key,
|
|
399
|
+
sectionIndex,
|
|
400
|
+
itemIndex: -1,
|
|
401
|
+
item: null,
|
|
402
|
+
section
|
|
403
|
+
});
|
|
404
|
+
this.flatIndexToSection.set(flatIndex, boundary);
|
|
405
|
+
flatIndex++;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
this.sectionBoundaries.set(section.key, boundary);
|
|
409
|
+
}
|
|
410
|
+
this.layoutPositioner.setData(this.flattenedData);
|
|
411
|
+
return this.flattenedData;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Get the flat index for a section/item coordinate.
|
|
415
|
+
*/
|
|
416
|
+
getFlatIndex(sectionIndex, itemIndex) {
|
|
417
|
+
const section = this.sections[sectionIndex];
|
|
418
|
+
if (!section) return -1;
|
|
419
|
+
const boundary = this.sectionBoundaries.get(section.key);
|
|
420
|
+
if (!boundary) return -1;
|
|
421
|
+
if (itemIndex === -1) return boundary.headerFlatIndex;
|
|
422
|
+
if (this.collapsedSections.has(section.key)) return -1;
|
|
423
|
+
if (itemIndex < 0 || itemIndex >= section.data.length) return -1;
|
|
424
|
+
return boundary.firstItemFlatIndex + itemIndex;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Get section/item coordinates from a flat index.
|
|
428
|
+
*/
|
|
429
|
+
getSectionItemIndex(flatIndex) {
|
|
430
|
+
const item = this.flattenedData[flatIndex];
|
|
431
|
+
if (!item) return {
|
|
432
|
+
sectionIndex: -1,
|
|
433
|
+
itemIndex: -1,
|
|
434
|
+
isHeader: false,
|
|
435
|
+
isFooter: false
|
|
436
|
+
};
|
|
437
|
+
return {
|
|
438
|
+
sectionIndex: item.sectionIndex,
|
|
439
|
+
itemIndex: item.itemIndex,
|
|
440
|
+
isHeader: item.type === "section-header",
|
|
441
|
+
isFooter: item.type === "section-footer"
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Get layout info for a section.
|
|
446
|
+
*/
|
|
447
|
+
getSectionLayout(sectionKey) {
|
|
448
|
+
const boundary = this.sectionBoundaries.get(sectionKey);
|
|
449
|
+
if (!boundary) return null;
|
|
450
|
+
const headerLayout = this.layoutPositioner.getLayoutForIndex(boundary.headerFlatIndex);
|
|
451
|
+
let footerLayout = null;
|
|
452
|
+
if (boundary.footerFlatIndex !== null) footerLayout = this.layoutPositioner.getLayoutForIndex(boundary.footerFlatIndex);
|
|
453
|
+
const isCollapsed = this.collapsedSections.has(sectionKey);
|
|
454
|
+
let itemsStartOffset = this.horizontal ? headerLayout.x + headerLayout.width : headerLayout.y + headerLayout.height;
|
|
455
|
+
let itemsEndOffset = itemsStartOffset;
|
|
456
|
+
if (!isCollapsed && boundary.lastItemFlatIndex >= boundary.firstItemFlatIndex) {
|
|
457
|
+
const lastItemLayout = this.layoutPositioner.getLayoutForIndex(boundary.lastItemFlatIndex);
|
|
458
|
+
itemsEndOffset = this.horizontal ? lastItemLayout.x + lastItemLayout.width : lastItemLayout.y + lastItemLayout.height;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
sectionKey,
|
|
462
|
+
sectionIndex: boundary.sectionIndex,
|
|
463
|
+
headerLayout,
|
|
464
|
+
footerLayout,
|
|
465
|
+
itemsStartOffset,
|
|
466
|
+
itemsEndOffset,
|
|
467
|
+
itemCount: boundary.itemCount,
|
|
468
|
+
isCollapsed
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Get the section containing a given scroll offset.
|
|
473
|
+
*/
|
|
474
|
+
getSectionAtOffset(offset) {
|
|
475
|
+
for (const [sectionKey] of this.sectionBoundaries) {
|
|
476
|
+
const layout = this.getSectionLayout(sectionKey);
|
|
477
|
+
if (!layout) continue;
|
|
478
|
+
const sectionStart = this.horizontal ? layout.headerLayout.x : layout.headerLayout.y;
|
|
479
|
+
const sectionEnd = layout.itemsEndOffset;
|
|
480
|
+
if (offset >= sectionStart && offset < sectionEnd) return layout;
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Get layout info for all sections.
|
|
486
|
+
*/
|
|
487
|
+
getAllSectionLayouts() {
|
|
488
|
+
const layouts = [];
|
|
489
|
+
for (const [sectionKey] of this.sectionBoundaries) {
|
|
490
|
+
const layout = this.getSectionLayout(sectionKey);
|
|
491
|
+
if (layout) layouts.push(layout);
|
|
492
|
+
}
|
|
493
|
+
return layouts;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Set a section's collapsed state.
|
|
497
|
+
*/
|
|
498
|
+
setSectionCollapsed(sectionKey, collapsed) {
|
|
499
|
+
if (collapsed) this.collapsedSections.add(sectionKey);
|
|
500
|
+
else this.collapsedSections.delete(sectionKey);
|
|
501
|
+
this.updateData(this.sections, this.collapsedSections);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Check if a section is collapsed.
|
|
505
|
+
*/
|
|
506
|
+
isSectionCollapsed(sectionKey) {
|
|
507
|
+
return this.collapsedSections.has(sectionKey);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Get all collapsed sections.
|
|
511
|
+
*/
|
|
512
|
+
getCollapsedSections() {
|
|
513
|
+
return new Set(this.collapsedSections);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Get the layout positioner for direct layout operations.
|
|
517
|
+
*/
|
|
518
|
+
getLayoutPositioner() {
|
|
519
|
+
return this.layoutPositioner;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Factory function to create a SectionLayoutManager.
|
|
524
|
+
*/
|
|
525
|
+
function createSectionLayoutManager(layoutCache, options) {
|
|
526
|
+
return new SectionLayoutManagerImpl(layoutCache, options);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/hooks/useScrollHandler.ts
|
|
531
|
+
/**
|
|
532
|
+
* Hook for tracking scroll state with velocity and direction detection.
|
|
533
|
+
* Used for adaptive buffering and scroll optimization.
|
|
534
|
+
*/
|
|
535
|
+
function useScrollHandler(options = {}) {
|
|
536
|
+
const { horizontal = false, onScrollStateChange, onEndReached, onEndReachedThreshold = .5 } = options;
|
|
537
|
+
const scrollState = useRef({
|
|
538
|
+
offset: 0,
|
|
539
|
+
velocity: 0,
|
|
540
|
+
direction: "idle",
|
|
541
|
+
isScrolling: false,
|
|
542
|
+
contentSize: 0,
|
|
543
|
+
viewportSize: 0
|
|
544
|
+
});
|
|
545
|
+
const lastOffset = useRef(0);
|
|
546
|
+
const lastTimestamp = useRef(Date.now());
|
|
547
|
+
const endReachedCalled = useRef(false);
|
|
548
|
+
/**
|
|
549
|
+
* Calculate velocity from consecutive scroll events.
|
|
550
|
+
*/
|
|
551
|
+
const calculateVelocity = useCallback((newOffset) => {
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
const timeDelta = now - lastTimestamp.current;
|
|
554
|
+
if (timeDelta === 0) return scrollState.current.velocity;
|
|
555
|
+
const offsetDelta = newOffset - lastOffset.current;
|
|
556
|
+
const velocity = offsetDelta / timeDelta;
|
|
557
|
+
lastOffset.current = newOffset;
|
|
558
|
+
lastTimestamp.current = now;
|
|
559
|
+
return velocity;
|
|
560
|
+
}, []);
|
|
561
|
+
/**
|
|
562
|
+
* Determine scroll direction from velocity.
|
|
563
|
+
*/
|
|
564
|
+
const getDirection = useCallback((velocity) => {
|
|
565
|
+
if (Math.abs(velocity) < .1) return "idle";
|
|
566
|
+
return velocity > 0 ? "forward" : "backward";
|
|
567
|
+
}, []);
|
|
568
|
+
/**
|
|
569
|
+
* Check if we've reached the end and should trigger callback.
|
|
570
|
+
*/
|
|
571
|
+
const checkEndReached = useCallback(() => {
|
|
572
|
+
if (!onEndReached) return;
|
|
573
|
+
const { offset, contentSize, viewportSize } = scrollState.current;
|
|
574
|
+
const distanceFromEnd = contentSize - offset - viewportSize;
|
|
575
|
+
const threshold = viewportSize * onEndReachedThreshold;
|
|
576
|
+
if (distanceFromEnd <= threshold && !endReachedCalled.current) {
|
|
577
|
+
endReachedCalled.current = true;
|
|
578
|
+
onEndReached(distanceFromEnd);
|
|
579
|
+
} else if (distanceFromEnd > threshold) endReachedCalled.current = false;
|
|
580
|
+
}, [onEndReached, onEndReachedThreshold]);
|
|
581
|
+
/**
|
|
582
|
+
* Update scroll state and notify listeners.
|
|
583
|
+
*/
|
|
584
|
+
const updateScrollState = useCallback((event, isScrolling) => {
|
|
585
|
+
const { contentOffset, contentSize, layoutMeasurement } = event;
|
|
586
|
+
const offset = horizontal ? contentOffset.x : contentOffset.y;
|
|
587
|
+
const size = horizontal ? contentSize.width : contentSize.height;
|
|
588
|
+
const viewport = horizontal ? layoutMeasurement.width : layoutMeasurement.height;
|
|
589
|
+
const velocity = calculateVelocity(offset);
|
|
590
|
+
const direction = getDirection(velocity);
|
|
591
|
+
scrollState.current = {
|
|
592
|
+
offset,
|
|
593
|
+
velocity,
|
|
594
|
+
direction,
|
|
595
|
+
isScrolling,
|
|
596
|
+
contentSize: size,
|
|
597
|
+
viewportSize: viewport
|
|
598
|
+
};
|
|
599
|
+
onScrollStateChange?.(scrollState.current);
|
|
600
|
+
checkEndReached();
|
|
601
|
+
}, [
|
|
602
|
+
horizontal,
|
|
603
|
+
calculateVelocity,
|
|
604
|
+
getDirection,
|
|
605
|
+
onScrollStateChange,
|
|
606
|
+
checkEndReached
|
|
607
|
+
]);
|
|
608
|
+
const onScroll = useCallback((event) => {
|
|
609
|
+
updateScrollState(event.nativeEvent, scrollState.current.isScrolling);
|
|
610
|
+
}, [updateScrollState]);
|
|
611
|
+
const onScrollBeginDrag = useCallback((event) => {
|
|
612
|
+
scrollState.current.isScrolling = true;
|
|
613
|
+
updateScrollState(event.nativeEvent, true);
|
|
614
|
+
}, [updateScrollState]);
|
|
615
|
+
const onScrollEndDrag = useCallback((event) => {
|
|
616
|
+
updateScrollState(event.nativeEvent, scrollState.current.isScrolling);
|
|
617
|
+
}, [updateScrollState]);
|
|
618
|
+
const onMomentumScrollBegin = useCallback((event) => {
|
|
619
|
+
scrollState.current.isScrolling = true;
|
|
620
|
+
updateScrollState(event.nativeEvent, true);
|
|
621
|
+
}, [updateScrollState]);
|
|
622
|
+
const onMomentumScrollEnd = useCallback((event) => {
|
|
623
|
+
scrollState.current.isScrolling = false;
|
|
624
|
+
scrollState.current.direction = "idle";
|
|
625
|
+
scrollState.current.velocity = 0;
|
|
626
|
+
updateScrollState(event.nativeEvent, false);
|
|
627
|
+
}, [updateScrollState]);
|
|
628
|
+
const onContentSizeChange = useCallback((width, height) => {
|
|
629
|
+
scrollState.current.contentSize = horizontal ? width : height;
|
|
630
|
+
}, [horizontal]);
|
|
631
|
+
return {
|
|
632
|
+
scrollState,
|
|
633
|
+
onScroll,
|
|
634
|
+
onScrollBeginDrag,
|
|
635
|
+
onScrollEndDrag,
|
|
636
|
+
onMomentumScrollBegin,
|
|
637
|
+
onMomentumScrollEnd,
|
|
638
|
+
onContentSizeChange
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Hook to calculate adaptive draw distance based on scroll velocity.
|
|
643
|
+
* Increases buffer for fast scrolling to reduce blank areas.
|
|
644
|
+
*/
|
|
645
|
+
function useAdaptiveDrawDistance(baseDistance, scrollVelocity) {
|
|
646
|
+
return useMemo(() => {
|
|
647
|
+
const absVelocity = Math.abs(scrollVelocity);
|
|
648
|
+
if (absVelocity < SCROLL_VELOCITY_THRESHOLD) return baseDistance;
|
|
649
|
+
const velocityMultiplier = Math.min(3, 1 + absVelocity / SCROLL_VELOCITY_THRESHOLD);
|
|
650
|
+
return Math.round(baseDistance * velocityMultiplier);
|
|
651
|
+
}, [baseDistance, scrollVelocity]);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region src/core/CellRecycler.ts
|
|
656
|
+
/**
|
|
657
|
+
* CellRecycler manages pools of recycled cell instances per item type.
|
|
658
|
+
* This is the core of FlashList-style performance - reusing view components
|
|
659
|
+
* instead of destroying and recreating them.
|
|
660
|
+
*/
|
|
661
|
+
var CellRecyclerImpl = class {
|
|
662
|
+
pools = new Map();
|
|
663
|
+
inUse = new Map();
|
|
664
|
+
cellCounter = 0;
|
|
665
|
+
constructor(defaultMaxPoolSize = DEFAULT_MAX_POOL_SIZE) {
|
|
666
|
+
this.defaultMaxPoolSize = defaultMaxPoolSize;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Acquire a cell from the pool for the given type.
|
|
670
|
+
* Returns null if no recycled cells are available (caller should create new).
|
|
671
|
+
*/
|
|
672
|
+
acquireCell(type, flatIndex) {
|
|
673
|
+
const pool = this.pools.get(type);
|
|
674
|
+
if (!pool || pool.cells.length === 0) return null;
|
|
675
|
+
const cell = pool.cells.pop();
|
|
676
|
+
cell.flatIndex = flatIndex;
|
|
677
|
+
let inUseSet = this.inUse.get(type);
|
|
678
|
+
if (!inUseSet) {
|
|
679
|
+
inUseSet = new Set();
|
|
680
|
+
this.inUse.set(type, inUseSet);
|
|
681
|
+
}
|
|
682
|
+
inUseSet.add(cell.key);
|
|
683
|
+
return cell;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Release a cell back to its pool for recycling.
|
|
687
|
+
*/
|
|
688
|
+
releaseCell(cell) {
|
|
689
|
+
const { itemType: type, key } = cell;
|
|
690
|
+
const inUseSet = this.inUse.get(type);
|
|
691
|
+
if (inUseSet) inUseSet.delete(key);
|
|
692
|
+
let pool = this.pools.get(type);
|
|
693
|
+
if (!pool) {
|
|
694
|
+
pool = {
|
|
695
|
+
type,
|
|
696
|
+
cells: [],
|
|
697
|
+
maxSize: this.defaultMaxPoolSize
|
|
698
|
+
};
|
|
699
|
+
this.pools.set(type, pool);
|
|
700
|
+
}
|
|
701
|
+
if (pool.cells.length < pool.maxSize) pool.cells.push(cell);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Clear all recycled cells from all pools.
|
|
705
|
+
* Useful when data changes significantly.
|
|
706
|
+
*/
|
|
707
|
+
clearPools() {
|
|
708
|
+
this.pools.clear();
|
|
709
|
+
this.inUse.clear();
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Set the maximum pool size for a specific item type.
|
|
713
|
+
*/
|
|
714
|
+
setMaxPoolSize(type, size) {
|
|
715
|
+
let pool = this.pools.get(type);
|
|
716
|
+
if (!pool) {
|
|
717
|
+
pool = {
|
|
718
|
+
type,
|
|
719
|
+
cells: [],
|
|
720
|
+
maxSize: size
|
|
721
|
+
};
|
|
722
|
+
this.pools.set(type, pool);
|
|
723
|
+
} else {
|
|
724
|
+
pool.maxSize = size;
|
|
725
|
+
while (pool.cells.length > size) pool.cells.pop();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Get statistics about pool usage for debugging.
|
|
730
|
+
*/
|
|
731
|
+
getPoolStats() {
|
|
732
|
+
const stats = new Map();
|
|
733
|
+
const allTypes = new Set([...this.pools.keys(), ...this.inUse.keys()]);
|
|
734
|
+
for (const type of allTypes) {
|
|
735
|
+
const pool = this.pools.get(type);
|
|
736
|
+
const inUseSet = this.inUse.get(type);
|
|
737
|
+
stats.set(type, {
|
|
738
|
+
available: pool?.cells.length ?? 0,
|
|
739
|
+
inUse: inUseSet?.size ?? 0
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
return stats;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Generate a unique key for a new cell.
|
|
746
|
+
*/
|
|
747
|
+
generateCellKey(type) {
|
|
748
|
+
return `${type}-${++this.cellCounter}`;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Create a new cell (when pool is empty).
|
|
752
|
+
*/
|
|
753
|
+
createCell(type, flatIndex) {
|
|
754
|
+
const key = this.generateCellKey(type);
|
|
755
|
+
const cell = {
|
|
756
|
+
key,
|
|
757
|
+
itemType: type,
|
|
758
|
+
flatIndex
|
|
759
|
+
};
|
|
760
|
+
let inUseSet = this.inUse.get(type);
|
|
761
|
+
if (!inUseSet) {
|
|
762
|
+
inUseSet = new Set();
|
|
763
|
+
this.inUse.set(type, inUseSet);
|
|
764
|
+
}
|
|
765
|
+
inUseSet.add(key);
|
|
766
|
+
return cell;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Get or create a cell - the main method used during rendering.
|
|
770
|
+
* First tries to acquire from pool, then creates new if needed.
|
|
771
|
+
*/
|
|
772
|
+
getCell(type, flatIndex) {
|
|
773
|
+
const recycled = this.acquireCell(type, flatIndex);
|
|
774
|
+
if (recycled) return recycled;
|
|
775
|
+
return this.createCell(type, flatIndex);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
/**
|
|
779
|
+
* Factory function to create a CellRecycler instance.
|
|
780
|
+
*/
|
|
781
|
+
function createCellRecycler(defaultMaxPoolSize = DEFAULT_MAX_POOL_SIZE) {
|
|
782
|
+
return new CellRecyclerImpl(defaultMaxPoolSize);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/hooks/useRecycler.ts
|
|
787
|
+
/**
|
|
788
|
+
* Hook for managing cell recycling state.
|
|
789
|
+
* Provides methods to acquire and release cells based on visibility.
|
|
790
|
+
*/
|
|
791
|
+
function useRecycler(options) {
|
|
792
|
+
const { flattenedData, getItemType, maxPoolSize } = options;
|
|
793
|
+
const recycler = useRef(null);
|
|
794
|
+
if (!recycler.current) recycler.current = createCellRecycler(maxPoolSize);
|
|
795
|
+
const activeCells = useRef(new Map());
|
|
796
|
+
const visibleRange = useRef({
|
|
797
|
+
start: 0,
|
|
798
|
+
end: -1
|
|
799
|
+
});
|
|
800
|
+
/**
|
|
801
|
+
* Get the item type for a flat index.
|
|
802
|
+
*/
|
|
803
|
+
const getTypeForIndex = useCallback((flatIndex) => {
|
|
804
|
+
const item = flattenedData[flatIndex];
|
|
805
|
+
if (!item) return DEFAULT_ITEM_TYPE;
|
|
806
|
+
if (item.type === "section-header") return SECTION_HEADER_TYPE;
|
|
807
|
+
if (item.type === "section-footer") return SECTION_FOOTER_TYPE;
|
|
808
|
+
if (getItemType && item.item !== null) return getItemType(item.item, item.itemIndex);
|
|
809
|
+
return DEFAULT_ITEM_TYPE;
|
|
810
|
+
}, [flattenedData, getItemType]);
|
|
811
|
+
/**
|
|
812
|
+
* Get or create a cell for a flat index.
|
|
813
|
+
*/
|
|
814
|
+
const getCell = useCallback((flatIndex) => {
|
|
815
|
+
const existing = activeCells.current.get(flatIndex);
|
|
816
|
+
if (existing) return existing;
|
|
817
|
+
const type = getTypeForIndex(flatIndex);
|
|
818
|
+
const cell = recycler.current.getCell(type, flatIndex);
|
|
819
|
+
activeCells.current.set(flatIndex, cell);
|
|
820
|
+
return cell;
|
|
821
|
+
}, [getTypeForIndex]);
|
|
822
|
+
/**
|
|
823
|
+
* Release a cell back to the pool.
|
|
824
|
+
*/
|
|
825
|
+
const releaseCell = useCallback((cell) => {
|
|
826
|
+
activeCells.current.delete(cell.flatIndex);
|
|
827
|
+
recycler.current.releaseCell(cell);
|
|
828
|
+
}, []);
|
|
829
|
+
/**
|
|
830
|
+
* Update the visible range and recycle cells outside it.
|
|
831
|
+
*/
|
|
832
|
+
const updateVisibleRange = useCallback((startIndex, endIndex) => {
|
|
833
|
+
const prevStart = visibleRange.current.start;
|
|
834
|
+
const prevEnd = visibleRange.current.end;
|
|
835
|
+
visibleRange.current = {
|
|
836
|
+
start: startIndex,
|
|
837
|
+
end: endIndex
|
|
838
|
+
};
|
|
839
|
+
for (const [flatIndex, cell] of activeCells.current) if (flatIndex < startIndex || flatIndex > endIndex) releaseCell(cell);
|
|
840
|
+
}, [releaseCell]);
|
|
841
|
+
/**
|
|
842
|
+
* Clear all pools (e.g., on data change).
|
|
843
|
+
*/
|
|
844
|
+
const clearPools = useCallback(() => {
|
|
845
|
+
activeCells.current.clear();
|
|
846
|
+
recycler.current.clearPools();
|
|
847
|
+
}, []);
|
|
848
|
+
/**
|
|
849
|
+
* Get pool statistics for debugging.
|
|
850
|
+
*/
|
|
851
|
+
const getPoolStats = useCallback(() => {
|
|
852
|
+
return recycler.current.getPoolStats();
|
|
853
|
+
}, []);
|
|
854
|
+
const dataLength = flattenedData.length;
|
|
855
|
+
useEffect(() => {
|
|
856
|
+
if (activeCells.current.size > dataLength * 2) clearPools();
|
|
857
|
+
}, [dataLength, clearPools]);
|
|
858
|
+
return {
|
|
859
|
+
getCell,
|
|
860
|
+
releaseCell,
|
|
861
|
+
updateVisibleRange,
|
|
862
|
+
clearPools,
|
|
863
|
+
getPoolStats
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Hook to determine which item type to use for rendering.
|
|
868
|
+
* Returns a stable function that maps flat indices to type strings.
|
|
869
|
+
*/
|
|
870
|
+
function useItemTypeResolver(flattenedData, getItemType) {
|
|
871
|
+
return useCallback((flatIndex) => {
|
|
872
|
+
const item = flattenedData[flatIndex];
|
|
873
|
+
if (!item) return DEFAULT_ITEM_TYPE;
|
|
874
|
+
if (item.type === "section-header") return SECTION_HEADER_TYPE;
|
|
875
|
+
if (item.type === "section-footer") return SECTION_FOOTER_TYPE;
|
|
876
|
+
if (getItemType && item.item !== null) return getItemType(item.item, item.itemIndex);
|
|
877
|
+
return DEFAULT_ITEM_TYPE;
|
|
878
|
+
}, [flattenedData, getItemType]);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
//#endregion
|
|
882
|
+
//#region src/hooks/useStickyHeader.ts
|
|
883
|
+
/**
|
|
884
|
+
* Hook for computing sticky header positioning.
|
|
885
|
+
* Handles the "pushing" effect when the next section header approaches.
|
|
886
|
+
*/
|
|
887
|
+
function useStickyHeader(options) {
|
|
888
|
+
const { sectionLayouts, scrollOffset, viewportHeight, horizontal = false, enabled = true } = options;
|
|
889
|
+
return useMemo(() => {
|
|
890
|
+
if (!enabled || sectionLayouts.length === 0) return {
|
|
891
|
+
sectionKey: null,
|
|
892
|
+
sectionIndex: -1,
|
|
893
|
+
translateY: 0,
|
|
894
|
+
isSticky: false,
|
|
895
|
+
headerLayout: null
|
|
896
|
+
};
|
|
897
|
+
let currentSection = null;
|
|
898
|
+
let nextSection = null;
|
|
899
|
+
for (let i = 0; i < sectionLayouts.length; i++) {
|
|
900
|
+
const section = sectionLayouts[i];
|
|
901
|
+
const headerStart = horizontal ? section.headerLayout.x : section.headerLayout.y;
|
|
902
|
+
if (headerStart <= scrollOffset) {
|
|
903
|
+
currentSection = section;
|
|
904
|
+
nextSection = sectionLayouts[i + 1] ?? null;
|
|
905
|
+
} else break;
|
|
906
|
+
}
|
|
907
|
+
if (!currentSection) return {
|
|
908
|
+
sectionKey: null,
|
|
909
|
+
sectionIndex: -1,
|
|
910
|
+
translateY: 0,
|
|
911
|
+
isSticky: false,
|
|
912
|
+
headerLayout: null
|
|
913
|
+
};
|
|
914
|
+
const headerSize = horizontal ? currentSection.headerLayout.width : currentSection.headerLayout.height;
|
|
915
|
+
let translateY = 0;
|
|
916
|
+
if (nextSection) {
|
|
917
|
+
const nextHeaderStart = horizontal ? nextSection.headerLayout.x : nextSection.headerLayout.y;
|
|
918
|
+
const pushPoint = nextHeaderStart - headerSize;
|
|
919
|
+
if (scrollOffset > pushPoint) translateY = pushPoint - scrollOffset;
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
sectionKey: currentSection.sectionKey,
|
|
923
|
+
sectionIndex: currentSection.sectionIndex,
|
|
924
|
+
translateY,
|
|
925
|
+
isSticky: true,
|
|
926
|
+
headerLayout: currentSection.headerLayout
|
|
927
|
+
};
|
|
928
|
+
}, [
|
|
929
|
+
sectionLayouts,
|
|
930
|
+
scrollOffset,
|
|
931
|
+
viewportHeight,
|
|
932
|
+
horizontal,
|
|
933
|
+
enabled
|
|
934
|
+
]);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Hook for tracking multiple sticky headers (e.g., for multi-level sections).
|
|
938
|
+
*/
|
|
939
|
+
function useMultipleStickyHeaders(sectionLayouts, scrollOffset, levels = 1) {
|
|
940
|
+
return useMemo(() => {
|
|
941
|
+
const states = [];
|
|
942
|
+
if (sectionLayouts.length === 0) return states;
|
|
943
|
+
let currentSection = null;
|
|
944
|
+
let nextSection = null;
|
|
945
|
+
for (let i = 0; i < sectionLayouts.length; i++) {
|
|
946
|
+
const section = sectionLayouts[i];
|
|
947
|
+
const headerStart = section.headerLayout.y;
|
|
948
|
+
if (headerStart <= scrollOffset) {
|
|
949
|
+
currentSection = section;
|
|
950
|
+
nextSection = sectionLayouts[i + 1] ?? null;
|
|
951
|
+
} else break;
|
|
952
|
+
}
|
|
953
|
+
if (currentSection) {
|
|
954
|
+
const headerSize = currentSection.headerLayout.height;
|
|
955
|
+
let translateY = 0;
|
|
956
|
+
if (nextSection) {
|
|
957
|
+
const nextHeaderStart = nextSection.headerLayout.y;
|
|
958
|
+
const pushPoint = nextHeaderStart - headerSize;
|
|
959
|
+
if (scrollOffset > pushPoint) translateY = pushPoint - scrollOffset;
|
|
960
|
+
}
|
|
961
|
+
states.push({
|
|
962
|
+
sectionKey: currentSection.sectionKey,
|
|
963
|
+
sectionIndex: currentSection.sectionIndex,
|
|
964
|
+
translateY,
|
|
965
|
+
isSticky: true,
|
|
966
|
+
headerLayout: currentSection.headerLayout
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return states;
|
|
970
|
+
}, [
|
|
971
|
+
sectionLayouts,
|
|
972
|
+
scrollOffset,
|
|
973
|
+
levels
|
|
974
|
+
]);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Hook for determining sticky header opacity during transitions.
|
|
978
|
+
*/
|
|
979
|
+
function useStickyHeaderOpacity(stickyState, fadeDistance = 20) {
|
|
980
|
+
return useMemo(() => {
|
|
981
|
+
if (!stickyState.isSticky) return 0;
|
|
982
|
+
if (stickyState.translateY >= 0) return 1;
|
|
983
|
+
const fadeProgress = Math.abs(stickyState.translateY) / fadeDistance;
|
|
984
|
+
return Math.max(0, 1 - fadeProgress);
|
|
985
|
+
}, [
|
|
986
|
+
stickyState.isSticky,
|
|
987
|
+
stickyState.translateY,
|
|
988
|
+
fadeDistance
|
|
989
|
+
]);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/core/ViewabilityTracker.ts
|
|
994
|
+
var ViewabilityTrackerImpl = class {
|
|
995
|
+
flattenedData = [];
|
|
996
|
+
scrollOffset = 0;
|
|
997
|
+
viewportSize = 0;
|
|
998
|
+
trackedItems = new Map();
|
|
999
|
+
currentlyViewable = new Set();
|
|
1000
|
+
callbacks = new Set();
|
|
1001
|
+
hasInteracted = false;
|
|
1002
|
+
pendingUpdate = null;
|
|
1003
|
+
constructor(layoutPositioner, flattenedData, config = {}, horizontal = false) {
|
|
1004
|
+
this.layoutPositioner = layoutPositioner;
|
|
1005
|
+
this.flattenedData = flattenedData;
|
|
1006
|
+
this.horizontal = horizontal;
|
|
1007
|
+
this.config = {
|
|
1008
|
+
minimumViewTime: config.minimumViewTime ?? DEFAULT_VIEWABILITY_CONFIG.minimumViewTime,
|
|
1009
|
+
viewAreaCoveragePercentThreshold: config.viewAreaCoveragePercentThreshold ?? DEFAULT_VIEWABILITY_CONFIG.viewAreaCoveragePercentThreshold,
|
|
1010
|
+
itemVisiblePercentThreshold: config.itemVisiblePercentThreshold ?? DEFAULT_VIEWABILITY_CONFIG.itemVisiblePercentThreshold,
|
|
1011
|
+
waitForInteraction: config.waitForInteraction ?? DEFAULT_VIEWABILITY_CONFIG.waitForInteraction
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Update flattened data when it changes.
|
|
1016
|
+
*/
|
|
1017
|
+
setData(flattenedData) {
|
|
1018
|
+
this.flattenedData = flattenedData;
|
|
1019
|
+
this.trackedItems.clear();
|
|
1020
|
+
this.currentlyViewable.clear();
|
|
1021
|
+
this.scheduleUpdate();
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Update the current scroll offset.
|
|
1025
|
+
*/
|
|
1026
|
+
updateScrollOffset(offset) {
|
|
1027
|
+
this.scrollOffset = offset;
|
|
1028
|
+
this.scheduleUpdate();
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Update the viewport size.
|
|
1032
|
+
*/
|
|
1033
|
+
setViewportSize(size) {
|
|
1034
|
+
if (this.viewportSize !== size) {
|
|
1035
|
+
this.viewportSize = size;
|
|
1036
|
+
this.scheduleUpdate();
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Record that user has interacted with the list.
|
|
1041
|
+
*/
|
|
1042
|
+
recordInteraction() {
|
|
1043
|
+
if (!this.hasInteracted) {
|
|
1044
|
+
this.hasInteracted = true;
|
|
1045
|
+
this.scheduleUpdate();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Schedule a viewability update (debounced).
|
|
1050
|
+
*/
|
|
1051
|
+
scheduleUpdate() {
|
|
1052
|
+
if (this.pendingUpdate) return;
|
|
1053
|
+
this.pendingUpdate = setTimeout(() => {
|
|
1054
|
+
this.pendingUpdate = null;
|
|
1055
|
+
this.computeViewability();
|
|
1056
|
+
}, 0);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Compute which items are viewable based on current scroll position.
|
|
1060
|
+
*/
|
|
1061
|
+
computeViewability() {
|
|
1062
|
+
if (this.config.waitForInteraction && !this.hasInteracted) return;
|
|
1063
|
+
const now = Date.now();
|
|
1064
|
+
const newViewable = new Set();
|
|
1065
|
+
const changed = [];
|
|
1066
|
+
const { startIndex, endIndex } = this.layoutPositioner.getVisibleRange(this.scrollOffset, this.viewportSize, 0);
|
|
1067
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
1068
|
+
const layout = this.layoutPositioner.getLayoutForIndex(i);
|
|
1069
|
+
const isViewable = this.isItemViewable(i, layout);
|
|
1070
|
+
if (isViewable) {
|
|
1071
|
+
newViewable.add(i);
|
|
1072
|
+
let tracked = this.trackedItems.get(i);
|
|
1073
|
+
if (!tracked) {
|
|
1074
|
+
tracked = {
|
|
1075
|
+
flatIndex: i,
|
|
1076
|
+
isViewable: false,
|
|
1077
|
+
lastVisibleTime: now,
|
|
1078
|
+
becameVisibleAt: now
|
|
1079
|
+
};
|
|
1080
|
+
this.trackedItems.set(i, tracked);
|
|
1081
|
+
}
|
|
1082
|
+
if (!tracked.isViewable && tracked.becameVisibleAt !== null) {
|
|
1083
|
+
const visibleDuration = now - tracked.becameVisibleAt;
|
|
1084
|
+
if (visibleDuration >= this.config.minimumViewTime) {
|
|
1085
|
+
tracked.isViewable = true;
|
|
1086
|
+
changed.push(this.createViewToken(i, true));
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
tracked.lastVisibleTime = now;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
for (const flatIndex of this.currentlyViewable) if (!newViewable.has(flatIndex)) {
|
|
1093
|
+
const tracked = this.trackedItems.get(flatIndex);
|
|
1094
|
+
if (tracked && tracked.isViewable) {
|
|
1095
|
+
tracked.isViewable = false;
|
|
1096
|
+
tracked.becameVisibleAt = null;
|
|
1097
|
+
changed.push(this.createViewToken(flatIndex, false));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
this.currentlyViewable = newViewable;
|
|
1101
|
+
if (changed.length > 0) {
|
|
1102
|
+
const viewableItems = Array.from(this.currentlyViewable).filter((i) => this.trackedItems.get(i)?.isViewable).map((i) => this.createViewToken(i, true));
|
|
1103
|
+
for (const callback of this.callbacks) callback({
|
|
1104
|
+
viewableItems,
|
|
1105
|
+
changed
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Check if an item meets viewability thresholds.
|
|
1111
|
+
*/
|
|
1112
|
+
isItemViewable(flatIndex, layout) {
|
|
1113
|
+
const itemStart = this.horizontal ? layout.x : layout.y;
|
|
1114
|
+
const itemSize = this.horizontal ? layout.width : layout.height;
|
|
1115
|
+
const itemEnd = itemStart + itemSize;
|
|
1116
|
+
const viewportStart = this.scrollOffset;
|
|
1117
|
+
const viewportEnd = this.scrollOffset + this.viewportSize;
|
|
1118
|
+
const visibleStart = Math.max(itemStart, viewportStart);
|
|
1119
|
+
const visibleEnd = Math.min(itemEnd, viewportEnd);
|
|
1120
|
+
const visibleSize = Math.max(0, visibleEnd - visibleStart);
|
|
1121
|
+
if (this.config.itemVisiblePercentThreshold > 0) {
|
|
1122
|
+
const visiblePercent = itemSize > 0 ? visibleSize / itemSize * 100 : 0;
|
|
1123
|
+
if (visiblePercent < this.config.itemVisiblePercentThreshold) return false;
|
|
1124
|
+
}
|
|
1125
|
+
if (this.config.viewAreaCoveragePercentThreshold > 0) {
|
|
1126
|
+
const coveragePercent = this.viewportSize > 0 ? visibleSize / this.viewportSize * 100 : 0;
|
|
1127
|
+
if (coveragePercent < this.config.viewAreaCoveragePercentThreshold) return false;
|
|
1128
|
+
}
|
|
1129
|
+
return visibleSize > 0;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Create a ViewToken for an item.
|
|
1133
|
+
*/
|
|
1134
|
+
createViewToken(flatIndex, isViewable) {
|
|
1135
|
+
const item = this.flattenedData[flatIndex];
|
|
1136
|
+
return {
|
|
1137
|
+
item: item?.item ?? null,
|
|
1138
|
+
key: item?.key ?? `item-${flatIndex}`,
|
|
1139
|
+
index: flatIndex,
|
|
1140
|
+
isViewable,
|
|
1141
|
+
section: item?.section
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Get all currently visible indices.
|
|
1146
|
+
*/
|
|
1147
|
+
getVisibleIndices() {
|
|
1148
|
+
return Array.from(this.currentlyViewable);
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Check if a specific index is visible.
|
|
1152
|
+
*/
|
|
1153
|
+
isIndexVisible(index) {
|
|
1154
|
+
return this.currentlyViewable.has(index);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Get the first visible index.
|
|
1158
|
+
*/
|
|
1159
|
+
getFirstVisibleIndex() {
|
|
1160
|
+
if (this.currentlyViewable.size === 0) return -1;
|
|
1161
|
+
return Math.min(...this.currentlyViewable);
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Get the last visible index.
|
|
1165
|
+
*/
|
|
1166
|
+
getLastVisibleIndex() {
|
|
1167
|
+
if (this.currentlyViewable.size === 0) return -1;
|
|
1168
|
+
return Math.max(...this.currentlyViewable);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Register a callback for viewability changes.
|
|
1172
|
+
*/
|
|
1173
|
+
onViewableItemsChanged(callback) {
|
|
1174
|
+
this.callbacks.add(callback);
|
|
1175
|
+
return () => {
|
|
1176
|
+
this.callbacks.delete(callback);
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Clean up resources.
|
|
1181
|
+
*/
|
|
1182
|
+
dispose() {
|
|
1183
|
+
if (this.pendingUpdate) {
|
|
1184
|
+
clearTimeout(this.pendingUpdate);
|
|
1185
|
+
this.pendingUpdate = null;
|
|
1186
|
+
}
|
|
1187
|
+
this.callbacks.clear();
|
|
1188
|
+
this.trackedItems.clear();
|
|
1189
|
+
this.currentlyViewable.clear();
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
/**
|
|
1193
|
+
* Factory function to create a ViewabilityTracker.
|
|
1194
|
+
*/
|
|
1195
|
+
function createViewabilityTracker(layoutPositioner, flattenedData, config, horizontal) {
|
|
1196
|
+
return new ViewabilityTrackerImpl(layoutPositioner, flattenedData, config, horizontal);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
//#endregion
|
|
1200
|
+
//#region src/hooks/useViewability.ts
|
|
1201
|
+
/**
|
|
1202
|
+
* Hook for tracking item viewability and triggering callbacks.
|
|
1203
|
+
*/
|
|
1204
|
+
function useViewability(options) {
|
|
1205
|
+
const { flattenedData, layoutPositioner, scrollOffset, viewportSize, horizontal = false, viewabilityConfig = DEFAULT_VIEWABILITY_CONFIG, onViewableItemsChanged } = options;
|
|
1206
|
+
const trackerRef = useRef(null);
|
|
1207
|
+
if (!trackerRef.current) trackerRef.current = createViewabilityTracker(layoutPositioner, flattenedData, viewabilityConfig, horizontal);
|
|
1208
|
+
const tracker = trackerRef.current;
|
|
1209
|
+
useEffect(() => {
|
|
1210
|
+
tracker.setData(flattenedData);
|
|
1211
|
+
}, [tracker, flattenedData]);
|
|
1212
|
+
useEffect(() => {
|
|
1213
|
+
tracker.setViewportSize(viewportSize);
|
|
1214
|
+
}, [tracker, viewportSize]);
|
|
1215
|
+
useEffect(() => {
|
|
1216
|
+
tracker.updateScrollOffset(scrollOffset);
|
|
1217
|
+
}, [tracker, scrollOffset]);
|
|
1218
|
+
useEffect(() => {
|
|
1219
|
+
if (!onViewableItemsChanged) return;
|
|
1220
|
+
const unsubscribe = tracker.onViewableItemsChanged(onViewableItemsChanged);
|
|
1221
|
+
return unsubscribe;
|
|
1222
|
+
}, [tracker, onViewableItemsChanged]);
|
|
1223
|
+
useEffect(() => {
|
|
1224
|
+
return () => {
|
|
1225
|
+
tracker.dispose();
|
|
1226
|
+
};
|
|
1227
|
+
}, [tracker]);
|
|
1228
|
+
const recordInteraction = useCallback(() => {
|
|
1229
|
+
tracker.recordInteraction();
|
|
1230
|
+
}, [tracker]);
|
|
1231
|
+
const getVisibleItems = useCallback(() => {
|
|
1232
|
+
return tracker.getVisibleIndices().map((flatIndex) => {
|
|
1233
|
+
const item = flattenedData[flatIndex];
|
|
1234
|
+
return {
|
|
1235
|
+
item: item?.item ?? null,
|
|
1236
|
+
key: item?.key ?? `item-${flatIndex}`,
|
|
1237
|
+
index: flatIndex,
|
|
1238
|
+
isViewable: true,
|
|
1239
|
+
section: item?.section
|
|
1240
|
+
};
|
|
1241
|
+
});
|
|
1242
|
+
}, [tracker, flattenedData]);
|
|
1243
|
+
const visibleState = useMemo(() => {
|
|
1244
|
+
const indices = tracker.getVisibleIndices();
|
|
1245
|
+
return {
|
|
1246
|
+
visibleIndices: indices,
|
|
1247
|
+
firstVisibleIndex: tracker.getFirstVisibleIndex(),
|
|
1248
|
+
lastVisibleIndex: tracker.getLastVisibleIndex()
|
|
1249
|
+
};
|
|
1250
|
+
}, [
|
|
1251
|
+
tracker,
|
|
1252
|
+
scrollOffset,
|
|
1253
|
+
viewportSize
|
|
1254
|
+
]);
|
|
1255
|
+
return {
|
|
1256
|
+
...visibleState,
|
|
1257
|
+
getVisibleItems,
|
|
1258
|
+
recordInteraction
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Hook for handling multiple viewability configs with different callbacks.
|
|
1263
|
+
*/
|
|
1264
|
+
function useMultipleViewabilityConfigs(flattenedData, layoutPositioner, scrollOffset, viewportSize, configs, horizontal = false) {
|
|
1265
|
+
const trackersRef = useRef([]);
|
|
1266
|
+
useEffect(() => {
|
|
1267
|
+
trackersRef.current = configs.map((config) => createViewabilityTracker(layoutPositioner, flattenedData, config.viewabilityConfig, horizontal));
|
|
1268
|
+
const unsubscribes = trackersRef.current.map((tracker, index) => tracker.onViewableItemsChanged(configs[index].onViewableItemsChanged));
|
|
1269
|
+
return () => {
|
|
1270
|
+
unsubscribes.forEach((unsub) => unsub());
|
|
1271
|
+
trackersRef.current.forEach((tracker) => tracker.dispose());
|
|
1272
|
+
trackersRef.current = [];
|
|
1273
|
+
};
|
|
1274
|
+
}, [
|
|
1275
|
+
configs,
|
|
1276
|
+
flattenedData,
|
|
1277
|
+
layoutPositioner,
|
|
1278
|
+
horizontal
|
|
1279
|
+
]);
|
|
1280
|
+
useEffect(() => {
|
|
1281
|
+
trackersRef.current.forEach((tracker) => tracker.setData(flattenedData));
|
|
1282
|
+
}, [flattenedData]);
|
|
1283
|
+
useEffect(() => {
|
|
1284
|
+
trackersRef.current.forEach((tracker) => tracker.setViewportSize(viewportSize));
|
|
1285
|
+
}, [viewportSize]);
|
|
1286
|
+
useEffect(() => {
|
|
1287
|
+
trackersRef.current.forEach((tracker) => tracker.updateScrollOffset(scrollOffset));
|
|
1288
|
+
}, [scrollOffset]);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region src/state/SectionFlowContext.tsx
|
|
1293
|
+
const SectionFlowContext = createContext(void 0);
|
|
1294
|
+
/**
|
|
1295
|
+
* Hook to access SectionFlow context.
|
|
1296
|
+
* Must be used within a SectionFlowProvider.
|
|
1297
|
+
*/
|
|
1298
|
+
function useSectionFlowContext() {
|
|
1299
|
+
const context = useContext(SectionFlowContext);
|
|
1300
|
+
if (!context) throw new Error("useSectionFlowContext must be used within a SectionFlowProvider");
|
|
1301
|
+
return context;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Provider component for SectionFlow context.
|
|
1305
|
+
*/
|
|
1306
|
+
function SectionFlowProvider({ children, sections, flattenedData, layoutManager, layoutCache, horizontal, collapsedSections, scrollOffset, viewportWidth, viewportHeight, stickySectionHeadersEnabled, estimatedItemSize, drawDistance, debug, renderItem, renderSectionHeader, renderSectionFooter, onCellMeasured, onSectionToggle, getItemType }) {
|
|
1307
|
+
const value = useMemo(() => ({
|
|
1308
|
+
sections,
|
|
1309
|
+
flattenedData,
|
|
1310
|
+
layoutManager,
|
|
1311
|
+
layoutCache,
|
|
1312
|
+
horizontal,
|
|
1313
|
+
collapsedSections,
|
|
1314
|
+
scrollOffset,
|
|
1315
|
+
viewportWidth,
|
|
1316
|
+
viewportHeight,
|
|
1317
|
+
stickySectionHeadersEnabled,
|
|
1318
|
+
estimatedItemSize,
|
|
1319
|
+
drawDistance,
|
|
1320
|
+
debug,
|
|
1321
|
+
renderItem,
|
|
1322
|
+
renderSectionHeader,
|
|
1323
|
+
renderSectionFooter,
|
|
1324
|
+
onCellMeasured,
|
|
1325
|
+
onSectionToggle,
|
|
1326
|
+
getItemType
|
|
1327
|
+
}), [
|
|
1328
|
+
sections,
|
|
1329
|
+
flattenedData,
|
|
1330
|
+
layoutManager,
|
|
1331
|
+
layoutCache,
|
|
1332
|
+
horizontal,
|
|
1333
|
+
collapsedSections,
|
|
1334
|
+
scrollOffset,
|
|
1335
|
+
viewportWidth,
|
|
1336
|
+
viewportHeight,
|
|
1337
|
+
stickySectionHeadersEnabled,
|
|
1338
|
+
estimatedItemSize,
|
|
1339
|
+
drawDistance,
|
|
1340
|
+
debug,
|
|
1341
|
+
renderItem,
|
|
1342
|
+
renderSectionHeader,
|
|
1343
|
+
renderSectionFooter,
|
|
1344
|
+
onCellMeasured,
|
|
1345
|
+
onSectionToggle,
|
|
1346
|
+
getItemType
|
|
1347
|
+
]);
|
|
1348
|
+
return /* @__PURE__ */ jsx(SectionFlowContext.Provider, {
|
|
1349
|
+
value,
|
|
1350
|
+
children
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/components/RecyclerCell.tsx
|
|
1356
|
+
/**
|
|
1357
|
+
* RecyclerCell wraps each item with absolute positioning.
|
|
1358
|
+
* This is the fundamental unit of the recycling system.
|
|
1359
|
+
*
|
|
1360
|
+
* Key responsibilities:
|
|
1361
|
+
* 1. Position the item at the correct x/y coordinates
|
|
1362
|
+
* 2. Report layout measurements back to the system
|
|
1363
|
+
* 3. Maintain stable identity for recycling (via key)
|
|
1364
|
+
*/
|
|
1365
|
+
function RecyclerCellComponent({ cell, layout, children, onLayout, debug = false }) {
|
|
1366
|
+
const handleLayout = useCallback((event) => {
|
|
1367
|
+
const { x, y, width, height } = event.nativeEvent.layout;
|
|
1368
|
+
onLayout(cell.key, cell.flatIndex, {
|
|
1369
|
+
x,
|
|
1370
|
+
y,
|
|
1371
|
+
width,
|
|
1372
|
+
height
|
|
1373
|
+
});
|
|
1374
|
+
}, [
|
|
1375
|
+
cell.key,
|
|
1376
|
+
cell.flatIndex,
|
|
1377
|
+
onLayout
|
|
1378
|
+
]);
|
|
1379
|
+
const positionStyle = {
|
|
1380
|
+
position: "absolute",
|
|
1381
|
+
left: layout.x,
|
|
1382
|
+
top: layout.y,
|
|
1383
|
+
width: layout.width
|
|
1384
|
+
};
|
|
1385
|
+
return /* @__PURE__ */ jsx(View, {
|
|
1386
|
+
style: [positionStyle, debug && styles$4.debug],
|
|
1387
|
+
onLayout: handleLayout,
|
|
1388
|
+
children
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
const styles$4 = StyleSheet.create({ debug: {
|
|
1392
|
+
borderWidth: 1,
|
|
1393
|
+
borderColor: "rgba(255, 0, 0, 0.3)"
|
|
1394
|
+
} });
|
|
1395
|
+
/**
|
|
1396
|
+
* Memoized cell component to prevent unnecessary re-renders.
|
|
1397
|
+
* Only re-renders when:
|
|
1398
|
+
* - Cell key changes (different item)
|
|
1399
|
+
* - Layout position changes
|
|
1400
|
+
* - Children change (new render)
|
|
1401
|
+
*/
|
|
1402
|
+
const RecyclerCell = memo(RecyclerCellComponent, (prevProps, nextProps) => {
|
|
1403
|
+
return prevProps.cell.key === nextProps.cell.key && prevProps.cell.flatIndex === nextProps.cell.flatIndex && prevProps.layout.x === nextProps.layout.x && prevProps.layout.y === nextProps.layout.y && prevProps.layout.width === nextProps.layout.width && prevProps.layout.height === nextProps.layout.height && prevProps.children === nextProps.children && prevProps.debug === nextProps.debug;
|
|
1404
|
+
});
|
|
1405
|
+
RecyclerCell.displayName = "RecyclerCell";
|
|
1406
|
+
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/components/RecyclerContainer.tsx
|
|
1409
|
+
/**
|
|
1410
|
+
* RecyclerContainer manages the absolute-positioned content area.
|
|
1411
|
+
* It renders only the visible items within the current scroll window.
|
|
1412
|
+
*
|
|
1413
|
+
* Key responsibilities:
|
|
1414
|
+
* 1. Set content size for scroll container
|
|
1415
|
+
* 2. Render visible items at correct positions
|
|
1416
|
+
* 3. Coordinate cell recycling
|
|
1417
|
+
*/
|
|
1418
|
+
function RecyclerContainerComponent({ flattenedData, visibleRange, getLayoutForIndex, getCell, contentSize, renderItem, renderSectionHeader, renderSectionFooter, onCellLayout, horizontal = false, debug = false }) {
|
|
1419
|
+
const { startIndex, endIndex } = visibleRange;
|
|
1420
|
+
const cells = useMemo(() => {
|
|
1421
|
+
const result = [];
|
|
1422
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
1423
|
+
const item = flattenedData[i];
|
|
1424
|
+
if (!item) continue;
|
|
1425
|
+
const cell = getCell(i);
|
|
1426
|
+
const layout = getLayoutForIndex(i);
|
|
1427
|
+
let content = null;
|
|
1428
|
+
switch (item.type) {
|
|
1429
|
+
case "section-header":
|
|
1430
|
+
if (renderSectionHeader) content = renderSectionHeader({
|
|
1431
|
+
section: item.section,
|
|
1432
|
+
sectionIndex: item.sectionIndex
|
|
1433
|
+
});
|
|
1434
|
+
break;
|
|
1435
|
+
case "section-footer":
|
|
1436
|
+
if (renderSectionFooter) content = renderSectionFooter({
|
|
1437
|
+
section: item.section,
|
|
1438
|
+
sectionIndex: item.sectionIndex
|
|
1439
|
+
});
|
|
1440
|
+
break;
|
|
1441
|
+
case "item":
|
|
1442
|
+
if (item.item !== null) content = renderItem({
|
|
1443
|
+
item: item.item,
|
|
1444
|
+
index: item.itemIndex,
|
|
1445
|
+
section: item.section,
|
|
1446
|
+
sectionIndex: item.sectionIndex
|
|
1447
|
+
});
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
if (content) result.push(/* @__PURE__ */ jsx(RecyclerCell, {
|
|
1451
|
+
cell,
|
|
1452
|
+
layout,
|
|
1453
|
+
onLayout: onCellLayout,
|
|
1454
|
+
debug,
|
|
1455
|
+
children: content
|
|
1456
|
+
}, cell.key));
|
|
1457
|
+
}
|
|
1458
|
+
return result;
|
|
1459
|
+
}, [
|
|
1460
|
+
startIndex,
|
|
1461
|
+
endIndex,
|
|
1462
|
+
flattenedData,
|
|
1463
|
+
getCell,
|
|
1464
|
+
getLayoutForIndex,
|
|
1465
|
+
renderItem,
|
|
1466
|
+
renderSectionHeader,
|
|
1467
|
+
renderSectionFooter,
|
|
1468
|
+
onCellLayout,
|
|
1469
|
+
debug
|
|
1470
|
+
]);
|
|
1471
|
+
const containerStyle = useMemo(() => ({
|
|
1472
|
+
width: contentSize.width || "100%",
|
|
1473
|
+
height: contentSize.height,
|
|
1474
|
+
position: "relative"
|
|
1475
|
+
}), [contentSize.width, contentSize.height]);
|
|
1476
|
+
return /* @__PURE__ */ jsx(View, {
|
|
1477
|
+
style: [containerStyle, debug && styles$3.debug],
|
|
1478
|
+
children: cells
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
const styles$3 = StyleSheet.create({ debug: { backgroundColor: "rgba(0, 255, 0, 0.05)" } });
|
|
1482
|
+
const RecyclerContainer = memo(RecyclerContainerComponent);
|
|
1483
|
+
|
|
1484
|
+
//#endregion
|
|
1485
|
+
//#region src/components/StickyHeaderContainer.tsx
|
|
1486
|
+
/**
|
|
1487
|
+
* Container for the sticky section header.
|
|
1488
|
+
* Positioned at the top (or left for horizontal) of the viewport.
|
|
1489
|
+
* Handles the "push" effect when the next section approaches.
|
|
1490
|
+
*/
|
|
1491
|
+
function StickyHeaderContainerComponent({ stickySection, translateY, sections, renderSectionHeader, horizontal = false, style }) {
|
|
1492
|
+
if (!stickySection) return null;
|
|
1493
|
+
const section = sections.find((s) => s.key === stickySection.sectionKey);
|
|
1494
|
+
if (!section) return null;
|
|
1495
|
+
const headerContent = renderSectionHeader({
|
|
1496
|
+
section,
|
|
1497
|
+
sectionIndex: stickySection.sectionIndex
|
|
1498
|
+
});
|
|
1499
|
+
if (!headerContent) return null;
|
|
1500
|
+
const containerStyle = useMemo(() => ({
|
|
1501
|
+
position: "absolute",
|
|
1502
|
+
top: horizontal ? void 0 : 0,
|
|
1503
|
+
left: horizontal ? 0 : 0,
|
|
1504
|
+
right: horizontal ? void 0 : 0,
|
|
1505
|
+
transform: horizontal ? [{ translateX: translateY }] : [{ translateY }],
|
|
1506
|
+
zIndex: 1e3,
|
|
1507
|
+
elevation: 4
|
|
1508
|
+
}), [horizontal, translateY]);
|
|
1509
|
+
return /* @__PURE__ */ jsx(View, {
|
|
1510
|
+
style: [
|
|
1511
|
+
containerStyle,
|
|
1512
|
+
styles$2.shadow,
|
|
1513
|
+
style
|
|
1514
|
+
],
|
|
1515
|
+
pointerEvents: "box-none",
|
|
1516
|
+
children: headerContent
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
const styles$2 = StyleSheet.create({ shadow: {
|
|
1520
|
+
shadowColor: "#000",
|
|
1521
|
+
shadowOffset: {
|
|
1522
|
+
width: 0,
|
|
1523
|
+
height: 2
|
|
1524
|
+
},
|
|
1525
|
+
shadowOpacity: .1,
|
|
1526
|
+
shadowRadius: 4
|
|
1527
|
+
} });
|
|
1528
|
+
const StickyHeaderContainer = memo(StickyHeaderContainerComponent);
|
|
1529
|
+
function AnimatedStickyHeaderContainerComponent({ stickySection, animatedTranslateY, sections, renderSectionHeader, horizontal = false, style }) {
|
|
1530
|
+
if (!stickySection) return null;
|
|
1531
|
+
const section = sections.find((s) => s.key === stickySection.sectionKey);
|
|
1532
|
+
if (!section) return null;
|
|
1533
|
+
const headerContent = renderSectionHeader({
|
|
1534
|
+
section,
|
|
1535
|
+
sectionIndex: stickySection.sectionIndex
|
|
1536
|
+
});
|
|
1537
|
+
if (!headerContent) return null;
|
|
1538
|
+
const animatedStyle = useMemo(() => ({
|
|
1539
|
+
position: "absolute",
|
|
1540
|
+
top: horizontal ? void 0 : 0,
|
|
1541
|
+
left: 0,
|
|
1542
|
+
right: horizontal ? void 0 : 0,
|
|
1543
|
+
transform: horizontal ? [{ translateX: animatedTranslateY }] : [{ translateY: animatedTranslateY }],
|
|
1544
|
+
zIndex: 1e3,
|
|
1545
|
+
elevation: 4
|
|
1546
|
+
}), [horizontal, animatedTranslateY]);
|
|
1547
|
+
return /* @__PURE__ */ jsx(Animated.View, {
|
|
1548
|
+
style: [
|
|
1549
|
+
animatedStyle,
|
|
1550
|
+
styles$2.shadow,
|
|
1551
|
+
style
|
|
1552
|
+
],
|
|
1553
|
+
pointerEvents: "box-none",
|
|
1554
|
+
children: headerContent
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
const AnimatedStickyHeaderContainer = memo(AnimatedStickyHeaderContainerComponent);
|
|
1558
|
+
|
|
1559
|
+
//#endregion
|
|
1560
|
+
//#region src/components/SectionFlow.tsx
|
|
1561
|
+
/**
|
|
1562
|
+
* SectionFlow - High-performance section list for React Native
|
|
1563
|
+
*
|
|
1564
|
+
* A drop-in replacement for SectionList with FlashList-style cell recycling.
|
|
1565
|
+
* Provides smooth 60fps scrolling through:
|
|
1566
|
+
* - Cell recycling (reuses views instead of creating new ones)
|
|
1567
|
+
* - Synchronous measurements (New Architecture)
|
|
1568
|
+
* - Type-based recycle pools
|
|
1569
|
+
* - Absolute positioning with computed layouts
|
|
1570
|
+
*/
|
|
1571
|
+
function SectionFlowInner(props, ref) {
|
|
1572
|
+
const { sections, renderItem, renderSectionHeader, renderSectionFooter, keyExtractor, getItemType, estimatedItemSize = DEFAULT_ESTIMATED_ITEM_SIZE, estimatedSectionHeaderSize = DEFAULT_ESTIMATED_HEADER_SIZE, estimatedSectionFooterSize = 0, horizontal = false, stickySectionHeadersEnabled = true, stickyHeaderStyle, collapsible = false, defaultCollapsed = [], onSectionToggle, maxItemsInRecyclePool, drawDistance = DEFAULT_DRAW_DISTANCE, viewabilityConfig, onViewableItemsChanged, onEndReached, onEndReachedThreshold, style, contentContainerStyle, ListHeaderComponent, ListFooterComponent, ListEmptyComponent, refreshing, onRefresh, extraData, debug = false,...scrollViewProps } = props;
|
|
1573
|
+
const scrollViewRef = useRef(null);
|
|
1574
|
+
const [viewportSize, setViewportSize] = useState({
|
|
1575
|
+
width: 0,
|
|
1576
|
+
height: 0
|
|
1577
|
+
});
|
|
1578
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
1579
|
+
const [collapsedSections, setCollapsedSections] = useState(() => new Set(defaultCollapsed));
|
|
1580
|
+
const layoutCache = useMemo(() => createLayoutCache(), []);
|
|
1581
|
+
const layoutManager = useMemo(() => createSectionLayoutManager(layoutCache, {
|
|
1582
|
+
horizontal,
|
|
1583
|
+
estimatedItemSize,
|
|
1584
|
+
estimatedHeaderSize: estimatedSectionHeaderSize,
|
|
1585
|
+
estimatedFooterSize: estimatedSectionFooterSize,
|
|
1586
|
+
hasSectionFooters: !!renderSectionFooter
|
|
1587
|
+
}), [
|
|
1588
|
+
layoutCache,
|
|
1589
|
+
horizontal,
|
|
1590
|
+
estimatedItemSize,
|
|
1591
|
+
estimatedSectionHeaderSize,
|
|
1592
|
+
estimatedSectionFooterSize,
|
|
1593
|
+
renderSectionFooter
|
|
1594
|
+
]);
|
|
1595
|
+
const flattenedData = useMemo(() => layoutManager.updateData(sections, collapsedSections), [
|
|
1596
|
+
layoutManager,
|
|
1597
|
+
sections,
|
|
1598
|
+
collapsedSections,
|
|
1599
|
+
extraData
|
|
1600
|
+
]);
|
|
1601
|
+
useEffect(() => {
|
|
1602
|
+
layoutManager.getLayoutPositioner().setContainerSize(viewportSize.width, viewportSize.height);
|
|
1603
|
+
}, [layoutManager, viewportSize]);
|
|
1604
|
+
const getItemTypeForIndex = useCallback((flatIndex) => {
|
|
1605
|
+
const item = flattenedData[flatIndex];
|
|
1606
|
+
if (!item) return DEFAULT_ITEM_TYPE;
|
|
1607
|
+
if (item.type === "section-header") return SECTION_HEADER_TYPE;
|
|
1608
|
+
if (item.type === "section-footer") return SECTION_FOOTER_TYPE;
|
|
1609
|
+
if (getItemType && item.item !== null) return getItemType(item.item, item.itemIndex, item.section);
|
|
1610
|
+
return DEFAULT_ITEM_TYPE;
|
|
1611
|
+
}, [flattenedData, getItemType]);
|
|
1612
|
+
const { scrollState, onScroll, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollBegin, onMomentumScrollEnd } = useScrollHandler({
|
|
1613
|
+
horizontal,
|
|
1614
|
+
onScrollStateChange: (state) => {
|
|
1615
|
+
setScrollOffset(state.offset);
|
|
1616
|
+
},
|
|
1617
|
+
onEndReached: onEndReached ? (distance) => onEndReached({ distanceFromEnd: distance }) : void 0,
|
|
1618
|
+
onEndReachedThreshold
|
|
1619
|
+
});
|
|
1620
|
+
const { getCell, releaseCell, updateVisibleRange, clearPools, getPoolStats } = useRecycler({
|
|
1621
|
+
flattenedData,
|
|
1622
|
+
getItemType,
|
|
1623
|
+
maxPoolSize: maxItemsInRecyclePool
|
|
1624
|
+
});
|
|
1625
|
+
const layoutPositioner = layoutManager.getLayoutPositioner();
|
|
1626
|
+
const viewportDimension = horizontal ? viewportSize.width : viewportSize.height;
|
|
1627
|
+
const visibleRange = useMemo(() => layoutPositioner.getVisibleRange(scrollOffset, viewportDimension, drawDistance), [
|
|
1628
|
+
layoutPositioner,
|
|
1629
|
+
scrollOffset,
|
|
1630
|
+
viewportDimension,
|
|
1631
|
+
drawDistance
|
|
1632
|
+
]);
|
|
1633
|
+
useEffect(() => {
|
|
1634
|
+
updateVisibleRange(visibleRange.startIndex, visibleRange.endIndex);
|
|
1635
|
+
}, [
|
|
1636
|
+
updateVisibleRange,
|
|
1637
|
+
visibleRange.startIndex,
|
|
1638
|
+
visibleRange.endIndex
|
|
1639
|
+
]);
|
|
1640
|
+
const sectionLayouts = useMemo(() => layoutManager.getAllSectionLayouts(), [
|
|
1641
|
+
layoutManager,
|
|
1642
|
+
flattenedData,
|
|
1643
|
+
scrollOffset
|
|
1644
|
+
]);
|
|
1645
|
+
const stickyHeaderState = useStickyHeader({
|
|
1646
|
+
sectionLayouts,
|
|
1647
|
+
scrollOffset,
|
|
1648
|
+
viewportHeight: viewportDimension,
|
|
1649
|
+
horizontal,
|
|
1650
|
+
enabled: stickySectionHeadersEnabled
|
|
1651
|
+
});
|
|
1652
|
+
const viewabilityResult = useViewability({
|
|
1653
|
+
flattenedData,
|
|
1654
|
+
layoutPositioner,
|
|
1655
|
+
scrollOffset,
|
|
1656
|
+
viewportSize: viewportDimension,
|
|
1657
|
+
horizontal,
|
|
1658
|
+
viewabilityConfig,
|
|
1659
|
+
onViewableItemsChanged
|
|
1660
|
+
});
|
|
1661
|
+
const handleCellLayout = useCallback((key, flatIndex, layout) => {
|
|
1662
|
+
layoutPositioner.updateItemLayout(flatIndex, layout);
|
|
1663
|
+
}, [layoutPositioner]);
|
|
1664
|
+
const handleViewportLayout = useCallback((event) => {
|
|
1665
|
+
const { width, height } = event.nativeEvent.layout;
|
|
1666
|
+
setViewportSize({
|
|
1667
|
+
width,
|
|
1668
|
+
height
|
|
1669
|
+
});
|
|
1670
|
+
}, []);
|
|
1671
|
+
const handleSectionToggle = useCallback((sectionKey) => {
|
|
1672
|
+
setCollapsedSections((prev) => {
|
|
1673
|
+
const next = new Set(prev);
|
|
1674
|
+
const isCollapsed = next.has(sectionKey);
|
|
1675
|
+
if (isCollapsed) next.delete(sectionKey);
|
|
1676
|
+
else next.add(sectionKey);
|
|
1677
|
+
onSectionToggle?.(sectionKey, !isCollapsed);
|
|
1678
|
+
return next;
|
|
1679
|
+
});
|
|
1680
|
+
}, [onSectionToggle]);
|
|
1681
|
+
const contentSize = useMemo(() => layoutPositioner.getContentSize(), [layoutPositioner, flattenedData]);
|
|
1682
|
+
const scrollToOffset = useCallback((options) => {
|
|
1683
|
+
scrollViewRef.current?.scrollTo({
|
|
1684
|
+
x: horizontal ? options.offset : 0,
|
|
1685
|
+
y: horizontal ? 0 : options.offset,
|
|
1686
|
+
animated: options.animated ?? true
|
|
1687
|
+
});
|
|
1688
|
+
}, [horizontal]);
|
|
1689
|
+
const scrollToSection = useCallback((options) => {
|
|
1690
|
+
const { sectionKey, sectionIndex, animated = true, viewPosition = 0 } = options;
|
|
1691
|
+
let targetSection = null;
|
|
1692
|
+
if (sectionKey) targetSection = layoutManager.getSectionLayout(sectionKey);
|
|
1693
|
+
else if (sectionIndex !== void 0) {
|
|
1694
|
+
const section = sections[sectionIndex];
|
|
1695
|
+
if (section) targetSection = layoutManager.getSectionLayout(section.key);
|
|
1696
|
+
}
|
|
1697
|
+
if (targetSection) {
|
|
1698
|
+
const headerOffset = horizontal ? targetSection.headerLayout.x : targetSection.headerLayout.y;
|
|
1699
|
+
scrollToOffset({
|
|
1700
|
+
offset: headerOffset,
|
|
1701
|
+
animated
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
}, [
|
|
1705
|
+
layoutManager,
|
|
1706
|
+
sections,
|
|
1707
|
+
horizontal,
|
|
1708
|
+
scrollToOffset
|
|
1709
|
+
]);
|
|
1710
|
+
const scrollToItem = useCallback((options) => {
|
|
1711
|
+
const { sectionKey, sectionIndex, itemIndex, animated = true } = options;
|
|
1712
|
+
let targetSectionIndex = sectionIndex;
|
|
1713
|
+
if (sectionKey) targetSectionIndex = sections.findIndex((s) => s.key === sectionKey);
|
|
1714
|
+
if (targetSectionIndex === void 0 || targetSectionIndex < 0) return;
|
|
1715
|
+
const flatIndex = layoutManager.getFlatIndex(targetSectionIndex, itemIndex);
|
|
1716
|
+
if (flatIndex < 0) return;
|
|
1717
|
+
const layout = layoutPositioner.getLayoutForIndex(flatIndex);
|
|
1718
|
+
const offset = horizontal ? layout.x : layout.y;
|
|
1719
|
+
scrollToOffset({
|
|
1720
|
+
offset,
|
|
1721
|
+
animated
|
|
1722
|
+
});
|
|
1723
|
+
}, [
|
|
1724
|
+
layoutManager,
|
|
1725
|
+
layoutPositioner,
|
|
1726
|
+
sections,
|
|
1727
|
+
horizontal,
|
|
1728
|
+
scrollToOffset
|
|
1729
|
+
]);
|
|
1730
|
+
const scrollToEnd = useCallback((options) => {
|
|
1731
|
+
const totalSize = horizontal ? contentSize.width : contentSize.height;
|
|
1732
|
+
const offset = Math.max(0, totalSize - viewportDimension);
|
|
1733
|
+
scrollToOffset({
|
|
1734
|
+
offset,
|
|
1735
|
+
animated: options?.animated ?? true
|
|
1736
|
+
});
|
|
1737
|
+
}, [
|
|
1738
|
+
contentSize,
|
|
1739
|
+
viewportDimension,
|
|
1740
|
+
horizontal,
|
|
1741
|
+
scrollToOffset
|
|
1742
|
+
]);
|
|
1743
|
+
useImperativeHandle(ref, () => ({
|
|
1744
|
+
scrollToSection,
|
|
1745
|
+
scrollToItem,
|
|
1746
|
+
scrollToOffset,
|
|
1747
|
+
scrollToEnd,
|
|
1748
|
+
toggleSection: handleSectionToggle,
|
|
1749
|
+
getSectionLayouts: () => sectionLayouts,
|
|
1750
|
+
getVisibleItems: viewabilityResult.getVisibleItems,
|
|
1751
|
+
recordInteraction: viewabilityResult.recordInteraction,
|
|
1752
|
+
flashScrollIndicators: () => scrollViewRef.current?.flashScrollIndicators()
|
|
1753
|
+
}), [
|
|
1754
|
+
scrollToSection,
|
|
1755
|
+
scrollToItem,
|
|
1756
|
+
scrollToOffset,
|
|
1757
|
+
scrollToEnd,
|
|
1758
|
+
handleSectionToggle,
|
|
1759
|
+
sectionLayouts,
|
|
1760
|
+
viewabilityResult
|
|
1761
|
+
]);
|
|
1762
|
+
if (sections.length === 0 && ListEmptyComponent) {
|
|
1763
|
+
const EmptyComponent = typeof ListEmptyComponent === "function" ? /* @__PURE__ */ jsx(ListEmptyComponent, {}) : ListEmptyComponent;
|
|
1764
|
+
return /* @__PURE__ */ jsx(View, {
|
|
1765
|
+
style: [styles$1.container, style],
|
|
1766
|
+
children: EmptyComponent
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
const HeaderComponent = ListHeaderComponent ? typeof ListHeaderComponent === "function" ? /* @__PURE__ */ jsx(ListHeaderComponent, {}) : ListHeaderComponent : null;
|
|
1770
|
+
const FooterComponent = ListFooterComponent ? typeof ListFooterComponent === "function" ? /* @__PURE__ */ jsx(ListFooterComponent, {}) : ListFooterComponent : null;
|
|
1771
|
+
return /* @__PURE__ */ jsxs(View, {
|
|
1772
|
+
style: [styles$1.container, style],
|
|
1773
|
+
onLayout: handleViewportLayout,
|
|
1774
|
+
children: [
|
|
1775
|
+
/* @__PURE__ */ jsxs(ScrollView, {
|
|
1776
|
+
ref: scrollViewRef,
|
|
1777
|
+
horizontal,
|
|
1778
|
+
scrollEventThrottle: 16,
|
|
1779
|
+
onScroll,
|
|
1780
|
+
onScrollBeginDrag,
|
|
1781
|
+
onScrollEndDrag,
|
|
1782
|
+
onMomentumScrollBegin,
|
|
1783
|
+
onMomentumScrollEnd,
|
|
1784
|
+
contentContainerStyle,
|
|
1785
|
+
refreshControl: onRefresh ? /* @__PURE__ */ jsx(RefreshControl, {
|
|
1786
|
+
refreshing: refreshing ?? false,
|
|
1787
|
+
onRefresh
|
|
1788
|
+
}) : void 0,
|
|
1789
|
+
...scrollViewProps,
|
|
1790
|
+
children: [
|
|
1791
|
+
HeaderComponent,
|
|
1792
|
+
/* @__PURE__ */ jsx(SectionFlowProvider, {
|
|
1793
|
+
sections,
|
|
1794
|
+
flattenedData,
|
|
1795
|
+
layoutManager,
|
|
1796
|
+
layoutCache,
|
|
1797
|
+
horizontal,
|
|
1798
|
+
collapsedSections,
|
|
1799
|
+
scrollOffset,
|
|
1800
|
+
viewportWidth: viewportSize.width,
|
|
1801
|
+
viewportHeight: viewportSize.height,
|
|
1802
|
+
stickySectionHeadersEnabled,
|
|
1803
|
+
estimatedItemSize,
|
|
1804
|
+
drawDistance,
|
|
1805
|
+
debug,
|
|
1806
|
+
renderItem,
|
|
1807
|
+
renderSectionHeader,
|
|
1808
|
+
renderSectionFooter,
|
|
1809
|
+
onCellMeasured: handleCellLayout,
|
|
1810
|
+
onSectionToggle: collapsible ? handleSectionToggle : void 0,
|
|
1811
|
+
getItemType: getItemTypeForIndex,
|
|
1812
|
+
children: /* @__PURE__ */ jsx(RecyclerContainer, {
|
|
1813
|
+
flattenedData,
|
|
1814
|
+
visibleRange,
|
|
1815
|
+
getLayoutForIndex: (index) => layoutPositioner.getLayoutForIndex(index),
|
|
1816
|
+
getCell,
|
|
1817
|
+
contentSize,
|
|
1818
|
+
renderItem,
|
|
1819
|
+
renderSectionHeader,
|
|
1820
|
+
renderSectionFooter,
|
|
1821
|
+
onCellLayout: handleCellLayout,
|
|
1822
|
+
horizontal,
|
|
1823
|
+
debug
|
|
1824
|
+
})
|
|
1825
|
+
}),
|
|
1826
|
+
FooterComponent
|
|
1827
|
+
]
|
|
1828
|
+
}),
|
|
1829
|
+
stickySectionHeadersEnabled && renderSectionHeader && stickyHeaderState.sectionKey && /* @__PURE__ */ jsx(StickyHeaderContainer, {
|
|
1830
|
+
stickySection: sectionLayouts.find((s) => s.sectionKey === stickyHeaderState.sectionKey) ?? null,
|
|
1831
|
+
translateY: stickyHeaderState.translateY,
|
|
1832
|
+
sections,
|
|
1833
|
+
renderSectionHeader,
|
|
1834
|
+
horizontal,
|
|
1835
|
+
style: stickyHeaderStyle
|
|
1836
|
+
}),
|
|
1837
|
+
debug && /* @__PURE__ */ jsx(View, {
|
|
1838
|
+
style: styles$1.debugOverlay,
|
|
1839
|
+
pointerEvents: "none"
|
|
1840
|
+
})
|
|
1841
|
+
]
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
const styles$1 = StyleSheet.create({
|
|
1845
|
+
container: { flex: 1 },
|
|
1846
|
+
debugOverlay: {
|
|
1847
|
+
position: "absolute",
|
|
1848
|
+
top: 0,
|
|
1849
|
+
left: 0,
|
|
1850
|
+
right: 0,
|
|
1851
|
+
bottom: 0
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
/**
|
|
1855
|
+
* Export with proper generic typing.
|
|
1856
|
+
* Usage: <SectionFlow<MyItemType> sections={...} renderItem={...} />
|
|
1857
|
+
*/
|
|
1858
|
+
const SectionFlow = forwardRef(SectionFlowInner);
|
|
1859
|
+
SectionFlow.displayName = "SectionFlow";
|
|
1860
|
+
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/components/SectionHeader.tsx
|
|
1863
|
+
/**
|
|
1864
|
+
* Default section header component.
|
|
1865
|
+
* Can be overridden by providing renderSectionHeader prop to SectionFlow.
|
|
1866
|
+
*/
|
|
1867
|
+
function SectionHeaderComponent({ section, sectionIndex, isCollapsed = false, collapsible = false, onToggle, renderContent, style }) {
|
|
1868
|
+
const handlePress = useCallback(() => {
|
|
1869
|
+
if (collapsible && onToggle) onToggle(section.key);
|
|
1870
|
+
}, [
|
|
1871
|
+
collapsible,
|
|
1872
|
+
onToggle,
|
|
1873
|
+
section.key
|
|
1874
|
+
]);
|
|
1875
|
+
if (renderContent) {
|
|
1876
|
+
const content = renderContent({
|
|
1877
|
+
section,
|
|
1878
|
+
sectionIndex,
|
|
1879
|
+
isCollapsed
|
|
1880
|
+
});
|
|
1881
|
+
if (content) {
|
|
1882
|
+
if (collapsible) return /* @__PURE__ */ jsx(TouchableOpacity, {
|
|
1883
|
+
onPress: handlePress,
|
|
1884
|
+
activeOpacity: .7,
|
|
1885
|
+
children: content
|
|
1886
|
+
});
|
|
1887
|
+
return /* @__PURE__ */ jsx(View, { children: content });
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
const Wrapper = collapsible ? TouchableOpacity : View;
|
|
1891
|
+
const wrapperProps = collapsible ? {
|
|
1892
|
+
onPress: handlePress,
|
|
1893
|
+
activeOpacity: .7
|
|
1894
|
+
} : {};
|
|
1895
|
+
return /* @__PURE__ */ jsxs(Wrapper, {
|
|
1896
|
+
...wrapperProps,
|
|
1897
|
+
style: [styles.container, style],
|
|
1898
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
1899
|
+
style: styles.title,
|
|
1900
|
+
children: section.title ?? section.key
|
|
1901
|
+
}), collapsible && /* @__PURE__ */ jsx(Text, {
|
|
1902
|
+
style: styles.indicator,
|
|
1903
|
+
children: isCollapsed ? "▸" : "▾"
|
|
1904
|
+
})]
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
const styles = StyleSheet.create({
|
|
1908
|
+
container: {
|
|
1909
|
+
flexDirection: "row",
|
|
1910
|
+
alignItems: "center",
|
|
1911
|
+
justifyContent: "space-between",
|
|
1912
|
+
paddingHorizontal: 16,
|
|
1913
|
+
paddingVertical: 12,
|
|
1914
|
+
backgroundColor: "#f5f5f5",
|
|
1915
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
1916
|
+
borderBottomColor: "#e0e0e0"
|
|
1917
|
+
},
|
|
1918
|
+
title: {
|
|
1919
|
+
fontSize: 14,
|
|
1920
|
+
fontWeight: "600",
|
|
1921
|
+
color: "#333",
|
|
1922
|
+
textTransform: "uppercase",
|
|
1923
|
+
letterSpacing: .5
|
|
1924
|
+
},
|
|
1925
|
+
indicator: {
|
|
1926
|
+
fontSize: 14,
|
|
1927
|
+
color: "#666"
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
const SectionHeader = memo(SectionHeaderComponent);
|
|
1931
|
+
|
|
1932
|
+
//#endregion
|
|
1933
|
+
//#region src/hooks/useLayoutMeasurement.ts
|
|
1934
|
+
/**
|
|
1935
|
+
* Hook for synchronous layout measurement using New Architecture features.
|
|
1936
|
+
*
|
|
1937
|
+
* In React Native's New Architecture with Fabric, useLayoutEffect runs synchronously
|
|
1938
|
+
* before paint, allowing us to measure and correct layouts without visible glitches.
|
|
1939
|
+
*
|
|
1940
|
+
* This hook provides:
|
|
1941
|
+
* 1. A ref to attach to the View
|
|
1942
|
+
* 2. An onLayout callback for initial/resize measurements
|
|
1943
|
+
* 3. A measure function for on-demand measurement
|
|
1944
|
+
*/
|
|
1945
|
+
function useLayoutMeasurement(options) {
|
|
1946
|
+
const { onMeasure, enabled = true } = options;
|
|
1947
|
+
const ref = useRef(null);
|
|
1948
|
+
const lastMeasurement = useRef(null);
|
|
1949
|
+
/**
|
|
1950
|
+
* Measure the view using measureInWindow for absolute positioning.
|
|
1951
|
+
* In New Architecture, this is synchronous when called in useLayoutEffect.
|
|
1952
|
+
*/
|
|
1953
|
+
const measure = useCallback(() => {
|
|
1954
|
+
if (!enabled || !ref.current) return;
|
|
1955
|
+
ref.current.measureInWindow((x, y, width, height) => {
|
|
1956
|
+
const layout = {
|
|
1957
|
+
x,
|
|
1958
|
+
y,
|
|
1959
|
+
width,
|
|
1960
|
+
height
|
|
1961
|
+
};
|
|
1962
|
+
if (!lastMeasurement.current || lastMeasurement.current.width !== width || lastMeasurement.current.height !== height) {
|
|
1963
|
+
lastMeasurement.current = layout;
|
|
1964
|
+
onMeasure(layout);
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
}, [enabled, onMeasure]);
|
|
1968
|
+
/**
|
|
1969
|
+
* Handle layout events from React Native's onLayout prop.
|
|
1970
|
+
* This fires on mount and whenever the layout changes.
|
|
1971
|
+
*/
|
|
1972
|
+
const onLayout = useCallback((event) => {
|
|
1973
|
+
if (!enabled) return;
|
|
1974
|
+
const { x, y, width, height } = event.nativeEvent.layout;
|
|
1975
|
+
const layout = {
|
|
1976
|
+
x,
|
|
1977
|
+
y,
|
|
1978
|
+
width,
|
|
1979
|
+
height
|
|
1980
|
+
};
|
|
1981
|
+
if (!lastMeasurement.current || lastMeasurement.current.width !== width || lastMeasurement.current.height !== height) {
|
|
1982
|
+
lastMeasurement.current = layout;
|
|
1983
|
+
onMeasure(layout);
|
|
1984
|
+
}
|
|
1985
|
+
}, [enabled, onMeasure]);
|
|
1986
|
+
return {
|
|
1987
|
+
ref,
|
|
1988
|
+
onLayout,
|
|
1989
|
+
measure
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Hook for measuring a cell's layout and reporting to the layout system.
|
|
1994
|
+
* Uses synchronous measurement in New Architecture for smooth corrections.
|
|
1995
|
+
*/
|
|
1996
|
+
function useCellMeasurement(cellKey, onCellMeasured, enabled = true) {
|
|
1997
|
+
const handleMeasure = useCallback((layout) => {
|
|
1998
|
+
onCellMeasured(cellKey, layout);
|
|
1999
|
+
}, [cellKey, onCellMeasured]);
|
|
2000
|
+
return useLayoutMeasurement({
|
|
2001
|
+
onMeasure: handleMeasure,
|
|
2002
|
+
enabled
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Hook for progressive rendering - measures initial items and expands.
|
|
2007
|
+
* Implements FlashList v2's progressive rendering strategy.
|
|
2008
|
+
*/
|
|
2009
|
+
function useProgressiveRender(totalItems, initialCount, batchSize, onRenderCountChange) {
|
|
2010
|
+
const renderCount = useRef(Math.min(initialCount, totalItems));
|
|
2011
|
+
const measuredCount = useRef(0);
|
|
2012
|
+
const onItemMeasured = useCallback((index) => {
|
|
2013
|
+
measuredCount.current++;
|
|
2014
|
+
if (measuredCount.current >= renderCount.current && renderCount.current < totalItems) {
|
|
2015
|
+
const newCount = Math.min(renderCount.current + batchSize, totalItems);
|
|
2016
|
+
renderCount.current = newCount;
|
|
2017
|
+
onRenderCountChange(newCount);
|
|
2018
|
+
}
|
|
2019
|
+
}, [
|
|
2020
|
+
totalItems,
|
|
2021
|
+
batchSize,
|
|
2022
|
+
onRenderCountChange
|
|
2023
|
+
]);
|
|
2024
|
+
return {
|
|
2025
|
+
renderCount: renderCount.current,
|
|
2026
|
+
onItemMeasured
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
//#endregion
|
|
2031
|
+
//#region src/utils/flattenSections.ts
|
|
2032
|
+
/**
|
|
2033
|
+
* Flatten sections into a single array of items for virtualization.
|
|
2034
|
+
* Each item includes metadata about its section and position.
|
|
2035
|
+
*/
|
|
2036
|
+
function flattenSections(sections, options = {}) {
|
|
2037
|
+
const { collapsedSections = new Set(), includeSectionFooters = false } = options;
|
|
2038
|
+
const result = [];
|
|
2039
|
+
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
|
|
2040
|
+
const section = sections[sectionIndex];
|
|
2041
|
+
const isCollapsed = collapsedSections.has(section.key);
|
|
2042
|
+
result.push({
|
|
2043
|
+
type: "section-header",
|
|
2044
|
+
key: `header-${section.key}`,
|
|
2045
|
+
sectionKey: section.key,
|
|
2046
|
+
sectionIndex,
|
|
2047
|
+
itemIndex: -1,
|
|
2048
|
+
item: null,
|
|
2049
|
+
section
|
|
2050
|
+
});
|
|
2051
|
+
if (!isCollapsed) {
|
|
2052
|
+
for (let itemIndex = 0; itemIndex < section.data.length; itemIndex++) result.push({
|
|
2053
|
+
type: "item",
|
|
2054
|
+
key: `item-${section.key}-${itemIndex}`,
|
|
2055
|
+
sectionKey: section.key,
|
|
2056
|
+
sectionIndex,
|
|
2057
|
+
itemIndex,
|
|
2058
|
+
item: section.data[itemIndex],
|
|
2059
|
+
section
|
|
2060
|
+
});
|
|
2061
|
+
if (includeSectionFooters) result.push({
|
|
2062
|
+
type: "section-footer",
|
|
2063
|
+
key: `footer-${section.key}`,
|
|
2064
|
+
sectionKey: section.key,
|
|
2065
|
+
sectionIndex,
|
|
2066
|
+
itemIndex: -1,
|
|
2067
|
+
item: null,
|
|
2068
|
+
section
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
return result;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Get the flat index for a specific section and item index.
|
|
2076
|
+
*/
|
|
2077
|
+
function getFlatIndex(sections, sectionIndex, itemIndex, collapsedSections = new Set(), includeSectionFooters = false) {
|
|
2078
|
+
let flatIndex = 0;
|
|
2079
|
+
for (let i = 0; i < sectionIndex; i++) {
|
|
2080
|
+
const section = sections[i];
|
|
2081
|
+
flatIndex++;
|
|
2082
|
+
if (!collapsedSections.has(section.key)) {
|
|
2083
|
+
flatIndex += section.data.length;
|
|
2084
|
+
if (includeSectionFooters) flatIndex++;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
flatIndex++;
|
|
2088
|
+
if (itemIndex >= 0 && !collapsedSections.has(sections[sectionIndex].key)) flatIndex += itemIndex;
|
|
2089
|
+
return flatIndex;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Get section and item index from a flat index.
|
|
2093
|
+
*/
|
|
2094
|
+
function getSectionItemFromFlatIndex(sections, flatIndex, collapsedSections = new Set(), includeSectionFooters = false) {
|
|
2095
|
+
let currentFlatIndex = 0;
|
|
2096
|
+
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
|
|
2097
|
+
const section = sections[sectionIndex];
|
|
2098
|
+
const isCollapsed = collapsedSections.has(section.key);
|
|
2099
|
+
if (currentFlatIndex === flatIndex) return {
|
|
2100
|
+
sectionIndex,
|
|
2101
|
+
itemIndex: -1,
|
|
2102
|
+
type: "header"
|
|
2103
|
+
};
|
|
2104
|
+
currentFlatIndex++;
|
|
2105
|
+
if (!isCollapsed) {
|
|
2106
|
+
for (let itemIndex = 0; itemIndex < section.data.length; itemIndex++) {
|
|
2107
|
+
if (currentFlatIndex === flatIndex) return {
|
|
2108
|
+
sectionIndex,
|
|
2109
|
+
itemIndex,
|
|
2110
|
+
type: "item"
|
|
2111
|
+
};
|
|
2112
|
+
currentFlatIndex++;
|
|
2113
|
+
}
|
|
2114
|
+
if (includeSectionFooters) {
|
|
2115
|
+
if (currentFlatIndex === flatIndex) return {
|
|
2116
|
+
sectionIndex,
|
|
2117
|
+
itemIndex: -1,
|
|
2118
|
+
type: "footer"
|
|
2119
|
+
};
|
|
2120
|
+
currentFlatIndex++;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
return null;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
//#endregion
|
|
2128
|
+
//#region src/utils/keyExtractor.ts
|
|
2129
|
+
/**
|
|
2130
|
+
* Key extraction utilities for list items.
|
|
2131
|
+
*/
|
|
2132
|
+
/**
|
|
2133
|
+
* Default key extractor that uses index as string.
|
|
2134
|
+
* Note: Using indices as keys is not recommended for dynamic lists.
|
|
2135
|
+
*/
|
|
2136
|
+
function defaultKeyExtractor(item, index) {
|
|
2137
|
+
const anyItem = item;
|
|
2138
|
+
if (typeof anyItem?.id === "string" || typeof anyItem?.id === "number") return String(anyItem.id);
|
|
2139
|
+
if (typeof anyItem?.key === "string" || typeof anyItem?.key === "number") return String(anyItem.key);
|
|
2140
|
+
if (typeof anyItem?._id === "string" || typeof anyItem?._id === "number") return String(anyItem._id);
|
|
2141
|
+
if (typeof anyItem?.uuid === "string") return anyItem.uuid;
|
|
2142
|
+
return String(index);
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Create a key extractor function with a custom field name.
|
|
2146
|
+
*/
|
|
2147
|
+
function createKeyExtractor(field) {
|
|
2148
|
+
return (item) => {
|
|
2149
|
+
const value = item[field];
|
|
2150
|
+
if (value !== void 0 && value !== null) return String(value);
|
|
2151
|
+
throw new Error(`Key field "${String(field)}" not found on item`);
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Create a composite key from multiple fields.
|
|
2156
|
+
*/
|
|
2157
|
+
function createCompositeKeyExtractor(fields) {
|
|
2158
|
+
return (item) => {
|
|
2159
|
+
return fields.map((field) => {
|
|
2160
|
+
const value = item[field];
|
|
2161
|
+
return value !== void 0 && value !== null ? String(value) : "";
|
|
2162
|
+
}).join("-");
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Validate that keys are unique within a list.
|
|
2167
|
+
* Throws an error if duplicates are found.
|
|
2168
|
+
*/
|
|
2169
|
+
function validateUniqueKeys(items, keyExtractor) {
|
|
2170
|
+
const seen = new Set();
|
|
2171
|
+
for (let i = 0; i < items.length; i++) {
|
|
2172
|
+
const key = keyExtractor(items[i], i);
|
|
2173
|
+
if (seen.has(key)) throw new Error(`Duplicate key "${key}" found at index ${i}. Keys must be unique within a list.`);
|
|
2174
|
+
seen.add(key);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
//#endregion
|
|
2179
|
+
//#region src/utils/binarySearch.ts
|
|
2180
|
+
/**
|
|
2181
|
+
* Binary search utilities for efficient offset-to-index lookups.
|
|
2182
|
+
*/
|
|
2183
|
+
/**
|
|
2184
|
+
* Binary search to find the index of an item with a specific value.
|
|
2185
|
+
* Returns -1 if not found.
|
|
2186
|
+
*/
|
|
2187
|
+
function binarySearch(array, target, getValue) {
|
|
2188
|
+
let low = 0;
|
|
2189
|
+
let high = array.length - 1;
|
|
2190
|
+
while (low <= high) {
|
|
2191
|
+
const mid = Math.floor((low + high) / 2);
|
|
2192
|
+
const value = getValue(array[mid]);
|
|
2193
|
+
if (value === target) return mid;
|
|
2194
|
+
else if (value < target) low = mid + 1;
|
|
2195
|
+
else high = mid - 1;
|
|
2196
|
+
}
|
|
2197
|
+
return -1;
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Binary search to find the insertion position for a value.
|
|
2201
|
+
* Returns the index where the value should be inserted to maintain sorted order.
|
|
2202
|
+
*/
|
|
2203
|
+
function binarySearchInsertPosition(array, target, getValue) {
|
|
2204
|
+
let low = 0;
|
|
2205
|
+
let high = array.length;
|
|
2206
|
+
while (low < high) {
|
|
2207
|
+
const mid = Math.floor((low + high) / 2);
|
|
2208
|
+
const value = getValue(array[mid]);
|
|
2209
|
+
if (value < target) low = mid + 1;
|
|
2210
|
+
else high = mid;
|
|
2211
|
+
}
|
|
2212
|
+
return low;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
//#endregion
|
|
2216
|
+
export { AnimatedStickyHeaderContainer, DEFAULT_DRAW_DISTANCE, DEFAULT_ESTIMATED_HEADER_SIZE, DEFAULT_ESTIMATED_ITEM_SIZE, DEFAULT_ITEM_TYPE, DEFAULT_MAX_POOL_SIZE, LinearLayoutPositioner, RecyclerCell, RecyclerContainer, SECTION_FOOTER_TYPE, SECTION_HEADER_TYPE, SectionFlow, SectionFlowProvider, SectionHeader, StickyHeaderContainer, binarySearch, binarySearchInsertPosition, createCellRecycler, createCompositeKeyExtractor, createKeyExtractor, createLayoutCache, createLayoutPositioner, createSectionLayoutManager, createViewabilityTracker, defaultKeyExtractor, flattenSections, getFlatIndex, getSectionItemFromFlatIndex, useAdaptiveDrawDistance, useCellMeasurement, useItemTypeResolver, useLayoutMeasurement, useMultipleStickyHeaders, useMultipleViewabilityConfigs, useProgressiveRender, useRecycler, useScrollHandler, useSectionFlowContext, useStickyHeader, useStickyHeaderOpacity, useViewability, validateUniqueKeys };
|
|
2217
|
+
//# sourceMappingURL=index.js.map
|