@dreamstack-us/section-flow 0.0.1

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/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