@symbiote-native/react 0.2.7 → 0.2.8
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/activity-indicator/index.android.d.ts +2 -1
- package/build/components/activity-indicator/index.android.js +9 -3
- package/build/components/activity-indicator/index.ios.d.ts +2 -1
- package/build/components/activity-indicator/index.ios.js +9 -3
- package/build/components/activity-indicator/shared.d.ts +1 -2
- package/build/components/activity-indicator/shared.js +13 -11
- package/build/components/scroll-view/shared.js +26 -30
- package/build/components/scroll-view/sticky-header.js +91 -65
- package/build/components/switch/index.android.d.ts +2 -1
- package/build/components/switch/index.android.js +9 -3
- package/build/components/switch/index.ios.d.ts +2 -1
- package/build/components/switch/index.ios.js +9 -3
- package/build/components/switch/shared.d.ts +2 -3
- package/build/components/switch/shared.js +44 -43
- package/build/components/touchable/index.js +22 -54
- package/build/components/virtualized-list/index.js +194 -305
- package/build/components/virtualized-section-list/index.js +2 -4
- package/build/render.js +8 -0
- package/package.json +2 -2
|
@@ -4,21 +4,22 @@
|
|
|
4
4
|
// Views whose sizes sum to the off-screen extent, so the scroll thumb and total
|
|
5
5
|
// content size stay correct without mounting all N rows.
|
|
6
6
|
//
|
|
7
|
-
// The
|
|
8
|
-
//
|
|
9
|
-
// @symbiote-native/components/
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// and the per-cell
|
|
13
|
-
//
|
|
7
|
+
// The orchestration — window recompute, edge-reached, viewability, batch fill, MVCP, the
|
|
8
|
+
// imperative scrolls — is the framework-agnostic `reduceList` state machine in
|
|
9
|
+
// @symbiote-native/components (state/virtualized-list-reducer), shared verbatim with Vue and Angular.
|
|
10
|
+
// React supplies ONLY its lifecycle: it turns native events into ACTIONS, holds ONE state cell,
|
|
11
|
+
// runs the returned EFFECTS with React primitives (a native scrollTo, a callback, a setTimeout, a
|
|
12
|
+
// forced re-render), and builds the per-cell elements (createElement). The single derive-per-render
|
|
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.
|
|
14
16
|
//
|
|
15
|
-
// Imperative scrolling
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
17
|
+
// Imperative scrolling resolves to an offset in the reducer and rides the ScrollView's native
|
|
18
|
+
// scrollTo command via its handle ref, animated by default. The `contentOffset` prop is only a
|
|
19
|
+
// fallback for the pre-mount window (handle not yet attached).
|
|
20
|
+
import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
|
|
20
21
|
import { dlog } from '@symbiote-native/engine';
|
|
21
|
-
import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE,
|
|
22
|
+
import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
22
23
|
import { ScrollView } from '../scroll-view';
|
|
23
24
|
import { RefreshControl } from '../refresh-control/index.js';
|
|
24
25
|
function resolveElement(component) {
|
|
@@ -55,215 +56,174 @@ export function VirtualizedList(props) {
|
|
|
55
56
|
// resolveAccessibilityProps itself; ref is pulled separately above, re-listed here only
|
|
56
57
|
// to keep it out of the forwarded rest.
|
|
57
58
|
ref: _ref, ...accessibilityRest } = props;
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
59
|
+
// The single-config and pairs forms both feed one viewability pass (RN supports either).
|
|
60
|
+
const viewabilityPairs = useMemo(() => buildViewabilityPairs(onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs), [onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs]);
|
|
61
|
+
// The framework-agnostic reducer inputs (props + defaults). Rebuilt each render; the handlers read
|
|
62
|
+
// it through inputsRef so they stay stable.
|
|
63
|
+
const inputs = {
|
|
64
|
+
data,
|
|
65
|
+
getItem,
|
|
66
|
+
getItemCount,
|
|
67
|
+
keyExtractor,
|
|
68
|
+
getItemLayout,
|
|
69
|
+
horizontal,
|
|
70
|
+
windowSize,
|
|
71
|
+
initialNumToRender,
|
|
72
|
+
maxToRenderPerBatch,
|
|
73
|
+
updateCellsBatchingPeriod,
|
|
74
|
+
onEndReachedThreshold,
|
|
75
|
+
onStartReachedThreshold,
|
|
76
|
+
onEndReachedActive: onEndReached !== undefined,
|
|
77
|
+
onStartReachedActive: onStartReached !== undefined,
|
|
78
|
+
viewabilityPairs,
|
|
79
|
+
maintainVisibleContentPosition,
|
|
80
|
+
initialScrollIndex,
|
|
81
|
+
};
|
|
82
|
+
const inputsRef = useRef(inputs);
|
|
83
|
+
inputsRef.current = inputs;
|
|
84
|
+
// The effect executors read the latest callbacks off this ref (the reducer never sees them).
|
|
85
|
+
const handlersRef = useRef({
|
|
86
|
+
onEndReached,
|
|
87
|
+
onStartReached,
|
|
88
|
+
onScrollToIndexFailed,
|
|
89
|
+
viewabilityPairs,
|
|
90
|
+
});
|
|
91
|
+
handlersRef.current = { onEndReached, onStartReached, onScrollToIndexFailed, viewabilityPairs };
|
|
92
|
+
// The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
|
|
93
|
+
const stateRef = useRef(null);
|
|
94
|
+
const state = (stateRef.current ??= createInitialListState());
|
|
95
|
+
const [, forceRender] = useReducer((tick) => tick + 1, 0);
|
|
96
|
+
// The offset we are imperatively driving native to (scrollTo* before the handle attaches). Pushed
|
|
97
|
+
// down as the ScrollView's contentOffset prop; fresh object identity each time. undefined = none.
|
|
63
98
|
const [commandedOffset, setCommandedOffset] = useState(undefined);
|
|
64
99
|
const scrollViewRef = useRef(null);
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
const measuredRef = useRef(new Map());
|
|
68
|
-
const [, setMeasureVersion] = useState(EMPTY_OFFSET);
|
|
69
|
-
// The content length we last fired onEndReached / onStartReached for (RN's
|
|
70
|
-
// _sentEndForContentLength / _sentStartForContentLength): dedup by content length, re-armed
|
|
71
|
-
// on scroll away from the edge.
|
|
72
|
-
const sentEndForContentLengthRef = useRef(NO_CONTENT_LENGTH_SENT);
|
|
73
|
-
const sentStartForContentLengthRef = useRef(NO_CONTENT_LENGTH_SENT);
|
|
74
|
-
// The previously committed window, so throttleWindow can grow it by at most
|
|
75
|
-
// maxToRenderPerBatch per batch tick instead of snapping.
|
|
76
|
-
const committedWindowRef = useRef({
|
|
77
|
-
first: FIRST_INDEX,
|
|
78
|
-
last: -1,
|
|
79
|
-
});
|
|
80
|
-
// The tokens reported viewable on the last onViewableItemsChanged, keyed by cell key.
|
|
81
|
-
const lastViewableRef = useRef(new Map());
|
|
82
|
-
// Pending minimumViewTime debounce timer (RN ViewabilityHelper._timers). null = no timer.
|
|
100
|
+
// Pending minimumViewTime debounce timer / the incremental-fill timer (adapter owns the timers;
|
|
101
|
+
// the reducer only asks for a delay).
|
|
83
102
|
const viewableTimerRef = useRef(null);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
103
|
+
const batchTimerRef = useRef(null);
|
|
104
|
+
// Per-gap separator overrides (keyed by the LEADING cell index of the gap) stay adapter-side: they
|
|
105
|
+
// are render state read directly in the cell walk, not part of the windowing orchestration.
|
|
87
106
|
const separatorOverridesRef = useRef(new Map());
|
|
88
107
|
const [, setSeparatorVersion] = useState(EMPTY_OFFSET);
|
|
89
|
-
//
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const layout = getItemLayout(data, index);
|
|
98
|
-
return { length: layout.length, offset: layout.offset };
|
|
99
|
-
};
|
|
100
|
-
}, [getItemLayout, data]);
|
|
101
|
-
// Running average of known cell lengths, used to size not-yet-measured cells and the trailing
|
|
102
|
-
// spacer so the total is plausible before full measurement.
|
|
103
|
-
const averageLength = useMemo(() => {
|
|
104
|
-
if (fixedLayout)
|
|
105
|
-
return fixedLayout(FIRST_INDEX).length;
|
|
106
|
-
return averageMeasuredLength(measuredRef.current);
|
|
107
|
-
}, [fixedLayout, scrollOffset, viewportLength]);
|
|
108
|
-
const { offsets, lengths, total } = buildOffsets(count, measuredRef.current, fixedLayout, averageLength);
|
|
109
|
-
const targetWindow = computeWindow(count, offsets, lengths, scrollOffset, viewportLength, windowSize, initialNumToRender);
|
|
110
|
-
const { first, last } = throttleWindow(targetWindow, committedWindowRef.current, maxToRenderPerBatch);
|
|
111
|
-
committedWindowRef.current = { first, last };
|
|
112
|
-
dlog(`VirtualizedList window [${first}, ${last}] of ${count} ` +
|
|
113
|
-
`(offset=${scrollOffset}, viewport=${viewportLength}, rendered=${Math.max(0, last - first + 1)})`);
|
|
114
|
-
// When the throttled window has not yet reached the target, schedule another render after the
|
|
115
|
-
// batching period so the window keeps filling toward target.
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
if (first <= targetWindow.first && last >= targetWindow.last)
|
|
108
|
+
// Drive a native scroll (or, before the handle attaches, the contentOffset fallback).
|
|
109
|
+
const scrollToPixel = useCallback((offset, animated) => {
|
|
110
|
+
const clamped = Math.max(EMPTY_OFFSET, offset);
|
|
111
|
+
const isHorizontal = inputsRef.current.horizontal;
|
|
112
|
+
const target = isHorizontal ? { x: clamped, y: EMPTY_OFFSET } : { x: EMPTY_OFFSET, y: clamped };
|
|
113
|
+
if (scrollViewRef.current !== null) {
|
|
114
|
+
dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
|
|
115
|
+
scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
|
|
118
116
|
return;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
}
|
|
118
|
+
dlog(`VirtualizedList scrollTo offset=${clamped} pending-ref (horizontal=${isHorizontal})`);
|
|
119
|
+
setCommandedOffset(target);
|
|
120
|
+
}, []);
|
|
121
|
+
// dispatch and runEffects are mutually recursive (a schedule-refill / fire-viewable effect
|
|
122
|
+
// dispatches a follow-up action), so runEffects reaches dispatch through a ref.
|
|
123
|
+
const dispatchRef = useRef(() => { });
|
|
124
|
+
const runEffects = useCallback((effects) => {
|
|
125
|
+
const handlers = handlersRef.current;
|
|
126
|
+
for (const effect of effects) {
|
|
127
|
+
switch (effect.kind) {
|
|
128
|
+
case 'scroll-to':
|
|
129
|
+
scrollToPixel(effect.offset, effect.animated);
|
|
130
|
+
break;
|
|
131
|
+
case 'fire-end-reached':
|
|
132
|
+
handlers.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
|
|
133
|
+
break;
|
|
134
|
+
case 'fire-start-reached':
|
|
135
|
+
handlers.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
|
|
136
|
+
break;
|
|
137
|
+
case 'fire-scroll-to-index-failed':
|
|
138
|
+
handlers.onScrollToIndexFailed?.({
|
|
139
|
+
index: effect.index,
|
|
140
|
+
highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
|
|
141
|
+
averageItemLength: effect.averageItemLength,
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
case 'schedule-refill': {
|
|
145
|
+
if (batchTimerRef.current !== null)
|
|
146
|
+
clearTimeout(batchTimerRef.current);
|
|
147
|
+
batchTimerRef.current = setTimeout(() => {
|
|
148
|
+
batchTimerRef.current = null;
|
|
149
|
+
dispatchRef.current({ kind: 'batch-tick' });
|
|
150
|
+
}, effect.delay);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'fire-viewable': {
|
|
154
|
+
const pairs = handlers.viewabilityPairs;
|
|
155
|
+
const info = effect.info;
|
|
156
|
+
const map = effect.map;
|
|
157
|
+
const fire = () => {
|
|
158
|
+
for (const pair of pairs)
|
|
159
|
+
pair.onViewableItemsChanged(info);
|
|
160
|
+
dispatchRef.current({ kind: 'viewable-fired', map });
|
|
161
|
+
};
|
|
162
|
+
if (viewableTimerRef.current !== null) {
|
|
163
|
+
clearTimeout(viewableTimerRef.current);
|
|
164
|
+
viewableTimerRef.current = null;
|
|
165
|
+
}
|
|
166
|
+
if (effect.delay > EMPTY_OFFSET) {
|
|
167
|
+
viewableTimerRef.current = setTimeout(() => {
|
|
168
|
+
viewableTimerRef.current = null;
|
|
169
|
+
fire();
|
|
170
|
+
}, effect.delay);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
fire();
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}, [scrollToPixel]);
|
|
180
|
+
const dispatch = useCallback((action) => {
|
|
181
|
+
const current = stateRef.current;
|
|
182
|
+
if (current === null)
|
|
183
|
+
return;
|
|
184
|
+
const result = reduceList(current, action, inputsRef.current);
|
|
185
|
+
runEffects(result.effects);
|
|
186
|
+
if (result.changed)
|
|
187
|
+
forceRender();
|
|
188
|
+
}, [runEffects]);
|
|
189
|
+
dispatchRef.current = dispatch;
|
|
190
|
+
// The single derive-per-render: recompute the window off the current state before reading it.
|
|
191
|
+
reduceList(state, { kind: 'refresh-metrics' }, inputs);
|
|
192
|
+
const m = state.metrics;
|
|
193
|
+
const { count, offsets, lengths, total, first, last } = m;
|
|
194
|
+
const commitSignature = listEffectSignature(state);
|
|
195
|
+
dlog(`VirtualizedList window [${first}, ${last}] of ${count} ` +
|
|
196
|
+
`(offset=${state.scrollOffset}, viewport=${state.viewportLength}, rendered=${Math.max(0, last - first + 1)})`);
|
|
124
197
|
const onScroll = useCallback((event) => {
|
|
125
198
|
const offset = readScrollOffset(event, horizontal);
|
|
126
199
|
if (offset === undefined)
|
|
127
200
|
return;
|
|
128
201
|
dlog(`VirtualizedList onScroll offset=${offset}`);
|
|
129
|
-
// First scroll is the interaction that ungates waitForInteraction configs.
|
|
130
|
-
hasInteractedRef.current = true;
|
|
131
|
-
setScrollOffset(offset);
|
|
132
202
|
// A real user/native scroll supersedes any pending commanded offset.
|
|
133
203
|
setCommandedOffset(undefined);
|
|
204
|
+
dispatch({ kind: 'scroll', offset });
|
|
134
205
|
// Compose, don't clobber: internal windowing ran first, now the user's onScroll.
|
|
135
206
|
if (userOnScroll !== undefined)
|
|
136
207
|
userOnScroll(event);
|
|
137
|
-
}, [horizontal, userOnScroll]);
|
|
138
|
-
// onEndReached gating (RN _maybeCallOnEdgeReached). Run against the COMMITTED window. Fire only
|
|
139
|
-
// when the last cell is rendered AND within threshold; dedup by content length; re-arm on
|
|
140
|
-
// scroll away from the end.
|
|
141
|
-
useEffect(() => {
|
|
142
|
-
if (onEndReached === undefined || viewportLength <= EMPTY_OFFSET)
|
|
143
|
-
return;
|
|
144
|
-
const { distanceFromEnd, withinThreshold } = computeEndReached(total, scrollOffset, viewportLength, onEndReachedThreshold);
|
|
145
|
-
const lastCellRendered = last === count - 1;
|
|
146
|
-
if (withinThreshold && lastCellRendered && sentEndForContentLengthRef.current !== total) {
|
|
147
|
-
sentEndForContentLengthRef.current = total;
|
|
148
|
-
dlog(`VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
|
|
149
|
-
`(last=${last} of ${count}, contentLength=${total})`);
|
|
150
|
-
onEndReached({ distanceFromEnd });
|
|
151
|
-
}
|
|
152
|
-
if (!withinThreshold) {
|
|
153
|
-
sentEndForContentLengthRef.current = NO_CONTENT_LENGTH_SENT;
|
|
154
|
-
}
|
|
155
|
-
}, [onEndReached, onEndReachedThreshold, viewportLength, scrollOffset, total, last, count]);
|
|
156
|
-
// onStartReached gating: the top-edge twin of the onEndReached effect.
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
if (onStartReached === undefined || viewportLength <= EMPTY_OFFSET)
|
|
159
|
-
return;
|
|
160
|
-
const { distanceFromStart, withinThreshold } = computeStartReached(scrollOffset, viewportLength, onStartReachedThreshold);
|
|
161
|
-
const firstCellRendered = first === FIRST_INDEX;
|
|
162
|
-
if (withinThreshold && firstCellRendered && sentStartForContentLengthRef.current !== total) {
|
|
163
|
-
sentStartForContentLengthRef.current = total;
|
|
164
|
-
dlog(`VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
|
|
165
|
-
`(first=${first}, contentLength=${total})`);
|
|
166
|
-
onStartReached({ distanceFromStart });
|
|
167
|
-
}
|
|
168
|
-
if (!withinThreshold) {
|
|
169
|
-
sentStartForContentLengthRef.current = NO_CONTENT_LENGTH_SENT;
|
|
170
|
-
}
|
|
171
|
-
}, [onStartReached, onStartReachedThreshold, viewportLength, scrollOffset, total, first]);
|
|
172
|
-
// The single-config and pairs forms both feed one viewability pass (RN supports either).
|
|
173
|
-
const viewabilityPairs = useMemo(() => buildViewabilityPairs(onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs), [onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs]);
|
|
174
|
-
// Viewability detection: after each scroll/window change, recompute which rendered cells clear
|
|
175
|
-
// the threshold and, if the viewable set changed, fire onViewableItemsChanged + every pair.
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (viewabilityPairs.length === EMPTY_OFFSET || viewportLength <= EMPTY_OFFSET)
|
|
178
|
-
return;
|
|
179
|
-
if (count === FIRST_INDEX)
|
|
180
|
-
return;
|
|
181
|
-
const { tokens, map } = computeViewableSet({
|
|
182
|
-
first,
|
|
183
|
-
last,
|
|
184
|
-
count,
|
|
185
|
-
offsets,
|
|
186
|
-
lengths,
|
|
187
|
-
scrollOffset,
|
|
188
|
-
viewportLength,
|
|
189
|
-
data,
|
|
190
|
-
getItem,
|
|
191
|
-
keyExtractor,
|
|
192
|
-
pairs: viewabilityPairs,
|
|
193
|
-
hasInteracted: hasInteractedRef.current,
|
|
194
|
-
});
|
|
195
|
-
const diff = diffViewable(lastViewableRef.current, map, tokens);
|
|
196
|
-
if (!diff.hasChanged)
|
|
197
|
-
return;
|
|
198
|
-
const commitAndFire = () => {
|
|
199
|
-
lastViewableRef.current = map;
|
|
200
|
-
dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
|
|
201
|
-
`(window [${first}, ${last}])`);
|
|
202
|
-
const info = {
|
|
203
|
-
viewableItems: tokens,
|
|
204
|
-
changed: diff.changed,
|
|
205
|
-
};
|
|
206
|
-
for (const pair of viewabilityPairs)
|
|
207
|
-
pair.onViewableItemsChanged(info);
|
|
208
|
-
};
|
|
209
|
-
const minimumViewTime = maxMinimumViewTime(viewabilityPairs);
|
|
210
|
-
if (viewableTimerRef.current !== null) {
|
|
211
|
-
clearTimeout(viewableTimerRef.current);
|
|
212
|
-
viewableTimerRef.current = null;
|
|
213
|
-
}
|
|
214
|
-
if (minimumViewTime > EMPTY_OFFSET) {
|
|
215
|
-
dlog(`VirtualizedList viewability debounce ${minimumViewTime}ms (window [${first}, ${last}])`);
|
|
216
|
-
viewableTimerRef.current = setTimeout(() => {
|
|
217
|
-
viewableTimerRef.current = null;
|
|
218
|
-
commitAndFire();
|
|
219
|
-
}, minimumViewTime);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
commitAndFire();
|
|
223
|
-
}, [
|
|
224
|
-
viewabilityPairs,
|
|
225
|
-
viewportLength,
|
|
226
|
-
scrollOffset,
|
|
227
|
-
first,
|
|
228
|
-
last,
|
|
229
|
-
count,
|
|
230
|
-
data,
|
|
231
|
-
getItem,
|
|
232
|
-
keyExtractor,
|
|
233
|
-
offsets,
|
|
234
|
-
lengths,
|
|
235
|
-
]);
|
|
236
|
-
// Clear any pending minimumViewTime debounce on unmount (RN ViewabilityHelper.dispose).
|
|
237
|
-
useEffect(() => {
|
|
238
|
-
return () => {
|
|
239
|
-
if (viewableTimerRef.current !== null)
|
|
240
|
-
clearTimeout(viewableTimerRef.current);
|
|
241
|
-
};
|
|
242
|
-
}, []);
|
|
208
|
+
}, [horizontal, userOnScroll, dispatch]);
|
|
243
209
|
const onViewportLayout = useCallback((event) => {
|
|
244
210
|
const length = readLayoutLength(event, horizontal);
|
|
245
211
|
if (length === undefined)
|
|
246
212
|
return;
|
|
247
213
|
dlog(`VirtualizedList onLayout viewport=${length}`);
|
|
248
|
-
|
|
249
|
-
}, [horizontal]);
|
|
214
|
+
dispatch({ kind: 'layout', length });
|
|
215
|
+
}, [horizontal, dispatch]);
|
|
250
216
|
const makeCellMeasure = useCallback((index) => (event) => {
|
|
251
|
-
if (fixedLayout)
|
|
252
|
-
return;
|
|
253
217
|
const length = readLayoutLength(event, horizontal);
|
|
254
218
|
if (length === undefined)
|
|
255
219
|
return;
|
|
256
|
-
const measured = measuredRef.current;
|
|
257
|
-
if (measured.get(index) === length)
|
|
258
|
-
return;
|
|
259
|
-
measured.set(index, length);
|
|
260
220
|
dlog(`VirtualizedList cell ${index} measured length=${length}`);
|
|
261
|
-
|
|
262
|
-
}, [
|
|
221
|
+
dispatch({ kind: 'measure', index, length });
|
|
222
|
+
}, [horizontal, dispatch]);
|
|
263
223
|
// Merge an override onto the separator at a given gap and request a re-render. A gap index
|
|
264
224
|
// outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
|
|
265
225
|
const mergeSeparator = useCallback((gapIndex, patch) => {
|
|
266
|
-
if (gapIndex
|
|
226
|
+
if (!isSeparatorGapInRange(gapIndex, count))
|
|
267
227
|
return;
|
|
268
228
|
const overrides = separatorOverridesRef.current;
|
|
269
229
|
overrides.set(gapIndex, { ...overrides.get(gapIndex), ...patch });
|
|
@@ -285,55 +245,36 @@ export function VirtualizedList(props) {
|
|
|
285
245
|
mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
|
|
286
246
|
},
|
|
287
247
|
}), [mergeSeparator]);
|
|
288
|
-
|
|
289
|
-
const offsetForIndex = useCallback((index, viewPosition, viewOffset) => resolveOffsetForIndex(index, viewPosition, viewOffset, count, offsets, lengths, viewportLength), [count, offsets, lengths, viewportLength]);
|
|
290
|
-
const scrollToPixel = useCallback((offset, animated) => {
|
|
291
|
-
const clamped = Math.max(EMPTY_OFFSET, offset);
|
|
292
|
-
const target = horizontal ? { x: clamped, y: EMPTY_OFFSET } : { x: EMPTY_OFFSET, y: clamped };
|
|
293
|
-
// Both animated and instant scrolls ride the ScrollView's native scrollTo command, exactly
|
|
294
|
-
// like RN. The contentOffset prop path stays as a fallback for the pre-mount window.
|
|
295
|
-
if (scrollViewRef.current !== null) {
|
|
296
|
-
dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${horizontal})`);
|
|
297
|
-
scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
dlog(`VirtualizedList scrollTo offset=${clamped} pending-ref (horizontal=${horizontal})`);
|
|
301
|
-
setCommandedOffset(target);
|
|
302
|
-
}, [horizontal]);
|
|
303
|
-
// The largest index whose length we have actually measured (RN
|
|
304
|
-
// ListMetricsAggregator.getHighestMeasuredCellIndex). With getItemLayout this is irrelevant.
|
|
305
|
-
const highestMeasuredIndex = useCallback(() => findHighestMeasuredIndex(measuredRef.current), []);
|
|
248
|
+
const keyForIndex = useCallback((index) => resolveItemKey(getItem(data, index), index, keyExtractor), [getItem, data, keyExtractor]);
|
|
306
249
|
useImperativeHandle(forwardedRef ?? null, () => ({
|
|
307
|
-
// RN animates every imperative scroll unless the caller passes animated: false.
|
|
250
|
+
// RN animates every imperative scroll unless the caller passes animated: false. Each resolves
|
|
251
|
+
// to an offset (or a scroll-to-index failure) inside the reducer, then rides scroll-to.
|
|
308
252
|
scrollToOffset: (params) => {
|
|
309
|
-
|
|
253
|
+
dispatch({
|
|
254
|
+
kind: 'scroll-to-offset',
|
|
255
|
+
offset: params.offset,
|
|
256
|
+
animated: params.animated ?? true,
|
|
257
|
+
});
|
|
310
258
|
},
|
|
311
259
|
scrollToIndex: (params) => {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
highestMeasuredFrameIndex: highestMeasuredIndex(),
|
|
320
|
-
averageItemLength: averageLength,
|
|
321
|
-
});
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
scrollToPixel(offsetForIndex(params.index, params.viewPosition ?? FIRST_INDEX, params.viewOffset ?? EMPTY_OFFSET), params.animated ?? true);
|
|
260
|
+
dispatch({
|
|
261
|
+
kind: 'scroll-to-index',
|
|
262
|
+
index: params.index,
|
|
263
|
+
animated: params.animated ?? true,
|
|
264
|
+
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
265
|
+
viewOffset: params.viewOffset ?? EMPTY_OFFSET,
|
|
266
|
+
});
|
|
325
267
|
},
|
|
326
268
|
scrollToItem: (params) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
dlog('VirtualizedList scrollToItem: item not found');
|
|
269
|
+
dispatch({
|
|
270
|
+
kind: 'scroll-to-item',
|
|
271
|
+
item: params.item,
|
|
272
|
+
animated: params.animated ?? true,
|
|
273
|
+
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
274
|
+
});
|
|
334
275
|
},
|
|
335
276
|
scrollToEnd: (params) => {
|
|
336
|
-
|
|
277
|
+
dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
|
|
337
278
|
},
|
|
338
279
|
flashScrollIndicators: () => {
|
|
339
280
|
scrollViewRef.current?.flashScrollIndicators?.();
|
|
@@ -345,80 +286,28 @@ export function VirtualizedList(props) {
|
|
|
345
286
|
// Manual trigger for RN's recordInteraction: flip the interaction flag so
|
|
346
287
|
// waitForInteraction viewability configs start reporting.
|
|
347
288
|
recordInteraction: () => {
|
|
348
|
-
|
|
289
|
+
dispatch({ kind: 'record-interaction' });
|
|
349
290
|
},
|
|
350
|
-
}), [
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
data,
|
|
355
|
-
getItem,
|
|
356
|
-
total,
|
|
357
|
-
viewportLength,
|
|
358
|
-
fixedLayout,
|
|
359
|
-
highestMeasuredIndex,
|
|
360
|
-
onScrollToIndexFailed,
|
|
361
|
-
averageLength,
|
|
362
|
-
]);
|
|
363
|
-
// initialScrollIndex: once the first viewport is known, jump to that index a single time.
|
|
364
|
-
useEffect(() => {
|
|
365
|
-
if (initialScrollIndex === undefined || appliedInitialScrollRef.current)
|
|
366
|
-
return;
|
|
367
|
-
if (viewportLength <= EMPTY_OFFSET || count === FIRST_INDEX)
|
|
368
|
-
return;
|
|
369
|
-
appliedInitialScrollRef.current = true;
|
|
370
|
-
// The initial jump is instant (RN doesn't animate initialScrollIndex).
|
|
371
|
-
scrollToPixel(offsetForIndex(initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET), false);
|
|
372
|
-
}, [initialScrollIndex, viewportLength, count, scrollToPixel, offsetForIndex]);
|
|
373
|
-
const keyForIndex = useCallback((index) => {
|
|
374
|
-
const item = getItem(data, index);
|
|
375
|
-
return keyExtractor ? keyExtractor(item, index) : String(index);
|
|
376
|
-
}, [getItem, data, keyExtractor]);
|
|
377
|
-
// maintainVisibleContentPosition JS anchor adjustment (RN getDerivedStateFromProps:715-768): the
|
|
378
|
-
// native MVCP cannot see prepended items above the window (they are collapsed into the leading
|
|
379
|
-
// SPACER), so we replicate the JS shift for exactly those. Runs in a layout effect so the
|
|
380
|
-
// correction lands before paint.
|
|
291
|
+
}), [dispatch]);
|
|
292
|
+
// After-commit pass: run the deferred effects (batch fill, edge-reached, viewability,
|
|
293
|
+
// initial-scroll, MVCP) in a LAYOUT effect so MVCP's shift lands before paint. Re-runs only when
|
|
294
|
+
// the windowing signature changed — the same dedup key every adapter shares.
|
|
381
295
|
useLayoutEffect(() => {
|
|
382
|
-
|
|
383
|
-
|
|
296
|
+
const current = stateRef.current;
|
|
297
|
+
if (current === null)
|
|
384
298
|
return;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (anchorIndex > minIndexForVisible) {
|
|
398
|
-
// Native MVCP shifts in-window cells itself; the JS shift covers ONLY the inserted items
|
|
399
|
-
// in the leading SPACER (above the first rendered index). Counting the full inserted extent
|
|
400
|
-
// here would double-correct.
|
|
401
|
-
const spacerEnd = Math.min(anchorIndex, committedWindowRef.current.first);
|
|
402
|
-
const insertedExtent = spacerEnd > minIndexForVisible
|
|
403
|
-
? offsets[spacerEnd] - offsets[minIndexForVisible]
|
|
404
|
-
: EMPTY_OFFSET;
|
|
405
|
-
if (insertedExtent > EMPTY_OFFSET) {
|
|
406
|
-
const autoThreshold = maintainVisibleContentPosition.autoscrollToTopThreshold;
|
|
407
|
-
const anchoredNearTop = autoThreshold !== undefined && scrollOffset <= autoThreshold;
|
|
408
|
-
if (anchoredNearTop) {
|
|
409
|
-
dlog(`VirtualizedList MVCP autoscroll-to-top (offset=${scrollOffset} <= ${autoThreshold})`);
|
|
410
|
-
scrollToPixel(EMPTY_OFFSET, true);
|
|
411
|
-
}
|
|
412
|
-
else {
|
|
413
|
-
dlog(`VirtualizedList MVCP adjust +${insertedExtent}px ` +
|
|
414
|
-
`(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
|
|
415
|
-
scrollToPixel(scrollOffset + insertedExtent, false);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
firstVisibleKeyRef.current = newFirstVisibleKey;
|
|
421
|
-
}, [maintainVisibleContentPosition, count, keyForIndex, offsets, scrollOffset, scrollToPixel]);
|
|
299
|
+
const result = reduceList(current, { kind: 'commit' }, inputsRef.current);
|
|
300
|
+
runEffects(result.effects);
|
|
301
|
+
}, [commitSignature, runEffects]);
|
|
302
|
+
// Clear any pending timers on unmount (RN ViewabilityHelper.dispose + the fill timer).
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
return () => {
|
|
305
|
+
if (viewableTimerRef.current !== null)
|
|
306
|
+
clearTimeout(viewableTimerRef.current);
|
|
307
|
+
if (batchTimerRef.current !== null)
|
|
308
|
+
clearTimeout(batchTimerRef.current);
|
|
309
|
+
};
|
|
310
|
+
}, []);
|
|
422
311
|
// ---- assemble the windowed child list ----------------------------------
|
|
423
312
|
// extraData needs no wiring: this component is not memoized, so any prop change (including
|
|
424
313
|
// extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// SectionList -> VirtualizedSectionList -> VirtualizedList stack.
|
|
8
8
|
import { createElement, useImperativeHandle, useRef, } from 'react';
|
|
9
9
|
import { dlog, Platform } from '@symbiote-native/engine';
|
|
10
|
-
import { flattenSections, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
|
|
10
|
+
import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
|
|
11
11
|
import { VirtualizedList, } from '../virtualized-list/index.js';
|
|
12
12
|
function resolveSeparator(component) {
|
|
13
13
|
if (component === undefined)
|
|
@@ -24,9 +24,7 @@ export function VirtualizedSectionList(props) {
|
|
|
24
24
|
const { entries, headerIndices } = flattenSections(sections, SectionSeparatorComponent !== undefined);
|
|
25
25
|
// RN sticks section headers by default only on iOS; Android does not unless asked. headerIndices
|
|
26
26
|
// are the flat positions of every section header; VirtualizedList forwards the in-window ones.
|
|
27
|
-
const
|
|
28
|
-
const stickyEnabled = stickySectionHeadersEnabled ?? stickyDefault;
|
|
29
|
-
const stickyHeaderIndices = stickyEnabled ? headerIndices : undefined;
|
|
27
|
+
const stickyHeaderIndices = resolveStickySectionHeaders(stickySectionHeadersEnabled, headerIndices, Platform.OS);
|
|
30
28
|
// The handle reaches into the inner VirtualizedList to drive scrollToIndex.
|
|
31
29
|
const listRef = useRef(null);
|
|
32
30
|
dlog(`VirtualizedSectionList: ${sections.length} sections flattened to ${entries.length} entries`);
|
package/build/render.js
CHANGED
|
@@ -8,8 +8,16 @@ const noop = () => { };
|
|
|
8
8
|
// A native event runs the listener (which may call setState) outside React's
|
|
9
9
|
// loop. Run it at discrete priority so the update takes the sync lane, then
|
|
10
10
|
// flush that work synchronously to paint the result.
|
|
11
|
+
//
|
|
12
|
+
// Diagnostic seam (gated, perf investigation): every native event forces its own
|
|
13
|
+
// synchronous flush here, with no continuous-vs-discrete split (unlike DOM React,
|
|
14
|
+
// which lets high-frequency continuous events like drag/scroll coalesce). This
|
|
15
|
+
// dlog counts how many forced flushes one gesture (e.g. a Slider drag) produces,
|
|
16
|
+
// to compare against Vue/Angular's microtask-coalesced requestCommit(). Kept
|
|
17
|
+
// behind DEBUG per <keep_logs_gate_behind_DEBUG>, never removed.
|
|
11
18
|
setEventDispatcher(run => {
|
|
12
19
|
withDiscretePriority(run);
|
|
20
|
+
dlog('react event-dispatch: forced flushSyncWork');
|
|
13
21
|
// @ts-expect-error flushSyncWork exists at runtime in react-reconciler 0.33
|
|
14
22
|
reconciler.flushSyncWork();
|
|
15
23
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "SymbioteNative's React adapter — a react-reconciler host config (mutation mode) driving real native iOS/Android views through stock React Native Fabric.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"react-reconciler": "^0.33.0",
|
|
40
|
-
"@symbiote-native/components": "0.
|
|
40
|
+
"@symbiote-native/components": "0.3.0",
|
|
41
41
|
"@symbiote-native/css-parser": "0.2.3"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|