@symbiote-native/solid 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/components/flat-list/index.d.ts +3 -2
- package/build/components/flat-list/index.jsx +8 -10
- package/build/components/input-accessory-view-props.d.ts +4 -4
- package/build/components/keyboard-avoiding-view.d.ts +2 -2
- package/build/components/keyboard-avoiding-view.jsx +34 -114
- package/build/components/layout-conformance-props.d.ts +6 -0
- package/build/components/layout-conformance-props.js +1 -0
- package/build/components/modal.d.ts +4 -4
- package/build/components/modal.jsx +11 -11
- package/build/components/refresh-control-props.d.ts +7 -7
- package/build/components/scroll-view-props.d.ts +8 -5
- package/build/components/text-input-props.d.ts +2 -1
- package/build/components/text-props.d.ts +3 -3
- package/build/components/touchable-props.d.ts +6 -6
- package/build/components/virtualized-list/index.android.d.ts +2 -2
- package/build/components/virtualized-list/index.ios.d.ts +2 -2
- package/build/components/virtualized-list/item-content.d.ts +6 -0
- package/build/components/virtualized-list/item-content.jsx +9 -0
- package/build/components/virtualized-list/list-body.d.ts +15 -0
- package/build/components/virtualized-list/list-body.jsx +20 -0
- package/build/components/virtualized-list/list-parts.d.ts +14 -0
- package/build/components/virtualized-list/list-parts.js +27 -0
- package/build/components/virtualized-list/list-rows.d.ts +18 -0
- package/build/components/virtualized-list/list-rows.jsx +91 -0
- package/build/components/virtualized-list/list-window.d.ts +11 -0
- package/build/components/virtualized-list/list-window.js +29 -0
- package/build/components/virtualized-list/nested-scope.d.ts +7 -0
- package/build/components/virtualized-list/nested-scope.js +21 -0
- package/build/components/virtualized-list/scroll-handlers.d.ts +12 -0
- package/build/components/virtualized-list/scroll-handlers.js +31 -0
- package/build/components/virtualized-list/scroll-tree.d.ts +18 -0
- package/build/components/virtualized-list/scroll-tree.jsx +143 -0
- package/build/components/virtualized-list/shared.d.ts +3 -74
- package/build/components/virtualized-list/shared.jsx +53 -752
- package/build/components/virtualized-list/sticky-binding.d.ts +13 -0
- package/build/components/virtualized-list/sticky-binding.js +111 -0
- package/build/components/virtualized-list/sticky-header.d.ts +1 -0
- package/build/components/virtualized-list/sticky-header.jsx +11 -159
- package/build/components/virtualized-list/sticky-state.d.ts +14 -0
- package/build/components/virtualized-list/sticky-state.js +54 -0
- package/build/components/virtualized-list/use-list-driver.d.ts +22 -0
- package/build/components/virtualized-list/use-list-driver.js +128 -0
- package/build/components/virtualized-list/use-separators.d.ts +7 -0
- package/build/components/virtualized-list/use-separators.js +22 -0
- package/build/components/virtualized-list/virtualized-list-props.d.ts +86 -0
- package/build/components/virtualized-list/virtualized-list-props.js +53 -0
- package/build/components/virtualized-section-list/index.d.ts +22 -9
- package/build/components/virtualized-section-list/index.jsx +87 -115
- package/build/components/virtualized-section-list/section-item-cell.d.ts +18 -0
- package/build/components/virtualized-section-list/section-item-cell.jsx +37 -0
- package/build/components.d.ts +5 -1
- package/build/components.js +1 -0
- package/build/index.d.ts +10 -4
- package/build/index.js +21 -37
- package/build/modules/animated/index.d.ts +54 -41
- package/build/modules/animated/index.js +2 -26
- package/build/modules/app-registry/index.d.ts +3 -3
- package/build/modules/app-registry/index.js +2 -2
- package/build/modules/status-bar.js +14 -27
- package/build/primitives/create-color-scheme.d.ts +1 -1
- package/build/render.js +16 -28
- package/build/root-tag-context.d.ts +1 -0
- package/build/root-tag-context.js +4 -0
- package/package.json +6 -6
|
@@ -1,759 +1,60 @@
|
|
|
1
|
-
// VirtualizedList
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// here. RefreshControl is an ordinary child now; `claimedChildren` decides where it lands.
|
|
20
|
-
//
|
|
21
|
-
// WHY THIS FILE STILL HAND-AUTHORS THE SCROLL TAG VIA `createElement` INSTEAD OF PLAIN JSX
|
|
22
|
-
// (`<scroll-view>{listBody}</scroll-view>`), unlike a component with no dynamic children. Solid's
|
|
23
|
-
// own `insert()` reconciles a parent's children by walking that SAME node's `getFirstChild`/
|
|
24
|
-
// `getNextSibling` — which read `node.children` directly and know nothing about `childHost`
|
|
25
|
-
// redirection. Calling `insert` on the SCROLL node itself would have it diff against the scroll
|
|
26
|
-
// node's own (single) child rather than the content node's list items. So this file keeps an
|
|
27
|
-
// explicit reference to the content node and calls `insert(content, listBody)` on THAT, exactly the
|
|
28
|
-
// node Solid's own diffing needs to see.
|
|
29
|
-
//
|
|
30
|
-
// WHY THE SCROLL CELLS ARE WALKED FOR STICKY HEADERS RATHER THAN LEFT TO THE ENGINE'S GENERIC
|
|
31
|
-
// `stickyHeaderIndices` WALK OF COMMITTED CHILDREN (what a bare `<scroll-view>` gets for free
|
|
32
|
-
// elsewhere): the flagged CELL here needs wrapping ONCE, inside the keyed <For> row that owns it, or
|
|
33
|
-
// every scroll event would tear the wrapper down and rebuild it (losing its measured layout each
|
|
34
|
-
// time) the moment a naive re-render tried to resolve which child is sticky. Same conclusion, same
|
|
35
|
-
// reason, as adapters/svelte's index.svelte. This is independent of the content-node question above
|
|
36
|
-
// — it is about the LIST's own cells, not about how the scroll host itself is built.
|
|
37
|
-
//
|
|
38
|
-
// THREE SOLID FACTS DRIVE THE REST, and none of them is cosmetic (.claude/rules/
|
|
39
|
-
// solid-descriptor-bridge.md):
|
|
40
|
-
//
|
|
41
|
-
// 1. A component body runs ONCE and `insert` REPLACES rather than diffing. So the cells ride a keyed
|
|
42
|
-
// <For> over the cell KEY strings — <For> matches by value identity, which is what makes a row
|
|
43
|
-
// MOVE as the window slides instead of being destroyed and rebuilt. <Index> would have been
|
|
44
|
-
// exactly wrong here: it keys by position, so row 0 would be reused for a different item on every
|
|
45
|
-
// scroll and its whole `renderItem` subtree would be rebuilt each step.
|
|
46
|
-
// 2. `renderItem` is a render prop, so its info arrives as an ACCESSOR and the call is untracked — a
|
|
47
|
-
// tracked call would rebuild the cell subtree on every change (trap 4). Same shape
|
|
48
|
-
// `<For>{(item, index) => …}` and Pressable's child already use.
|
|
49
|
-
// 3. The scroll AXIS picks a different host TAG and Solid cannot swap a tag under a live node — so
|
|
50
|
-
// the flip is an explicit rebuild boundary (trap 5), with the build untracked so every other prop
|
|
51
|
-
// re-props the SAME nodes through `spread`.
|
|
52
|
-
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack, } from 'solid-js';
|
|
53
|
-
import { DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, NO_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildScrollViewHandle, buildViewabilityPairs, nextStickyHeaderY, isSeparatorGapInRange, listEffectSignature, attachStickyScroll, forwardScrollEvent, resolveAccessibilityProps, resolveScrollForwarding, selectScrollIntrinsics, createInitialListState, readLayoutLength, readLayoutOffset, readLayoutNumber, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
54
|
-
import { AnimatedValue, dlog, event as animatedEvent, getNativeTag, isNativeAnimatedAvailable, isSymbioteNode, whenCommitted, resolveClassName, } from '@symbiote-native/engine';
|
|
55
|
-
import { createElement, insert, insertNode, spread } from '../../renderer.js';
|
|
56
|
-
import { withStableKeys } from '../../utils/stable-keys.js';
|
|
57
|
-
import { ScrollViewStickyHeader } from './sticky-header.jsx';
|
|
58
|
-
// Consumed by the lifecycle itself; everything LEFT OVER is the accessibility surface, which rides
|
|
59
|
-
// down onto the scroll host exactly as React spreads `...accessibilityRest` onto its ScrollView.
|
|
60
|
-
const HANDLED_PROPS = [
|
|
61
|
-
'data',
|
|
62
|
-
'getItem',
|
|
63
|
-
'getItemCount',
|
|
64
|
-
'renderItem',
|
|
65
|
-
'getItemLayout',
|
|
66
|
-
'initialNumToRender',
|
|
67
|
-
'windowSize',
|
|
68
|
-
'ListHeaderComponent',
|
|
69
|
-
'ListFooterComponent',
|
|
70
|
-
'ListEmptyComponent',
|
|
71
|
-
'ItemSeparatorComponent',
|
|
72
|
-
'ref',
|
|
73
|
-
'onEndReached',
|
|
74
|
-
'onEndReachedThreshold',
|
|
75
|
-
'onStartReached',
|
|
76
|
-
'onStartReachedThreshold',
|
|
77
|
-
'keyExtractor',
|
|
78
|
-
'onViewableItemsChanged',
|
|
79
|
-
'viewabilityConfig',
|
|
80
|
-
'viewabilityConfigCallbackPairs',
|
|
81
|
-
'onScroll',
|
|
82
|
-
'onScrollBeginDrag',
|
|
83
|
-
'onScrollEndDrag',
|
|
84
|
-
'onMomentumScrollBegin',
|
|
85
|
-
'onMomentumScrollEnd',
|
|
86
|
-
'scrollEventThrottle',
|
|
87
|
-
'keyboardShouldPersistTaps',
|
|
88
|
-
'keyboardDismissMode',
|
|
89
|
-
'horizontal',
|
|
90
|
-
'inverted',
|
|
91
|
-
'style',
|
|
92
|
-
'contentContainerStyle',
|
|
93
|
-
'class',
|
|
94
|
-
'onScrollToIndexFailed',
|
|
95
|
-
'onRefresh',
|
|
96
|
-
'refreshing',
|
|
97
|
-
'progressViewOffset',
|
|
98
|
-
'stickyHeaderIndices',
|
|
99
|
-
'maintainVisibleContentPosition',
|
|
100
|
-
'initialScrollIndex',
|
|
101
|
-
'maxToRenderPerBatch',
|
|
102
|
-
'updateCellsBatchingPeriod',
|
|
103
|
-
'extraData',
|
|
104
|
-
];
|
|
105
|
-
// A ListHeaderComponent occupies child 0 of the content container, which shifts every data index
|
|
106
|
-
// native MVCP anchors against by one (RN VirtualizedList.js does the same bump).
|
|
107
|
-
const HEADER_CHILD_COUNT = 1;
|
|
108
|
-
// No platform parameter any more: the RefreshControl placement per platform (a sibling on iOS, an
|
|
109
|
-
// inverting wrap on Android) is entirely the tag's own engine behavior's concern now
|
|
110
|
-
// (`core/components/src/behaviors/scroll-view/index.{ios,android}.ts`) — this factory used to take
|
|
111
|
-
// an `IScrollViewHostPlatform` only to re-decide the same thing by hand.
|
|
1
|
+
// VirtualizedList over the `<scroll-view>` tag's scroll host, Solid supplies only the lifecycle
|
|
2
|
+
// The body runs ONCE and `insert` replaces rather than diffs, so rows ride a keyed `<For>` and MOVE
|
|
3
|
+
import { splitProps, untrack } from 'solid-js';
|
|
4
|
+
import { FIRST_INDEX, listStyleOf, resolveAccessibilityProps, } from '@symbiote-native/components';
|
|
5
|
+
import { provideVirtualizedListScope } from './nested-scope.js';
|
|
6
|
+
import { buildRow } from './list-rows.jsx';
|
|
7
|
+
import { listBody } from './list-body.jsx';
|
|
8
|
+
import { createListParts } from './list-parts.js';
|
|
9
|
+
import { createScrollTree } from './scroll-tree.jsx';
|
|
10
|
+
import { createSeparators } from './use-separators.js';
|
|
11
|
+
import { HANDLED_PROPS, } from './virtualized-list-props.js';
|
|
12
|
+
// A slot wrapper counter-flips with an inverted list, then takes the slot's own style
|
|
13
|
+
function slotStyleOf(props, slotStyle) {
|
|
14
|
+
return listStyleOf({
|
|
15
|
+
inverted: props.inverted === true,
|
|
16
|
+
horizontal: props.horizontal === true,
|
|
17
|
+
}, slotStyle);
|
|
18
|
+
}
|
|
112
19
|
export function createVirtualizedList() {
|
|
113
20
|
return function VirtualizedList(props) {
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const emptyElement = untrack(() => props.ListEmptyComponent);
|
|
119
|
-
// The scroll host, held by IDENTITY in a plain variable: a store or any proxy wrapper would be a
|
|
120
|
-
// different key than the one the engine's commit mirror holds, and every imperative command would
|
|
121
|
-
// silently no-op (symbiote-engine-core §3).
|
|
122
|
-
let hostNode = null;
|
|
123
|
-
// A LAZY node getter, not the node captured once: it is null until the tree builds, and every
|
|
124
|
-
// command re-reads it.
|
|
125
|
-
const scrollHandle = buildScrollViewHandle(() => hostNode);
|
|
21
|
+
// Each slot is read once, a JSX prop is a getter that builds the element on read
|
|
22
|
+
const header = untrack(() => props.ListHeaderComponent);
|
|
23
|
+
const footer = untrack(() => props.ListFooterComponent);
|
|
24
|
+
const empty = untrack(() => props.ListEmptyComponent);
|
|
126
25
|
const [, accessibilityRest] = splitProps(props, HANDLED_PROPS);
|
|
127
|
-
const
|
|
128
|
-
//
|
|
129
|
-
// exactly like React's unconditional hook, and held by identity.
|
|
130
|
-
const scrollAnimatedValue = new AnimatedValue(0);
|
|
131
|
-
// Sticky cross-talk (RN's _headerLayoutYs): a data-index -> measured-y map so each header learns
|
|
132
|
-
// where the NEXT one starts, its push-off collision point. Mutated imperatively from each
|
|
133
|
-
// header's onLayout; the version bump is what lets the PREVIOUS header re-read it.
|
|
134
|
-
const headerLayoutYs = new Map();
|
|
135
|
-
const [stickyVersion, setStickyVersion] = createSignal(0);
|
|
136
|
-
const stickySet = createMemo(() => props.stickyHeaderIndices !== undefined
|
|
137
|
-
? new Set(props.stickyHeaderIndices)
|
|
138
|
-
: undefined);
|
|
139
|
-
const hasStickyHeaders = () => {
|
|
140
|
-
const indices = props.stickyHeaderIndices;
|
|
141
|
-
return indices !== undefined && indices.length > FIRST_INDEX;
|
|
142
|
-
};
|
|
143
|
-
const nativeStickyAvailable = () => hasStickyHeaders() && isNativeAnimatedAvailable();
|
|
144
|
-
// RN's FlatList/SectionList expose no invertStickyHeaders of their own (only ScrollView does), so
|
|
145
|
-
// list headers always pin to the top and the inverted viewport capture is never needed.
|
|
146
|
-
const forwarding = createMemo(() => resolveScrollForwarding({
|
|
147
|
-
hasStickyHeaders: hasStickyHeaders(),
|
|
148
|
-
nativeStickyAvailable: nativeStickyAvailable(),
|
|
149
|
-
invertStickyHeaders: undefined,
|
|
150
|
-
scrollEventThrottle: props.scrollEventThrottle,
|
|
151
|
-
maintainVisibleContentPosition: props.maintainVisibleContentPosition,
|
|
152
|
-
snapToAlignment: undefined,
|
|
153
|
-
}));
|
|
154
|
-
// Inversion flips the scroll container along the scroll axis; each cell re-flips so its own content
|
|
155
|
-
// stays upright (RN does the same with a scale(-1) transform). The content CONTAINER is left alone
|
|
156
|
-
// — flipping it too would cancel the outer flip.
|
|
157
|
-
const invertedStyle = () => {
|
|
158
|
-
if (props.inverted !== true)
|
|
159
|
-
return undefined;
|
|
160
|
-
return isHorizontal() ? INVERTED_X_STYLE : INVERTED_Y_STYLE;
|
|
161
|
-
};
|
|
162
|
-
const listState = createInitialListState();
|
|
163
|
-
// Bumped on every render-relevant transition; that bump is what re-derives `metrics`. listState is
|
|
164
|
-
// a plain object, so mutating it triggers nothing on its own.
|
|
165
|
-
const [version, setVersion] = createSignal(0);
|
|
166
|
-
// Per-gap separator overrides, keyed by the LEADING cell index of the gap. Render state read
|
|
167
|
-
// directly in the cell walk, not part of the windowing orchestration, so it stays adapter-side.
|
|
168
|
-
const separatorOverrides = new Map();
|
|
169
|
-
const [separatorVersion, setSeparatorVersion] = createSignal(0);
|
|
170
|
-
// The adapter owns the timers; the reducer only asks for a delay.
|
|
171
|
-
let viewableTimer = null;
|
|
172
|
-
let batchTimer = null;
|
|
173
|
-
// The offset we are imperatively driving native to before the host node has a Fabric tag. Rides
|
|
174
|
-
// down as the scroll host's contentOffset prop; fresh object identity each push.
|
|
175
|
-
const [commandedOffset, setCommandedOffset] = createSignal(undefined);
|
|
176
|
-
function inputs() {
|
|
177
|
-
return {
|
|
178
|
-
data: props.data,
|
|
179
|
-
getItem: props.getItem,
|
|
180
|
-
getItemCount: props.getItemCount,
|
|
181
|
-
keyExtractor: props.keyExtractor,
|
|
182
|
-
getItemLayout: props.getItemLayout,
|
|
183
|
-
horizontal: isHorizontal(),
|
|
184
|
-
windowSize: props.windowSize ?? DEFAULT_WINDOW_SIZE,
|
|
185
|
-
initialNumToRender: props.initialNumToRender ?? DEFAULT_INITIAL_NUM_TO_RENDER,
|
|
186
|
-
maxToRenderPerBatch: props.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH,
|
|
187
|
-
updateCellsBatchingPeriod: props.updateCellsBatchingPeriod ??
|
|
188
|
-
DEFAULT_UPDATE_CELLS_BATCHING_PERIOD,
|
|
189
|
-
onEndReachedThreshold: props.onEndReachedThreshold,
|
|
190
|
-
onStartReachedThreshold: props.onStartReachedThreshold,
|
|
191
|
-
maintainVisibleContentPosition: props.maintainVisibleContentPosition,
|
|
192
|
-
initialScrollIndex: props.initialScrollIndex,
|
|
193
|
-
onEndReachedActive: props.onEndReached !== undefined,
|
|
194
|
-
onStartReachedActive: props.onStartReached !== undefined,
|
|
195
|
-
viewabilityPairs: buildViewabilityPairs(props.onViewableItemsChanged, props.viewabilityConfig, props.viewabilityConfigCallbackPairs),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
function keyFor(index) {
|
|
199
|
-
return resolveItemKey(props.getItem(props.data, index), index, props.keyExtractor);
|
|
200
|
-
}
|
|
201
|
-
// The work the reducer asked for, run with Solid's own primitives. The reducer DESCRIBES an
|
|
202
|
-
// effect; which timer holds a debounce and how a native scroll is dispatched stay here.
|
|
203
|
-
function runEffects(effects) {
|
|
204
|
-
for (const effect of effects) {
|
|
205
|
-
switch (effect.kind) {
|
|
206
|
-
case 'scroll-to': {
|
|
207
|
-
const clamped = Math.max(EMPTY_OFFSET, effect.offset);
|
|
208
|
-
const target = {
|
|
209
|
-
x: isHorizontal() ? clamped : EMPTY_OFFSET,
|
|
210
|
-
y: isHorizontal() ? EMPTY_OFFSET : clamped,
|
|
211
|
-
};
|
|
212
|
-
// A native scrollTo needs the node's COMMITTED Fabric tag; this adapter commits on a
|
|
213
|
-
// microtask, so a scroll requested in the same tick as mount has none yet.
|
|
214
|
-
// contentOffset is RN's own pre-commit fallback.
|
|
215
|
-
const node = hostNode;
|
|
216
|
-
if (node !== null && getNativeTag(node) !== undefined) {
|
|
217
|
-
dlog(`VirtualizedList scrollTo offset=${clamped} animated=${effect.animated}`);
|
|
218
|
-
setCommandedOffset(undefined);
|
|
219
|
-
scrollHandle.scrollTo({ ...target, animated: effect.animated });
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
dlog(`VirtualizedList scrollTo offset=${clamped} pending-commit`);
|
|
223
|
-
setCommandedOffset(target);
|
|
224
|
-
break;
|
|
225
|
-
}
|
|
226
|
-
case 'fire-end-reached':
|
|
227
|
-
props.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
|
|
228
|
-
break;
|
|
229
|
-
case 'fire-start-reached':
|
|
230
|
-
props.onStartReached?.({
|
|
231
|
-
distanceFromStart: effect.distanceFromStart,
|
|
232
|
-
});
|
|
233
|
-
break;
|
|
234
|
-
case 'fire-viewable': {
|
|
235
|
-
const pairs = inputs().viewabilityPairs;
|
|
236
|
-
const info = effect.info;
|
|
237
|
-
const map = effect.map;
|
|
238
|
-
// lastViewable is folded back only when the fire actually LANDS, so a debounce superseded
|
|
239
|
-
// mid-flight still diffs against the last committed set.
|
|
240
|
-
const fire = () => {
|
|
241
|
-
for (const pair of pairs) {
|
|
242
|
-
pair.onViewableItemsChanged({
|
|
243
|
-
...info,
|
|
244
|
-
viewabilityConfig: pair.viewabilityConfig,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
dispatch({ kind: 'viewable-fired', map });
|
|
248
|
-
};
|
|
249
|
-
if (viewableTimer !== null) {
|
|
250
|
-
clearTimeout(viewableTimer);
|
|
251
|
-
viewableTimer = null;
|
|
252
|
-
}
|
|
253
|
-
if (effect.delay > EMPTY_OFFSET) {
|
|
254
|
-
viewableTimer = setTimeout(() => {
|
|
255
|
-
viewableTimer = null;
|
|
256
|
-
fire();
|
|
257
|
-
}, effect.delay);
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
fire();
|
|
261
|
-
}
|
|
262
|
-
break;
|
|
263
|
-
}
|
|
264
|
-
case 'schedule-refill':
|
|
265
|
-
// Ask for another render tick so the throttled window keeps growing toward its target.
|
|
266
|
-
if (batchTimer !== null)
|
|
267
|
-
clearTimeout(batchTimer);
|
|
268
|
-
batchTimer = setTimeout(() => {
|
|
269
|
-
batchTimer = null;
|
|
270
|
-
dispatch({ kind: 'batch-tick' });
|
|
271
|
-
}, effect.delay);
|
|
272
|
-
break;
|
|
273
|
-
case 'fire-scroll-to-index-failed':
|
|
274
|
-
props.onScrollToIndexFailed?.({
|
|
275
|
-
index: effect.index,
|
|
276
|
-
highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
|
|
277
|
-
averageItemLength: effect.averageItemLength,
|
|
278
|
-
});
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
// Untracked by construction: dispatch is reached from native events, timers and the imperative
|
|
284
|
-
// handle, never from a render position, and reading every prop through inputs() inside a tracked
|
|
285
|
-
// scope would subscribe that scope to the whole prop surface.
|
|
286
|
-
function dispatch(action) {
|
|
287
|
-
untrack(() => {
|
|
288
|
-
const result = reduceList(listState, action, inputs());
|
|
289
|
-
runEffects(result.effects);
|
|
290
|
-
if (result.changed)
|
|
291
|
-
setVersion(tick => tick + 1);
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
// RN animates every imperative scroll unless the caller passes animated: false; each resolves to an
|
|
295
|
-
// offset inside the reducer and then rides the native scrollTo command.
|
|
296
|
-
const handle = {
|
|
297
|
-
scrollToOffset: (params) => {
|
|
298
|
-
dispatch({
|
|
299
|
-
kind: 'scroll-to-offset',
|
|
300
|
-
offset: params.offset,
|
|
301
|
-
animated: params.animated ?? true,
|
|
302
|
-
});
|
|
303
|
-
},
|
|
304
|
-
scrollToIndex: (params) => {
|
|
305
|
-
dispatch({
|
|
306
|
-
kind: 'scroll-to-index',
|
|
307
|
-
index: params.index,
|
|
308
|
-
animated: params.animated ?? true,
|
|
309
|
-
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
310
|
-
viewOffset: params.viewOffset ?? EMPTY_OFFSET,
|
|
311
|
-
});
|
|
312
|
-
},
|
|
313
|
-
scrollToItem: (params) => {
|
|
314
|
-
dispatch({
|
|
315
|
-
kind: 'scroll-to-item',
|
|
316
|
-
item: params.item,
|
|
317
|
-
animated: params.animated ?? true,
|
|
318
|
-
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
319
|
-
});
|
|
320
|
-
},
|
|
321
|
-
scrollToEnd: (params) => {
|
|
322
|
-
dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
|
|
323
|
-
},
|
|
324
|
-
flashScrollIndicators: () => {
|
|
325
|
-
scrollHandle.flashScrollIndicators();
|
|
326
|
-
},
|
|
327
|
-
// Three names for RN-API parity; SymbioteNative has no findNodeHandle / legacy-instance
|
|
328
|
-
// distinction, so all three route to the same underlying handle (see IScrollRoutingHandle).
|
|
329
|
-
getNativeScrollRef: () => scrollHandle,
|
|
330
|
-
getScrollableNode: () => scrollHandle,
|
|
331
|
-
getScrollResponder: () => scrollHandle,
|
|
332
|
-
getScrollNode: () => scrollHandle.getScrollNode(),
|
|
333
|
-
// RN's recordInteraction: flip the interaction flag so waitForInteraction viewability configs
|
|
334
|
-
// start reporting.
|
|
335
|
-
recordInteraction: () => {
|
|
336
|
-
dispatch({ kind: 'record-interaction' });
|
|
337
|
-
},
|
|
338
|
-
};
|
|
339
|
-
// Solid's `ref` is a COMPILE-TIME construct: a `ref={list}` call site has already been rewritten
|
|
340
|
-
// into a callback by the time this body reads it (utils/host-ref.ts has the full rationale).
|
|
26
|
+
const { driver, nesting, sticky, listWindow, handlers } = createListParts(props);
|
|
27
|
+
// Solid's `ref` is a compile-time construct, a `ref={list}` call site is already a callback
|
|
341
28
|
if (typeof props.ref === 'function')
|
|
342
|
-
props.ref(handle);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// what fills the offset table the window and the spacers are computed from.
|
|
352
|
-
function makeCellMeasure(index) {
|
|
353
|
-
return (event) => {
|
|
354
|
-
const length = readLayoutLength(event, isHorizontal());
|
|
355
|
-
if (length === undefined)
|
|
356
|
-
return;
|
|
357
|
-
const offset = readLayoutOffset(event, isHorizontal());
|
|
358
|
-
dlog(`VirtualizedList cell ${index()} measured length=${length} offset=${offset ?? 'none'}`);
|
|
359
|
-
dispatch({ kind: 'measure', index: index(), length, offset });
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
function handleScroll(event) {
|
|
363
|
-
const offset = readScrollOffset(event, isHorizontal());
|
|
364
|
-
if (offset === undefined)
|
|
365
|
-
return;
|
|
366
|
-
dlog(`VirtualizedList onScroll offset=${offset}`);
|
|
367
|
-
// A real user/native scroll supersedes any pending commanded offset.
|
|
368
|
-
setCommandedOffset(undefined);
|
|
369
|
-
dispatch({ kind: 'scroll', offset });
|
|
370
|
-
// Compose, don't clobber: internal windowing ran first, now the user's onScroll.
|
|
371
|
-
props.onScroll?.(event);
|
|
372
|
-
}
|
|
373
|
-
// Without the native animated module the sticky value has to be driven off the JS thread, so the
|
|
374
|
-
// handler is wrapped in Animated.event; the native and plain paths forward it untouched.
|
|
375
|
-
const scrollHandler = createMemo(() => {
|
|
376
|
-
if (forwarding().mode !== 'sticky-js')
|
|
377
|
-
return handleScroll;
|
|
378
|
-
return animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], {
|
|
379
|
-
listener: (...args) => forwardScrollEvent(handleScroll, args),
|
|
380
|
-
});
|
|
381
|
-
});
|
|
382
|
-
// The single derive-per-render: the reducer recomputes the window exactly once per render pass
|
|
383
|
-
// (deriving twice would advance its throttled window two batch steps per frame).
|
|
384
|
-
const metrics = createMemo(() => {
|
|
385
|
-
version();
|
|
386
|
-
reduceList(listState, { kind: 'refresh-metrics' }, inputs());
|
|
387
|
-
return listState.metrics;
|
|
388
|
-
});
|
|
389
|
-
// The shared plan: the spacer extents plus the in-window cell keys. Every geometry decision in it
|
|
390
|
-
// is core's, so a windowing bug is fixed once for all adapters.
|
|
391
|
-
const plan = createMemo(() => {
|
|
392
|
-
const m = metrics();
|
|
393
|
-
if (m.count === FIRST_INDEX)
|
|
394
|
-
return null;
|
|
395
|
-
return buildListPlan({
|
|
396
|
-
count: m.count,
|
|
397
|
-
first: m.first,
|
|
398
|
-
last: m.last,
|
|
399
|
-
offsets: m.offsets,
|
|
400
|
-
lengths: m.lengths,
|
|
401
|
-
total: m.total,
|
|
402
|
-
keyFor,
|
|
403
|
-
stickyIndices: stickySet(),
|
|
404
|
-
hasHeader: headerElement !== undefined,
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
// forcedStickyCell is prepended to the window cells so BOTH walk through the SAME keyed <For>.
|
|
408
|
-
// Two separate template positions would give a header toggling between "forced ahead of the
|
|
409
|
-
// window" and "inside the window" no shared identity, so it would be destroyed and rebuilt
|
|
410
|
-
// (losing its measured layout, back to its default translateY) on every crossing.
|
|
411
|
-
const cells = createMemo(() => {
|
|
412
|
-
const current = plan();
|
|
413
|
-
if (current === null)
|
|
414
|
-
return [];
|
|
415
|
-
return current.forcedStickyCell !== undefined
|
|
416
|
-
? [current.forcedStickyCell, ...current.cells]
|
|
417
|
-
: current.cells;
|
|
418
|
-
});
|
|
419
|
-
// <For> matches by VALUE identity, so the KEYS — not the plan's freshly-built cell objects — are
|
|
420
|
-
// what it walks. A key that survives a window slide keeps its row's nodes and simply moves; over
|
|
421
|
-
// the objects every row would be rebuilt on every recompute.
|
|
422
|
-
const cellKeys = createMemo(() => cells().map(cell => cell.key));
|
|
423
|
-
const cellIndexByKey = createMemo(() => new Map(cells().map((cell) => [cell.key, cell.index])));
|
|
424
|
-
// The after-commit pass, in the order every adapter runs it: batch fill, edge-reached, viewability,
|
|
425
|
-
// initial scroll, MVCP. Each is guarded by its own dedup state inside the reducer, so running it on
|
|
426
|
-
// every windowing change is safe. `on()` runs the body untracked, so this depends on the windowing
|
|
427
|
-
// SIGNATURE alone — the same dedup key every adapter shares — and not on every prop the reducer
|
|
428
|
-
// inputs touch.
|
|
429
|
-
const commitSignature = createMemo(() => {
|
|
430
|
-
metrics();
|
|
431
|
-
return listEffectSignature(listState);
|
|
432
|
-
});
|
|
433
|
-
createEffect(on(commitSignature, () => {
|
|
434
|
-
const result = reduceList(listState, { kind: 'commit' }, inputs());
|
|
435
|
-
runEffects(result.effects);
|
|
436
|
-
}));
|
|
437
|
-
// RN's ViewabilityHelper.dispose: a pending dwell timer must not outlive the list.
|
|
438
|
-
onCleanup(() => {
|
|
439
|
-
if (viewableTimer !== null)
|
|
440
|
-
clearTimeout(viewableTimer);
|
|
441
|
-
if (batchTimer !== null)
|
|
442
|
-
clearTimeout(batchTimer);
|
|
443
|
-
});
|
|
444
|
-
const isEmpty = () => metrics().count === FIRST_INDEX;
|
|
445
|
-
const leadingExtent = () => plan()?.leadingExtent ?? EMPTY_OFFSET;
|
|
446
|
-
const trailingExtent = () => plan()?.trailingExtent ?? EMPTY_OFFSET;
|
|
447
|
-
const spacerStyle = (extent) => isHorizontal() ? { width: extent } : { height: extent };
|
|
448
|
-
// Merge an override onto the separator at a given gap. A gap index outside [0, count-2] addresses
|
|
449
|
-
// no separator, so the write is a no-op (RN bails on the same bounds).
|
|
450
|
-
function mergeSeparator(gapIndex, patch) {
|
|
451
|
-
if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
|
|
452
|
-
return;
|
|
453
|
-
separatorOverrides.set(gapIndex, {
|
|
454
|
-
...separatorOverrides.get(gapIndex),
|
|
455
|
-
...patch,
|
|
456
|
-
});
|
|
457
|
-
setSeparatorVersion(tick => tick + 1);
|
|
458
|
-
}
|
|
459
|
-
// The ISeparators handle for the cell at `index` (RN CellRenderer._separators): highlight touches
|
|
460
|
-
// the gap before AND after this cell, so a pressed row draws a full-bleed divider on both sides.
|
|
461
|
-
function makeSeparators(index) {
|
|
462
|
-
return {
|
|
463
|
-
highlight: () => {
|
|
464
|
-
mergeSeparator(index - 1, { highlighted: true });
|
|
465
|
-
mergeSeparator(index, { highlighted: true });
|
|
466
|
-
},
|
|
467
|
-
unhighlight: () => {
|
|
468
|
-
mergeSeparator(index - 1, { highlighted: false });
|
|
469
|
-
mergeSeparator(index, { highlighted: false });
|
|
470
|
-
},
|
|
471
|
-
updateProps: (select, newProps) => {
|
|
472
|
-
mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
|
|
473
|
-
},
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
// Reads separatorVersion so a highlight()/unhighlight()/updateProps() call reactively refreshes
|
|
477
|
-
// this gap's props — separatorOverrides is a plain Map and tracks nothing on its own.
|
|
478
|
-
function separatorPropsFor(index) {
|
|
479
|
-
separatorVersion();
|
|
480
|
-
const overrides = separatorOverrides.get(index);
|
|
481
|
-
return {
|
|
482
|
-
highlighted: overrides?.highlighted === true,
|
|
483
|
-
leadingItem: props.getItem(props.data, index),
|
|
484
|
-
trailingItem: props.getItem(props.data, index + 1),
|
|
485
|
-
...overrides,
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
function separatorElement(index) {
|
|
489
|
-
const Separator = props.ItemSeparatorComponent;
|
|
490
|
-
if (Separator === undefined)
|
|
491
|
-
return undefined;
|
|
492
|
-
// A spread of a CALL compiles to mergeProps(() => …), so the separator's props stay live.
|
|
493
|
-
return <Separator {...separatorPropsFor(index)}/>;
|
|
494
|
-
}
|
|
495
|
-
// RN renders a separator in the gap AFTER a cell, only while there is a following rendered
|
|
496
|
-
// cell — never after the window's last one, and never after the force-mounted sticky cell,
|
|
497
|
-
// which is not adjacent to the window.
|
|
498
|
-
// RN gates the separator on the last index of the DATA, not of the WINDOW
|
|
499
|
-
// (VirtualizedList.js:793): the separator lives INSIDE the measuring wrapper, so gating on the
|
|
500
|
-
// window would make a cell's own measured height change as the window slides past it.
|
|
501
|
-
const hasSeparatorAfter = (index) => props.ItemSeparatorComponent !== undefined && index < metrics().count - 1;
|
|
502
|
-
// Records a sticky header's measured y (RN's _headerLayoutYs) so the header AHEAD of it can
|
|
503
|
-
// compute its collision point; without it every header would stick indefinitely.
|
|
504
|
-
function makeStickyCellLayout(index) {
|
|
505
|
-
const measure = makeCellMeasure(index);
|
|
506
|
-
return (event) => {
|
|
507
|
-
measure(event);
|
|
508
|
-
const y = readLayoutNumber(event, 'y');
|
|
509
|
-
if (y === undefined || headerLayoutYs.get(index()) === y)
|
|
510
|
-
return;
|
|
511
|
-
dlog(`VirtualizedList sticky header index=${index()} y=${y}`);
|
|
512
|
-
headerLayoutYs.set(index(), y);
|
|
513
|
-
setStickyVersion(tick => tick + 1);
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
// Only a header MOUNTED RIGHT NOW can collide with this one — the same restriction React gets by
|
|
517
|
-
// handing its ScrollView the RENDERED sticky positions rather than the full index list. Feeding
|
|
518
|
-
// the full list instead lets a header collide against a stale y from one that already scrolled
|
|
519
|
-
// out, freezing it in place. The map itself is deliberately NOT pruned (React does not either): a
|
|
520
|
-
// measured y stays valid for when that header scrolls back in.
|
|
521
|
-
function nextStickyHeaderYFor(index) {
|
|
522
|
-
// Read the bump FIRST so this stays a dependency of whichever header calls it — that is what
|
|
523
|
-
// turns "a later header measured" into "the previous header rebuilds its collision range".
|
|
524
|
-
stickyVersion();
|
|
525
|
-
const indices = props.stickyHeaderIndices;
|
|
526
|
-
if (indices === undefined)
|
|
527
|
-
return undefined;
|
|
528
|
-
const mounted = new Set(cells().map(cell => cell.index));
|
|
529
|
-
const rendered = indices.filter(sticky => mounted.has(sticky));
|
|
530
|
-
return nextStickyHeaderY(rendered, rendered.indexOf(index), headerLayoutYs);
|
|
531
|
-
}
|
|
532
|
-
function buildCell(index, sticky) {
|
|
533
|
-
// Called ONCE and untracked, the shape Solid core uses for its own render props: a tracked call
|
|
534
|
-
// would put the item signals in the cell's `insert` effect and rebuild this whole subtree on
|
|
535
|
-
// every window step (.claude/rules/solid-descriptor-bridge.md §4).
|
|
536
|
-
const content = untrack(() => props.renderItem(() => ({
|
|
537
|
-
item: props.getItem(props.data, index()),
|
|
538
|
-
index: index(),
|
|
539
|
-
separators: makeSeparators(index()),
|
|
540
|
-
})));
|
|
541
|
-
// The separator rides INSIDE the measuring wrapper (VirtualizedListCellRenderer.js:218-221).
|
|
542
|
-
// As a SIBLING it would be an extra flex child, landing every cell below the leading spacer
|
|
543
|
-
// short by (separator + gap) — see `list-geometry-feedback-loop.md`.
|
|
544
|
-
const separator = (<Show when={hasSeparatorAfter(index())}>
|
|
545
|
-
<view>{separatorElement(index())}</view>
|
|
546
|
-
</Show>);
|
|
547
|
-
if (!sticky) {
|
|
548
|
-
return (<view onLayout={makeCellMeasure(index)} style={invertedStyle()}>
|
|
549
|
-
{content}
|
|
550
|
-
{separator}
|
|
551
|
-
</view>);
|
|
552
|
-
}
|
|
553
|
-
// The sticky wrapper measures the cell itself, so it REPLACES the plain measuring view rather
|
|
554
|
-
// than nesting inside it. Every reactive input is written as a CALL, which makes the compiler
|
|
555
|
-
// emit a getter the header reads inside its OWN effect — a resolved value here would join the
|
|
556
|
-
// enclosing insert effect and rebuild every header on every layout.
|
|
557
|
-
return (<ScrollViewStickyHeader onLayout={makeStickyCellLayout(index)} nextHeaderLayoutY={nextStickyHeaderYFor(index())} scrollAnimatedValue={scrollAnimatedValue} inverted={undefined} scrollViewHeight={undefined}>
|
|
558
|
-
{content}
|
|
559
|
-
{separator}
|
|
560
|
-
</ScrollViewStickyHeader>);
|
|
561
|
-
}
|
|
562
|
-
function buildRow(key) {
|
|
563
|
-
// The row is keyed by its cell key, so its data INDEX moves under it as the window slides. The
|
|
564
|
-
// previous value is the fallback for the tick between a key leaving the plan and <For>
|
|
565
|
-
// disposing its row.
|
|
566
|
-
const index = createMemo((previous) => cellIndexByKey().get(key) ?? previous, NO_INDEX);
|
|
567
|
-
const isSticky = createMemo(() => stickySet()?.has(index()) === true);
|
|
568
|
-
// A sticky cell is a different host subtree, not a different prop, and Solid has no reconciler
|
|
569
|
-
// to swap one for the other — so the flip is an explicit rebuild boundary keyed on the
|
|
570
|
-
// DISCRIMINATOR alone, with the build untracked by `on()`.
|
|
571
|
-
const cell = createMemo(on(isSticky, sticky => buildCell(index, sticky)));
|
|
572
|
-
const forcedStickyIndex = () => plan()?.forcedStickyCell?.index;
|
|
573
|
-
const gapExtent = () => plan()?.gapExtent ?? EMPTY_OFFSET;
|
|
574
|
-
return [
|
|
575
|
-
cell,
|
|
576
|
-
// The gap between the force-mounted sticky cell and the window's first cell.
|
|
577
|
-
<Show when={forcedStickyIndex() === index() && gapExtent() > EMPTY_OFFSET}>
|
|
578
|
-
<view style={spacerStyle(gapExtent())}/>
|
|
579
|
-
</Show>,
|
|
580
|
-
];
|
|
581
|
-
}
|
|
582
|
-
// The scroll host's own intrinsics. A horizontal list resolves DIFFERENT host tags (on Android
|
|
583
|
-
// horizontal scrolling is a separate ViewManager entirely) and lays its content out in a row.
|
|
584
|
-
// A class-name string resolves through the shared registry before it reaches the intrinsic
|
|
585
|
-
// selector, which only understands style objects/arrays.
|
|
586
|
-
const contentContainerStyleInput = () => {
|
|
587
|
-
const style = props.contentContainerStyle;
|
|
588
|
-
return typeof style === 'string' ? resolveClassName(style) : style;
|
|
29
|
+
props.ref(driver.handle);
|
|
30
|
+
const rowDeps = {
|
|
31
|
+
props,
|
|
32
|
+
listWindow,
|
|
33
|
+
metrics: driver.metrics,
|
|
34
|
+
separators: createSeparators(props, driver.currentCount),
|
|
35
|
+
sticky,
|
|
36
|
+
measureCell: handlers.measureCell,
|
|
37
|
+
focusCell: handlers.focusCell,
|
|
589
38
|
};
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const bag = {
|
|
611
|
-
...resolveAccessibilityProps(accessibilityRest),
|
|
612
|
-
// VirtualizedList.js: `[inversionStyle, style]` — the app's style can override the flip.
|
|
613
|
-
style: [invertedStyle(), props.style],
|
|
614
|
-
class: props.class,
|
|
615
|
-
contentContainerStyle: contentContainerStyleForContent(),
|
|
616
|
-
onLayout: onViewportLayout,
|
|
617
|
-
onScroll: scrollHandler(),
|
|
618
|
-
};
|
|
619
|
-
// VirtualizedList.js:1111 — Android moves the scrollbar back after the `scale: -1` flip.
|
|
620
|
-
if (props.inverted === true)
|
|
621
|
-
bag.isInvertedVirtualizedList = true;
|
|
622
|
-
// forwarding's throttle, not the raw prop: it folds in the sticky-mode default (1 native / 16
|
|
623
|
-
// JS-fallback), without which a header rebuilt off too-sparse scroll events pins late.
|
|
624
|
-
if (forwarding().scrollEventThrottle !== undefined)
|
|
625
|
-
bag.scrollEventThrottle = forwarding().scrollEventThrottle;
|
|
626
|
-
if (props.onScrollBeginDrag !== undefined)
|
|
627
|
-
bag.onScrollBeginDrag = props.onScrollBeginDrag;
|
|
628
|
-
if (props.onScrollEndDrag !== undefined)
|
|
629
|
-
bag.onScrollEndDrag = props.onScrollEndDrag;
|
|
630
|
-
if (props.onMomentumScrollBegin !== undefined)
|
|
631
|
-
bag.onMomentumScrollBegin = props.onMomentumScrollBegin;
|
|
632
|
-
if (props.onMomentumScrollEnd !== undefined)
|
|
633
|
-
bag.onMomentumScrollEnd = props.onMomentumScrollEnd;
|
|
634
|
-
if (props.keyboardShouldPersistTaps !== undefined)
|
|
635
|
-
bag.keyboardShouldPersistTaps = props.keyboardShouldPersistTaps;
|
|
636
|
-
if (props.keyboardDismissMode !== undefined)
|
|
637
|
-
bag.keyboardDismissMode = props.keyboardDismissMode;
|
|
638
|
-
// Forwarded so native anchors the cells it CAN see; the JS half (the prepended items collapsed
|
|
639
|
-
// into the leading spacer) is the reducer's. minIndexForVisible counts CHILDREN, so it is
|
|
640
|
-
// bumped by one when a ListHeaderComponent occupies child 0.
|
|
641
|
-
const commanded = commandedOffset();
|
|
642
|
-
if (commanded !== undefined)
|
|
643
|
-
bag.contentOffset = commanded;
|
|
644
|
-
const mvcp = props.maintainVisibleContentPosition;
|
|
645
|
-
if (mvcp !== undefined) {
|
|
646
|
-
bag.maintainVisibleContentPosition = {
|
|
647
|
-
...mvcp,
|
|
648
|
-
minIndexForVisible: mvcp.minIndexForVisible +
|
|
649
|
-
(headerElement !== undefined ? HEADER_CHILD_COUNT : FIRST_INDEX),
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
return bag;
|
|
653
|
-
});
|
|
654
|
-
// Every reactive read below sits inside a <Show>/<For> prop getter, so this accessor itself has no
|
|
655
|
-
// dependencies and the content view's `insert` effect runs exactly ONCE. Moving one of those
|
|
656
|
-
// conditions into a plain helper called from here would put it in that effect's dependency set and
|
|
657
|
-
// rebuild the whole list on the next scroll (.claude/rules/solid-descriptor-bridge.md §4).
|
|
658
|
-
function listBody() {
|
|
659
|
-
return [
|
|
660
|
-
<Show when={headerElement !== undefined}>
|
|
661
|
-
<view>{headerElement}</view>
|
|
662
|
-
</Show>,
|
|
663
|
-
<Show when={isEmpty()} fallback={<>
|
|
664
|
-
<Show when={leadingExtent() > EMPTY_OFFSET}>
|
|
665
|
-
<view style={spacerStyle(leadingExtent())}/>
|
|
666
|
-
</Show>
|
|
667
|
-
<For each={cellKeys()}>{buildRow}</For>
|
|
668
|
-
<Show when={trailingExtent() > EMPTY_OFFSET}>
|
|
669
|
-
<view style={spacerStyle(trailingExtent())}/>
|
|
670
|
-
</Show>
|
|
671
|
-
</>}>
|
|
672
|
-
<Show when={emptyElement !== undefined}>
|
|
673
|
-
<view>{emptyElement}</view>
|
|
674
|
-
</Show>
|
|
675
|
-
</Show>,
|
|
676
|
-
<Show when={footerElement !== undefined}>
|
|
677
|
-
<view>{footerElement}</view>
|
|
678
|
-
</Show>,
|
|
679
|
-
];
|
|
680
|
-
}
|
|
681
|
-
function hostElement(tag) {
|
|
682
|
-
const node = createElement(tag);
|
|
683
|
-
// Narrowing, not defensive: the renderer types createElement over its IHostNode union (which
|
|
684
|
-
// includes the surface), while everything below needs a real host node.
|
|
685
|
-
if (!isSymbioteNode(node))
|
|
686
|
-
throw new Error(`VirtualizedList: ${tag} did not create a host node`);
|
|
687
|
-
return node;
|
|
688
|
-
}
|
|
689
|
-
// Built ONCE per tree: `refreshing` is written as a CALL, so the compiler emits a getter and the
|
|
690
|
-
// controlled state keeps reaching this same node.
|
|
691
|
-
function buildRefreshControl() {
|
|
692
|
-
const onRefresh = props.onRefresh;
|
|
693
|
-
if (onRefresh === undefined)
|
|
694
|
-
return undefined;
|
|
695
|
-
dlog('VirtualizedList wiring RefreshControl (onRefresh provided)');
|
|
696
|
-
const element = (<refresh-control refreshing={props.refreshing ?? false} onRefresh={onRefresh} progressViewOffset={props.progressViewOffset}/>);
|
|
697
|
-
return isSymbioteNode(element) ? element : undefined;
|
|
698
|
-
}
|
|
699
|
-
// The scroll host, wired to the ENGINE-OWNED content node rather than a hand-built one. Once
|
|
700
|
-
// `registerScrollViewBehavior()` is live, `createElement(scrollViewIntrinsic)` already ran the
|
|
701
|
-
// behavior's `buildStructure` and appended its own content child as `childHost` — building a
|
|
702
|
-
// second one here would nest `RCTScrollContentView > RCTScrollContentView`
|
|
703
|
-
// (`core/components/src/behaviors/scroll-view/shared.ts`'s own header names this precondition).
|
|
704
|
-
function buildTree() {
|
|
705
|
-
const { scrollViewIntrinsic } = intrinsics();
|
|
706
|
-
dlog(`VirtualizedList -> ${scrollViewIntrinsic}`);
|
|
707
|
-
const scroll = hostElement(scrollViewIntrinsic);
|
|
708
|
-
hostNode = scroll;
|
|
709
|
-
spread(scroll, outerBag, true);
|
|
710
|
-
const content = scroll.childHost;
|
|
711
|
-
if (content === undefined)
|
|
712
|
-
throw new Error(`VirtualizedList: ${scrollViewIntrinsic} built no content node — is ` +
|
|
713
|
-
'registerScrollViewBehavior() wired?');
|
|
714
|
-
insert(content, listBody);
|
|
715
|
-
// An ordinary child on both platforms now, not a hand-branched sibling/wrap: the behavior's
|
|
716
|
-
// own `claimedChildren` places it (a sibling of the content view on iOS, an inverting wrap on
|
|
717
|
-
// Android) and puts it BEFORE the content view regardless of insertion order.
|
|
718
|
-
const refresh = buildRefreshControl();
|
|
719
|
-
if (refresh !== undefined)
|
|
720
|
-
insertNode(scroll, refresh);
|
|
721
|
-
// A wrap claim re-parents `scroll` under the RefreshControl and records it as `scroll.wrapper`
|
|
722
|
-
// (`core/engine/src/node.ts`'s `placedNode`) — the wrapper, not `scroll`, is what actually
|
|
723
|
-
// occupies this subtree's place once claimed, so returning `scroll` on Android would hand back
|
|
724
|
-
// a node that is no longer the tree's root.
|
|
725
|
-
return scroll.wrapper ?? scroll;
|
|
726
|
-
}
|
|
727
|
-
// Drive the sticky scroll value on the native UI thread (RN's attachNativeEvent) so the header
|
|
728
|
-
// interpolations ride scroll natively with no JS per frame. `tree()` is read for its dependency:
|
|
729
|
-
// a rebuild means a NEW scroll node to attach to. The engine commits on a microtask, so the node
|
|
730
|
-
// has no Fabric tag on the first run and the attach would no-op with no retry — whenCommitted is
|
|
731
|
-
// that retry.
|
|
732
|
-
function attachSticky() {
|
|
733
|
-
createEffect(() => {
|
|
734
|
-
tree();
|
|
735
|
-
if (!nativeStickyAvailable())
|
|
736
|
-
return;
|
|
737
|
-
const node = hostNode;
|
|
738
|
-
if (node === null)
|
|
739
|
-
return;
|
|
740
|
-
let detach;
|
|
741
|
-
const cancel = whenCommitted(node, () => {
|
|
742
|
-
detach = attachStickyScroll(node, scrollAnimatedValue);
|
|
743
|
-
});
|
|
744
|
-
onCleanup(() => {
|
|
745
|
-
cancel();
|
|
746
|
-
detach?.();
|
|
747
|
-
});
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
// The scroll AXIS picks a different host TAG and Solid cannot swap a tag under a live node, so the
|
|
751
|
-
// flip REBUILDS — which is exactly what React does when an element type changes. `on()` runs
|
|
752
|
-
// buildTree untracked, so every other read inside it re-props the SAME nodes through `spread`
|
|
753
|
-
// instead of rebuilding them.
|
|
754
|
-
const treeShape = createMemo(() => String(isHorizontal()));
|
|
755
|
-
const tree = createMemo(on(treeShape, () => buildTree()));
|
|
756
|
-
attachSticky();
|
|
757
|
-
return tree;
|
|
39
|
+
return provideVirtualizedListScope(nesting.scope, () => createScrollTree({
|
|
40
|
+
props,
|
|
41
|
+
accessibility: () => resolveAccessibilityProps(accessibilityRest),
|
|
42
|
+
driver,
|
|
43
|
+
sticky,
|
|
44
|
+
handlers,
|
|
45
|
+
nesting,
|
|
46
|
+
hasHeader: header !== undefined,
|
|
47
|
+
body: () => listBody({
|
|
48
|
+
header,
|
|
49
|
+
footer,
|
|
50
|
+
empty,
|
|
51
|
+
headerStyle: () => slotStyleOf(props, props.ListHeaderComponentStyle),
|
|
52
|
+
footerStyle: () => slotStyleOf(props, props.ListFooterComponentStyle),
|
|
53
|
+
emptyStyle: () => slotStyleOf(props, undefined),
|
|
54
|
+
isEmpty: () => driver.metrics().count === FIRST_INDEX,
|
|
55
|
+
rowKeys: listWindow.rowKeys,
|
|
56
|
+
renderRow: rowKey => buildRow(rowDeps, rowKey),
|
|
57
|
+
}),
|
|
58
|
+
}));
|
|
758
59
|
};
|
|
759
60
|
}
|