@symbiote-native/react 3.2.0 → 3.4.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/build/batched-updates.d.ts +2 -0
- package/build/batched-updates.js +5 -0
- package/build/components/checkbox-props.d.ts +5 -0
- package/build/components/checkbox-props.js +1 -0
- package/build/components/flat-list/index.d.ts +16 -13
- package/build/components/flat-list/index.js +86 -72
- package/build/components/input-accessory-view-props.d.ts +4 -3
- package/build/components/keyboard-avoiding-view/index.d.ts +2 -2
- package/build/components/keyboard-avoiding-view/index.js +45 -84
- package/build/components/layout-conformance-props.d.ts +6 -0
- package/build/components/layout-conformance-props.js +1 -0
- package/build/components/modal/index.d.ts +4 -4
- package/build/components/modal/index.js +47 -18
- package/build/components/refresh-control-props.d.ts +7 -6
- package/build/components/scroll-view/scroll-view-props.d.ts +8 -5
- package/build/components/text-input/text-input-props.d.ts +4 -0
- package/build/components/touchable/touchable-props.d.ts +6 -5
- package/build/components/virtualized-list/index.d.ts +3 -70
- package/build/components/virtualized-list/index.js +50 -548
- package/build/components/virtualized-list/list-children.d.ts +13 -0
- package/build/components/virtualized-list/list-children.js +79 -0
- package/build/components/virtualized-list/list-config.d.ts +11 -0
- package/build/components/virtualized-list/list-config.js +14 -0
- package/build/components/virtualized-list/list-elements.d.ts +11 -0
- package/build/components/virtualized-list/list-elements.js +26 -0
- package/build/components/virtualized-list/list-host.d.ts +14 -0
- package/build/components/virtualized-list/list-host.js +24 -0
- package/build/components/virtualized-list/list-scroll-props.d.ts +32 -0
- package/build/components/virtualized-list/list-scroll-props.js +30 -0
- package/build/components/virtualized-list/nested-scope.d.ts +10 -0
- package/build/components/virtualized-list/nested-scope.js +16 -0
- package/build/components/virtualized-list/use-list-driver.d.ts +22 -0
- package/build/components/virtualized-list/use-list-driver.js +104 -0
- package/build/components/virtualized-list/use-list-nesting.d.ts +12 -0
- package/build/components/virtualized-list/use-list-nesting.js +37 -0
- package/build/components/virtualized-list/use-list-parts.d.ts +12 -0
- package/build/components/virtualized-list/use-list-parts.js +20 -0
- package/build/components/virtualized-list/use-scroll-handlers.d.ts +17 -0
- package/build/components/virtualized-list/use-scroll-handlers.js +106 -0
- package/build/components/virtualized-list/use-separators.d.ts +8 -0
- package/build/components/virtualized-list/use-separators.js +17 -0
- package/build/components/virtualized-list/virtualized-list-props.d.ts +85 -0
- package/build/components/virtualized-list/virtualized-list-props.js +2 -0
- package/build/components/virtualized-section-list/index.d.ts +24 -8
- package/build/components/virtualized-section-list/index.js +89 -93
- package/build/components/virtualized-section-list/section-item-cell.d.ts +23 -0
- package/build/components/virtualized-section-list/section-item-cell.js +48 -0
- package/build/components.d.ts +5 -5
- package/build/descriptor-to-react/with-children.d.ts +4 -0
- package/build/descriptor-to-react/with-children.js +6 -0
- package/build/hooks/use-animated-value.d.ts +7 -0
- package/build/hooks/use-animated-value.js +19 -0
- package/build/hooks/use-color-scheme.d.ts +2 -2
- package/build/hooks/use-color-scheme.js +4 -13
- package/build/hooks/use-event.d.ts +6 -0
- package/build/hooks/use-event.js +15 -0
- package/build/hooks/use-native-view-controller.d.ts +3 -0
- package/build/hooks/use-native-view-controller.js +15 -0
- package/build/hooks/use-pressability.d.ts +34 -0
- package/build/hooks/use-pressability.js +39 -0
- package/build/hooks/use-window-dimensions.js +6 -10
- package/build/host-config.js +6 -5
- package/build/index.d.ts +23 -5
- package/build/index.js +23 -14
- package/build/jsx-runtime.d.ts +17 -16
- package/build/modules/animated/create-animated-component.d.ts +2 -2
- package/build/modules/animated/create-animated-component.js +10 -22
- package/build/modules/animated/index.d.ts +54 -41
- package/build/modules/animated/index.js +2 -27
- package/build/modules/app-registry/index.d.ts +3 -3
- package/build/modules/app-registry/index.js +2 -2
- package/build/modules/status-bar/index.android.d.ts +1 -1
- package/build/modules/status-bar/index.android.js +7 -27
- package/build/modules/status-bar/index.ios.d.ts +1 -1
- package/build/modules/status-bar/index.ios.js +4 -28
- package/build/modules/status-bar/shared.d.ts +5 -4
- package/build/modules/status-bar/shared.js +33 -6
- package/build/native-host-tags.d.ts +2 -0
- package/build/native-host-tags.js +4 -0
- package/build/render.d.ts +1 -1
- package/build/render.js +3 -1
- package/build/root-tag-context.d.ts +1 -0
- package/build/root-tag-context.js +4 -0
- package/package.json +6 -6
|
@@ -1,551 +1,53 @@
|
|
|
1
|
-
// VirtualizedList
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// invariant is a `refresh-metrics` dispatched from the render body; the after-commit effects come
|
|
14
|
-
// from a `commit` dispatched in a layout effect (before paint, so MVCP's shift lands without a
|
|
15
|
-
// visible jump). Lists have no Descriptor render fn — the cell content is React's own children.
|
|
16
|
-
//
|
|
17
|
-
// Imperative scrolling resolves to an offset in the reducer and rides the scroll node's native
|
|
18
|
-
// scrollTo command, animated by default. The `contentOffset` prop is only a fallback for the
|
|
19
|
-
// pre-mount window (the node not yet attached).
|
|
20
|
-
//
|
|
21
|
-
// WHAT IS STILL THIS COMPONENT'S, now that the scroll view is a tag and the engine owns its
|
|
22
|
-
// content node, its sticky seam and its RefreshControl placement — checked rather than assumed,
|
|
23
|
-
// because "the wrapper is gone" and "nothing is left" are different claims:
|
|
24
|
-
//
|
|
25
|
-
// the WINDOW `reduceList` decides which indices are in view, and this file is what
|
|
26
|
-
// turns that into elements. The output SHAPE is decided in JS from the
|
|
27
|
-
// app's own data through `renderItem` — the one disqualifier that no
|
|
28
|
-
// engine seam removes (`.claude/rules/host-primitive-tier.md`, tier 3).
|
|
29
|
-
// the CELL WRAPPERS a measuring `view` per cell with the inversion counter-flip, the two
|
|
30
|
-
// spacers, the header/footer/empty slots, the separator INSIDE the cell.
|
|
31
|
-
// the LIFECYCLE native events -> actions, effects run with React primitives (timers, a
|
|
32
|
-
// forced re-render, a layout effect for MVCP), one state cell.
|
|
33
|
-
// the HANDLE `buildScrollViewHandle` over the node a `ref` hands back, plus the list's
|
|
34
|
-
// own imperative surface on top of it.
|
|
35
|
-
//
|
|
36
|
-
// What is NOT: the content node, `contentContainerStyle`'s routing, where a RefreshControl goes
|
|
37
|
-
// per platform, and how a sticky header pins. The list names `sticky-header` on a cell and knows
|
|
38
|
-
// nothing else about it.
|
|
39
|
-
import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
|
|
40
|
-
import { dlog, } from '@symbiote-native/engine';
|
|
41
|
-
import { DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildScrollViewHandle, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
42
|
-
function resolveElement(component) {
|
|
43
|
-
if (component === undefined)
|
|
44
|
-
return undefined;
|
|
45
|
-
if (typeof component === 'function')
|
|
46
|
-
return createElement(component, {});
|
|
47
|
-
return component;
|
|
48
|
-
}
|
|
49
|
-
// Build the ItemSeparatorComponent element for the gap between leadingItem and trailingItem, with
|
|
50
|
-
// the highlight flag and any handle-pushed overrides merged on top (RN renders
|
|
51
|
-
// `<ItemSeparatorComponent {...separatorProps} />`). A bare element is returned as-is.
|
|
52
|
-
function renderSeparatorElement(component, leadingItem, trailingItem, overrides) {
|
|
53
|
-
if (component === undefined)
|
|
54
|
-
return undefined;
|
|
55
|
-
const props = {
|
|
56
|
-
highlighted: false,
|
|
57
|
-
leadingItem,
|
|
58
|
-
trailingItem,
|
|
59
|
-
...overrides,
|
|
60
|
-
};
|
|
61
|
-
return createElement(component, props);
|
|
62
|
-
}
|
|
63
|
-
// React 19 passes `ref` as a regular prop, so a generic function component can
|
|
64
|
-
// expose an imperative handle without forwardRef (which erases the ItemT
|
|
65
|
-
// generic). The ref is destructured here and wired through useImperativeHandle.
|
|
1
|
+
// VirtualizedList over the `scroll-view` TAG, spacers stand in for the cells outside the window
|
|
2
|
+
// The orchestration is the shared `reduceList` machine, React supplies lifecycle and elements
|
|
3
|
+
import { useImperativeHandle } from 'react';
|
|
4
|
+
import { dlog } from '@symbiote-native/engine';
|
|
5
|
+
import { buildListHandle, } from '@symbiote-native/components';
|
|
6
|
+
import { buildListChildren } from './list-children.js';
|
|
7
|
+
import { listHostOf } from './list-host.js';
|
|
8
|
+
import { accessibilityRestOf, buildScrollProps } from './list-scroll-props.js';
|
|
9
|
+
import { useListParts } from './use-list-parts.js';
|
|
10
|
+
import { useSeparators } from './use-separators.js';
|
|
11
|
+
// React 19 passes `ref` as a regular prop, so a generic function component can expose an
|
|
12
|
+
// imperative handle without `forwardRef`, which erases the `ItemT` generic
|
|
66
13
|
export function VirtualizedList(props) {
|
|
67
|
-
const
|
|
68
|
-
const {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
onScrollToIndexFailed,
|
|
106
|
-
viewabilityPairs,
|
|
14
|
+
const { config, driver, nesting, handlers } = useListParts(props);
|
|
15
|
+
const { state, dispatch, scroll } = driver;
|
|
16
|
+
const metrics = state.metrics;
|
|
17
|
+
const separators = useSeparators(metrics.count);
|
|
18
|
+
useImperativeHandle(props.ref ?? null, () => buildListHandle({
|
|
19
|
+
dispatch,
|
|
20
|
+
scrollHandle: scroll.handle,
|
|
21
|
+
getNode: () => scroll.nodeRef.current,
|
|
22
|
+
}), [dispatch, scroll.handle, scroll.nodeRef]);
|
|
23
|
+
dlog(`VirtualizedList window [${metrics.first}, ${metrics.last}] of ${metrics.count} ` +
|
|
24
|
+
`(offset=${state.scrollOffset}, viewport=${state.viewportLength})`);
|
|
25
|
+
const accessibilityRest = accessibilityRestOf(props);
|
|
26
|
+
return listHostOf({
|
|
27
|
+
config,
|
|
28
|
+
nesting,
|
|
29
|
+
accessibilityRest,
|
|
30
|
+
nodeRef: scroll.nodeRef,
|
|
31
|
+
children: buildListChildren({
|
|
32
|
+
config,
|
|
33
|
+
metrics,
|
|
34
|
+
separators,
|
|
35
|
+
measureCell: handlers.measureCell,
|
|
36
|
+
focusCell: handlers.focusCell,
|
|
37
|
+
}),
|
|
38
|
+
scrollProps: buildScrollProps({
|
|
39
|
+
config,
|
|
40
|
+
accessibilityRest,
|
|
41
|
+
total: metrics.total,
|
|
42
|
+
onScroll: handlers.onScroll,
|
|
43
|
+
onScrollBeginDrag: handlers.onScrollBeginDrag,
|
|
44
|
+
onScrollEndDrag: handlers.onScrollEndDrag,
|
|
45
|
+
onMomentumScrollBegin: handlers.onMomentumScrollBegin,
|
|
46
|
+
onMomentumScrollEnd: handlers.onMomentumScrollEnd,
|
|
47
|
+
onContentSizeChange: handlers.onContentSizeChange,
|
|
48
|
+
onLayout: handlers.onViewportLayout,
|
|
49
|
+
commandedOffset: scroll.commandedOffset,
|
|
50
|
+
hasHeader: config.ListHeaderComponent !== undefined,
|
|
51
|
+
}),
|
|
107
52
|
});
|
|
108
|
-
handlersRef.current = {
|
|
109
|
-
onEndReached,
|
|
110
|
-
onStartReached,
|
|
111
|
-
onScrollToIndexFailed,
|
|
112
|
-
viewabilityPairs,
|
|
113
|
-
};
|
|
114
|
-
// The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
|
|
115
|
-
const stateRef = useRef(null);
|
|
116
|
-
const state = (stateRef.current ??= createInitialListState());
|
|
117
|
-
const [, forceRender] = useReducer((tick) => tick + 1, 0);
|
|
118
|
-
// The offset we are imperatively driving native to (scrollTo* before the handle attaches). Pushed
|
|
119
|
-
// down as the ScrollView's contentOffset prop; fresh object identity each time. undefined = none.
|
|
120
|
-
const [commandedOffset, setCommandedOffset] = useState(undefined);
|
|
121
|
-
// The engine node of the scroll TAG. A `ref` on an intrinsic hands back the host instance, so
|
|
122
|
-
// there is no wrapper left to run a `useImperativeHandle` — `buildScrollViewHandle` is what turns
|
|
123
|
-
// the node into RN's scroll surface, and it is the same function every other adapter feeds from
|
|
124
|
-
// its own host-instance accessor. Lazy getter: the node is null until commit.
|
|
125
|
-
const scrollNodeRef = useRef(null);
|
|
126
|
-
const scrollHandle = useMemo(() => buildScrollViewHandle(() => scrollNodeRef.current), []);
|
|
127
|
-
// Pending minimumViewTime debounce timer / the incremental-fill timer (adapter owns the timers;
|
|
128
|
-
// the reducer only asks for a delay).
|
|
129
|
-
const viewableTimerRef = useRef(null);
|
|
130
|
-
const batchTimerRef = useRef(null);
|
|
131
|
-
// Per-gap separator overrides (keyed by the LEADING cell index of the gap) stay adapter-side: they
|
|
132
|
-
// are render state read directly in the cell walk, not part of the windowing orchestration.
|
|
133
|
-
const separatorOverridesRef = useRef(new Map());
|
|
134
|
-
const [, setSeparatorVersion] = useState(EMPTY_OFFSET);
|
|
135
|
-
// Drive a native scroll (or, before the handle attaches, the contentOffset fallback).
|
|
136
|
-
const scrollToPixel = useCallback((offset, animated) => {
|
|
137
|
-
const clamped = Math.max(EMPTY_OFFSET, offset);
|
|
138
|
-
const isHorizontal = inputsRef.current.horizontal;
|
|
139
|
-
const target = isHorizontal
|
|
140
|
-
? { x: clamped, y: EMPTY_OFFSET }
|
|
141
|
-
: { x: EMPTY_OFFSET, y: clamped };
|
|
142
|
-
if (scrollNodeRef.current !== null) {
|
|
143
|
-
dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
|
|
144
|
-
scrollHandle.scrollTo({ x: target.x, y: target.y, animated });
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
dlog(`VirtualizedList scrollTo offset=${clamped} pending-ref (horizontal=${isHorizontal})`);
|
|
148
|
-
setCommandedOffset(target);
|
|
149
|
-
}, [scrollHandle]);
|
|
150
|
-
// dispatch and runEffects are mutually recursive (a schedule-refill / fire-viewable effect
|
|
151
|
-
// dispatches a follow-up action), so runEffects reaches dispatch through a ref.
|
|
152
|
-
const dispatchRef = useRef(() => { });
|
|
153
|
-
const runEffects = useCallback((effects) => {
|
|
154
|
-
const handlers = handlersRef.current;
|
|
155
|
-
for (const effect of effects) {
|
|
156
|
-
switch (effect.kind) {
|
|
157
|
-
case 'scroll-to':
|
|
158
|
-
scrollToPixel(effect.offset, effect.animated);
|
|
159
|
-
break;
|
|
160
|
-
case 'fire-end-reached':
|
|
161
|
-
handlers.onEndReached?.({
|
|
162
|
-
distanceFromEnd: effect.distanceFromEnd,
|
|
163
|
-
});
|
|
164
|
-
break;
|
|
165
|
-
case 'fire-start-reached':
|
|
166
|
-
handlers.onStartReached?.({
|
|
167
|
-
distanceFromStart: effect.distanceFromStart,
|
|
168
|
-
});
|
|
169
|
-
break;
|
|
170
|
-
case 'fire-scroll-to-index-failed':
|
|
171
|
-
handlers.onScrollToIndexFailed?.({
|
|
172
|
-
index: effect.index,
|
|
173
|
-
highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
|
|
174
|
-
averageItemLength: effect.averageItemLength,
|
|
175
|
-
});
|
|
176
|
-
break;
|
|
177
|
-
case 'schedule-refill': {
|
|
178
|
-
if (batchTimerRef.current !== null)
|
|
179
|
-
clearTimeout(batchTimerRef.current);
|
|
180
|
-
batchTimerRef.current = setTimeout(() => {
|
|
181
|
-
batchTimerRef.current = null;
|
|
182
|
-
dispatchRef.current({ kind: 'batch-tick' });
|
|
183
|
-
}, effect.delay);
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
case 'fire-viewable': {
|
|
187
|
-
const pairs = handlers.viewabilityPairs;
|
|
188
|
-
const info = effect.info;
|
|
189
|
-
const map = effect.map;
|
|
190
|
-
const fire = () => {
|
|
191
|
-
for (const pair of pairs) {
|
|
192
|
-
pair.onViewableItemsChanged({
|
|
193
|
-
...info,
|
|
194
|
-
viewabilityConfig: pair.viewabilityConfig,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
dispatchRef.current({ kind: 'viewable-fired', map });
|
|
198
|
-
};
|
|
199
|
-
if (viewableTimerRef.current !== null) {
|
|
200
|
-
clearTimeout(viewableTimerRef.current);
|
|
201
|
-
viewableTimerRef.current = null;
|
|
202
|
-
}
|
|
203
|
-
if (effect.delay > EMPTY_OFFSET) {
|
|
204
|
-
viewableTimerRef.current = setTimeout(() => {
|
|
205
|
-
viewableTimerRef.current = null;
|
|
206
|
-
fire();
|
|
207
|
-
}, effect.delay);
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
fire();
|
|
211
|
-
}
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}, [scrollToPixel]);
|
|
217
|
-
const dispatch = useCallback((action) => {
|
|
218
|
-
const current = stateRef.current;
|
|
219
|
-
if (current === null)
|
|
220
|
-
return;
|
|
221
|
-
const result = reduceList(current, action, inputsRef.current);
|
|
222
|
-
runEffects(result.effects);
|
|
223
|
-
if (result.changed)
|
|
224
|
-
forceRender();
|
|
225
|
-
}, [runEffects]);
|
|
226
|
-
dispatchRef.current = dispatch;
|
|
227
|
-
// The single derive-per-render: recompute the window off the current state before reading it.
|
|
228
|
-
reduceList(state, { kind: 'refresh-metrics' }, inputs);
|
|
229
|
-
const m = state.metrics;
|
|
230
|
-
const { count, offsets, lengths, total, first, last } = m;
|
|
231
|
-
const commitSignature = listEffectSignature(state);
|
|
232
|
-
dlog(`VirtualizedList window [${first}, ${last}] of ${count} ` +
|
|
233
|
-
`(offset=${state.scrollOffset}, viewport=${state.viewportLength}, rendered=${Math.max(0, last - first + 1)})`);
|
|
234
|
-
const onScroll = useCallback((event) => {
|
|
235
|
-
const offset = readScrollOffset(event, horizontal);
|
|
236
|
-
if (offset === undefined)
|
|
237
|
-
return;
|
|
238
|
-
dlog(`VirtualizedList onScroll offset=${offset}`);
|
|
239
|
-
// A real user/native scroll supersedes any pending commanded offset.
|
|
240
|
-
setCommandedOffset(undefined);
|
|
241
|
-
dispatch({ kind: 'scroll', offset });
|
|
242
|
-
// Compose, don't clobber: internal windowing ran first, now the user's onScroll.
|
|
243
|
-
if (userOnScroll !== undefined)
|
|
244
|
-
userOnScroll(event);
|
|
245
|
-
}, [horizontal, userOnScroll, dispatch]);
|
|
246
|
-
const onViewportLayout = useCallback((event) => {
|
|
247
|
-
const length = readLayoutLength(event, horizontal);
|
|
248
|
-
if (length === undefined)
|
|
249
|
-
return;
|
|
250
|
-
dlog(`VirtualizedList onLayout viewport=${length}`);
|
|
251
|
-
dispatch({ kind: 'layout', length });
|
|
252
|
-
}, [horizontal, dispatch]);
|
|
253
|
-
const makeCellMeasure = useCallback((index) => (event) => {
|
|
254
|
-
const length = readLayoutLength(event, horizontal);
|
|
255
|
-
if (length === undefined)
|
|
256
|
-
return;
|
|
257
|
-
const offset = readLayoutOffset(event, horizontal);
|
|
258
|
-
dlog(`VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
|
|
259
|
-
dispatch({ kind: 'measure', index, length, offset });
|
|
260
|
-
}, [horizontal, dispatch]);
|
|
261
|
-
// Merge an override onto the separator at a given gap and request a re-render. A gap index
|
|
262
|
-
// outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
|
|
263
|
-
const mergeSeparator = useCallback((gapIndex, patch) => {
|
|
264
|
-
if (!isSeparatorGapInRange(gapIndex, count))
|
|
265
|
-
return;
|
|
266
|
-
const overrides = separatorOverridesRef.current;
|
|
267
|
-
overrides.set(gapIndex, { ...overrides.get(gapIndex), ...patch });
|
|
268
|
-
setSeparatorVersion(version => version + 1);
|
|
269
|
-
}, [count]);
|
|
270
|
-
// The ISeparators handle for the cell at `index` (RN CellRenderer._separators).
|
|
271
|
-
const makeSeparators = useCallback((index) => ({
|
|
272
|
-
highlight: () => {
|
|
273
|
-
dlog(`VirtualizedList separator highlight cell=${index}`);
|
|
274
|
-
mergeSeparator(index - 1, { highlighted: true });
|
|
275
|
-
mergeSeparator(index, { highlighted: true });
|
|
276
|
-
},
|
|
277
|
-
unhighlight: () => {
|
|
278
|
-
dlog(`VirtualizedList separator unhighlight cell=${index}`);
|
|
279
|
-
mergeSeparator(index - 1, { highlighted: false });
|
|
280
|
-
mergeSeparator(index, { highlighted: false });
|
|
281
|
-
},
|
|
282
|
-
updateProps: (select, newProps) => {
|
|
283
|
-
mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
|
|
284
|
-
},
|
|
285
|
-
}), [mergeSeparator]);
|
|
286
|
-
const keyForIndex = useCallback((index) => resolveItemKey(getItem(data, index), index, keyExtractor), [getItem, data, keyExtractor]);
|
|
287
|
-
useImperativeHandle(forwardedRef ?? null, () => ({
|
|
288
|
-
// RN animates every imperative scroll unless the caller passes animated: false. Each resolves
|
|
289
|
-
// to an offset (or a scroll-to-index failure) inside the reducer, then rides scroll-to.
|
|
290
|
-
scrollToOffset: (params) => {
|
|
291
|
-
dispatch({
|
|
292
|
-
kind: 'scroll-to-offset',
|
|
293
|
-
offset: params.offset,
|
|
294
|
-
animated: params.animated ?? true,
|
|
295
|
-
});
|
|
296
|
-
},
|
|
297
|
-
scrollToIndex: (params) => {
|
|
298
|
-
dispatch({
|
|
299
|
-
kind: 'scroll-to-index',
|
|
300
|
-
index: params.index,
|
|
301
|
-
animated: params.animated ?? true,
|
|
302
|
-
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
303
|
-
viewOffset: params.viewOffset ?? EMPTY_OFFSET,
|
|
304
|
-
});
|
|
305
|
-
},
|
|
306
|
-
scrollToItem: (params) => {
|
|
307
|
-
dispatch({
|
|
308
|
-
kind: 'scroll-to-item',
|
|
309
|
-
item: params.item,
|
|
310
|
-
animated: params.animated ?? true,
|
|
311
|
-
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
312
|
-
});
|
|
313
|
-
},
|
|
314
|
-
scrollToEnd: (params) => {
|
|
315
|
-
dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
|
|
316
|
-
},
|
|
317
|
-
flashScrollIndicators: () => {
|
|
318
|
-
scrollHandle.flashScrollIndicators?.();
|
|
319
|
-
},
|
|
320
|
-
// Null until the tag commits, so these keep RN's "no scroll view yet" answer rather than
|
|
321
|
-
// handing back a handle that would silently no-op.
|
|
322
|
-
getNativeScrollRef: () => scrollNodeRef.current !== null ? scrollHandle : null,
|
|
323
|
-
getScrollableNode: () => scrollNodeRef.current !== null ? scrollHandle : null,
|
|
324
|
-
getScrollResponder: () => scrollNodeRef.current !== null ? scrollHandle : null,
|
|
325
|
-
getScrollNode: () => scrollNodeRef.current,
|
|
326
|
-
// Manual trigger for RN's recordInteraction: flip the interaction flag so
|
|
327
|
-
// waitForInteraction viewability configs start reporting.
|
|
328
|
-
recordInteraction: () => {
|
|
329
|
-
dispatch({ kind: 'record-interaction' });
|
|
330
|
-
},
|
|
331
|
-
}), [dispatch, scrollHandle]);
|
|
332
|
-
// After-commit pass: run the deferred effects (batch fill, edge-reached, viewability,
|
|
333
|
-
// initial-scroll, MVCP) in a LAYOUT effect so MVCP's shift lands before paint. Re-runs only when
|
|
334
|
-
// the windowing signature changed — the same dedup key every adapter shares.
|
|
335
|
-
useLayoutEffect(() => {
|
|
336
|
-
const current = stateRef.current;
|
|
337
|
-
if (current === null)
|
|
338
|
-
return;
|
|
339
|
-
const result = reduceList(current, { kind: 'commit' }, inputsRef.current);
|
|
340
|
-
runEffects(result.effects);
|
|
341
|
-
}, [commitSignature, runEffects]);
|
|
342
|
-
// Clear any pending timers on unmount (RN ViewabilityHelper.dispose + the fill timer).
|
|
343
|
-
useEffect(() => {
|
|
344
|
-
return () => {
|
|
345
|
-
if (viewableTimerRef.current !== null)
|
|
346
|
-
clearTimeout(viewableTimerRef.current);
|
|
347
|
-
if (batchTimerRef.current !== null)
|
|
348
|
-
clearTimeout(batchTimerRef.current);
|
|
349
|
-
};
|
|
350
|
-
}, []);
|
|
351
|
-
// ---- assemble the windowed child list ----------------------------------
|
|
352
|
-
// extraData needs no wiring: this component is not memoized, so any prop change (including
|
|
353
|
-
// extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
|
|
354
|
-
void extraData;
|
|
355
|
-
const children = [];
|
|
356
|
-
const stickySet = stickyHeaderIndices !== undefined
|
|
357
|
-
? new Set(stickyHeaderIndices)
|
|
358
|
-
: undefined;
|
|
359
|
-
const header = resolveElement(ListHeaderComponent);
|
|
360
|
-
if (header !== undefined) {
|
|
361
|
-
children.push(createElement('view', { key: 'list-header' }, header));
|
|
362
|
-
}
|
|
363
|
-
if (count === FIRST_INDEX) {
|
|
364
|
-
const empty = resolveElement(ListEmptyComponent);
|
|
365
|
-
if (empty !== undefined) {
|
|
366
|
-
children.push(createElement('view', { key: 'list-empty' }, empty));
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
// The shared plan: spacer extents, in-window cell keys, and the sticky child positions.
|
|
371
|
-
const plan = buildListPlan({
|
|
372
|
-
count,
|
|
373
|
-
first,
|
|
374
|
-
last,
|
|
375
|
-
offsets,
|
|
376
|
-
lengths,
|
|
377
|
-
total,
|
|
378
|
-
keyFor: keyForIndex,
|
|
379
|
-
stickyIndices: stickySet,
|
|
380
|
-
hasHeader: header !== undefined,
|
|
381
|
-
});
|
|
382
|
-
// `plan.stickyChildPositions` is deliberately NOT read. It was the input to the wrapper's own
|
|
383
|
-
// JS sticky pass, and the behavior honours `stickyHeaderIndices` by numbering the OWNER's paint
|
|
384
|
-
// children — which a windowed list cannot supply: it paints a header, a spacer and a slice, so
|
|
385
|
-
// data index 3 is almost never paint child 3, and the positions would have to be recomputed
|
|
386
|
-
// every time the window slides. The cells that should pin carry the `sticky-header` TAG
|
|
387
|
-
// instead, which pins by document order and survives windowing.
|
|
388
|
-
if (plan.leadingExtent > EMPTY_OFFSET) {
|
|
389
|
-
children.push(createElement('view', {
|
|
390
|
-
key: 'spacer-leading',
|
|
391
|
-
style: horizontal
|
|
392
|
-
? { width: plan.leadingExtent }
|
|
393
|
-
: { height: plan.leadingExtent },
|
|
394
|
-
}));
|
|
395
|
-
}
|
|
396
|
-
// The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
|
|
397
|
-
// below the window, kept alive outside [first,last] so it never gets destroyed/recreated
|
|
398
|
-
// as the window slides back over its origin index. Rendered exactly like an in-window
|
|
399
|
-
// cell — same measuring wrapper, same key format (so a transition between forced and
|
|
400
|
-
// in-window rendering of the SAME index keeps the same React key and never remounts).
|
|
401
|
-
if (plan.forcedStickyCell !== undefined) {
|
|
402
|
-
const forcedIndex = plan.forcedStickyCell.index;
|
|
403
|
-
const forcedItem = getItem(data, forcedIndex);
|
|
404
|
-
const forcedCell = renderItem({
|
|
405
|
-
item: forcedItem,
|
|
406
|
-
index: forcedIndex,
|
|
407
|
-
separators: makeSeparators(forcedIndex),
|
|
408
|
-
});
|
|
409
|
-
children.push(createElement(
|
|
410
|
-
// Sticky by construction — `forcedStickyCell` exists only for an index in `stickySet` —
|
|
411
|
-
// and the SAME tag the in-window branch below uses, so a cell crossing between the two
|
|
412
|
-
// keeps its element type as well as its key and never remounts.
|
|
413
|
-
'sticky-header', {
|
|
414
|
-
key: `cell-${plan.forcedStickyCell.key}`,
|
|
415
|
-
onLayout: makeCellMeasure(forcedIndex),
|
|
416
|
-
style: inverted
|
|
417
|
-
? horizontal
|
|
418
|
-
? INVERTED_X_STYLE
|
|
419
|
-
: INVERTED_Y_STYLE
|
|
420
|
-
: undefined,
|
|
421
|
-
}, forcedCell));
|
|
422
|
-
}
|
|
423
|
-
if (plan.gapExtent > EMPTY_OFFSET) {
|
|
424
|
-
children.push(createElement('view', {
|
|
425
|
-
key: 'spacer-gap',
|
|
426
|
-
style: horizontal
|
|
427
|
-
? { width: plan.gapExtent }
|
|
428
|
-
: { height: plan.gapExtent },
|
|
429
|
-
}));
|
|
430
|
-
}
|
|
431
|
-
for (const planCell of plan.cells) {
|
|
432
|
-
const item = getItem(data, planCell.index);
|
|
433
|
-
// renderItem gets a separators handle so a row can highlight/update its own dividers.
|
|
434
|
-
const cell = renderItem({
|
|
435
|
-
item,
|
|
436
|
-
index: planCell.index,
|
|
437
|
-
separators: makeSeparators(planCell.index),
|
|
438
|
-
});
|
|
439
|
-
// The separator lives INSIDE the cell's own measuring wrapper, not as a sibling: a sibling
|
|
440
|
-
// is an extra flex child, so a leading-spacer collapse would land every cell below it short
|
|
441
|
-
// by (separator + gap). See .claude/rules/list-geometry-feedback-loop.md.
|
|
442
|
-
const separator =
|
|
443
|
-
// Gated on the last index of the DATA, not the WINDOW: since the separator lives inside
|
|
444
|
-
// the measuring wrapper, gating on the window would make a cell's own measured height
|
|
445
|
-
// change as the window slides past it.
|
|
446
|
-
planCell.index < count - 1
|
|
447
|
-
? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
|
|
448
|
-
: undefined;
|
|
449
|
-
// Wrap each cell in a measuring View. When inverted, each cell carries the counter-flip so
|
|
450
|
-
// its content reads upright inside the flipped content container.
|
|
451
|
-
//
|
|
452
|
-
// A cell the app flagged sticky is wrapped in the `sticky-header` TAG instead — same
|
|
453
|
-
// position, same key, same `onLayout` (the behavior forwards it rather than replacing it),
|
|
454
|
-
// and the engine finds this scroll view by walking up. Nothing here computes a child index:
|
|
455
|
-
// the collision point comes from the owner's DOCUMENT order, which is the one form that
|
|
456
|
-
// survives windowing.
|
|
457
|
-
children.push(createElement(stickySet?.has(planCell.index) === true ? 'sticky-header' : 'view', {
|
|
458
|
-
key: `cell-${planCell.key}`,
|
|
459
|
-
onLayout: makeCellMeasure(planCell.index),
|
|
460
|
-
style: inverted
|
|
461
|
-
? horizontal
|
|
462
|
-
? INVERTED_X_STYLE
|
|
463
|
-
: INVERTED_Y_STYLE
|
|
464
|
-
: undefined,
|
|
465
|
-
}, cell, separator));
|
|
466
|
-
}
|
|
467
|
-
if (plan.trailingExtent > EMPTY_OFFSET) {
|
|
468
|
-
children.push(createElement('view', {
|
|
469
|
-
key: 'spacer-trailing',
|
|
470
|
-
style: horizontal
|
|
471
|
-
? { width: plan.trailingExtent }
|
|
472
|
-
: { height: plan.trailingExtent },
|
|
473
|
-
}));
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
const footer = resolveElement(ListFooterComponent);
|
|
477
|
-
if (footer !== undefined) {
|
|
478
|
-
children.push(createElement('view', { key: 'list-footer' }, footer));
|
|
479
|
-
}
|
|
480
|
-
// A horizontal list pins the content container to the full row width so the row overflows for
|
|
481
|
-
// iOS to scroll. The inversion flip rides ONLY the outer ScrollView style and each cell, never
|
|
482
|
-
// the content container (flipping it too would cancel the ScrollView flip).
|
|
483
|
-
const resolvedContentContainerStyle = horizontal
|
|
484
|
-
? [contentContainerStyle, { width: total }]
|
|
485
|
-
: contentContainerStyle;
|
|
486
|
-
// VirtualizedList.js: `[inversionStyle, style]` — the app's style can override the flip.
|
|
487
|
-
const resolvedStyle = inverted
|
|
488
|
-
? [horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE, style]
|
|
489
|
-
: style;
|
|
490
|
-
const scrollProps = {
|
|
491
|
-
// The list's accessibility surface rides down onto the ScrollView. Spread first so the
|
|
492
|
-
// explicit windowing props below always win.
|
|
493
|
-
...accessibilityRest,
|
|
494
|
-
style: resolvedStyle,
|
|
495
|
-
contentContainerStyle: resolvedContentContainerStyle,
|
|
496
|
-
onScroll,
|
|
497
|
-
onLayout: onViewportLayout,
|
|
498
|
-
};
|
|
499
|
-
// `horizontal` is NOT in the bag: the axis is the TAG (`horizontal-scroll-view`), because Android
|
|
500
|
-
// scrolls the two axes with different native ViewManagers. The behavior's own fold deletes the
|
|
501
|
-
// prop and rewrites it from the tag, so passing it would be at best redundant and at worst a
|
|
502
|
-
// contradiction it has to warn about.
|
|
503
|
-
// VirtualizedList.js:1111 — Android moves the scrollbar back after the `scale: -1` flip.
|
|
504
|
-
if (inverted)
|
|
505
|
-
scrollProps.isInvertedVirtualizedList = true;
|
|
506
|
-
if (onScrollBeginDrag !== undefined)
|
|
507
|
-
scrollProps.onScrollBeginDrag = onScrollBeginDrag;
|
|
508
|
-
if (onScrollEndDrag !== undefined)
|
|
509
|
-
scrollProps.onScrollEndDrag = onScrollEndDrag;
|
|
510
|
-
if (onMomentumScrollBegin !== undefined)
|
|
511
|
-
scrollProps.onMomentumScrollBegin = onMomentumScrollBegin;
|
|
512
|
-
if (onMomentumScrollEnd !== undefined)
|
|
513
|
-
scrollProps.onMomentumScrollEnd = onMomentumScrollEnd;
|
|
514
|
-
if (scrollEventThrottle !== undefined)
|
|
515
|
-
scrollProps.scrollEventThrottle = scrollEventThrottle;
|
|
516
|
-
if (keyboardShouldPersistTaps !== undefined)
|
|
517
|
-
scrollProps.keyboardShouldPersistTaps = keyboardShouldPersistTaps;
|
|
518
|
-
if (keyboardDismissMode !== undefined)
|
|
519
|
-
scrollProps.keyboardDismissMode = keyboardDismissMode;
|
|
520
|
-
// A pending imperative/initial scroll rides down as contentOffset (fresh identity each push).
|
|
521
|
-
if (commandedOffset !== undefined)
|
|
522
|
-
scrollProps.contentOffset = commandedOffset;
|
|
523
|
-
// Forward maintainVisibleContentPosition to the native ScrollView so it anchors in-window cells.
|
|
524
|
-
// minIndexForVisible is bumped by 1 when a ListHeaderComponent occupies child 0.
|
|
525
|
-
if (maintainVisibleContentPosition !== undefined) {
|
|
526
|
-
scrollProps.maintainVisibleContentPosition = {
|
|
527
|
-
...maintainVisibleContentPosition,
|
|
528
|
-
minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
|
|
529
|
-
(header !== undefined ? 1 : 0),
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
// Pull-to-refresh: when onRefresh is set, the RefreshControl is an ordinary FIRST CHILD on both
|
|
533
|
-
// platforms rather than a prop. The scroll behavior CLAIMS it, so the engine keeps it beside the
|
|
534
|
-
// content view on iOS and inverts the tree on Android; everything after it lands in the content
|
|
535
|
-
// node the behavior built. refreshing is RN-required alongside onRefresh, default false.
|
|
536
|
-
const refreshControl = onRefresh !== undefined
|
|
537
|
-
? createElement('refresh-control', {
|
|
538
|
-
key: 'refresh-control',
|
|
539
|
-
refreshing: refreshing ?? false,
|
|
540
|
-
onRefresh,
|
|
541
|
-
progressViewOffset,
|
|
542
|
-
})
|
|
543
|
-
: undefined;
|
|
544
|
-
if (refreshControl !== undefined) {
|
|
545
|
-
dlog('VirtualizedList wiring RefreshControl (onRefresh provided)');
|
|
546
|
-
}
|
|
547
|
-
// The scroll TAG, not a wrapper: the engine builds the content node, carries
|
|
548
|
-
// `contentContainerStyle` onto it and places the refresh control. `ref` hands back the engine
|
|
549
|
-
// node, which is what `buildScrollViewHandle` drives for the imperative scrolls.
|
|
550
|
-
return createElement(horizontal ? 'horizontal-scroll-view' : 'scroll-view', { ...scrollProps, ref: scrollNodeRef }, refreshControl, ...children);
|
|
551
53
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ISymbioteEvent } from '@symbiote-native/engine';
|
|
3
|
+
import { type IListMetrics } from '@symbiote-native/components';
|
|
4
|
+
import type { IListConfig } from './list-config';
|
|
5
|
+
import type { IListSeparators } from './use-separators';
|
|
6
|
+
export type IChildrenArgs<ItemT> = {
|
|
7
|
+
config: IListConfig<ItemT>;
|
|
8
|
+
metrics: IListMetrics;
|
|
9
|
+
separators: IListSeparators<ItemT>;
|
|
10
|
+
measureCell: (index: number) => (event: ISymbioteEvent) => void;
|
|
11
|
+
focusCell: (index: number) => () => void;
|
|
12
|
+
};
|
|
13
|
+
export declare function buildListChildren<ItemT>(args: IChildrenArgs<ItemT>): ReactNode[];
|