@symbiote-native/vue 0.3.6 → 0.3.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/scroll-view/shared.js +21 -16
- package/build/components/scroll-view/sticky-header.js +85 -67
- package/build/components/touchable.js +30 -50
- package/build/components/virtualized-list/index.js +154 -270
- package/build/components/virtualized-section-list/index.js +2 -4
- package/package.json +7 -6
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// onScroll composition (native attach vs Animated.event) all live here; the per-header component and
|
|
20
20
|
// the children wrap live in scroll-view-sticky-header.ts (the Vue twin of the React file).
|
|
21
21
|
import { defineComponent, h, isVNode, markRaw, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
|
|
22
|
-
import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, selectScrollIntrinsics, } from '@symbiote-native/components';
|
|
22
|
+
import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
|
|
23
23
|
import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, isSymbioteNode, resolveClassName, } from '@symbiote-native/engine';
|
|
24
24
|
import { wrapStickyHeaders } from './sticky-header.js';
|
|
25
25
|
import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
|
|
@@ -196,31 +196,37 @@ export function createScrollView(platform) {
|
|
|
196
196
|
outerProps.decelerationRate = resolveDecelerationRate(decel);
|
|
197
197
|
// onScroll: when sticky headers are active, the offset must reach the AnimatedValue. RN does
|
|
198
198
|
// the same with _scrollAnimatedValueAttachment. forwardAttrs already put the user's onScroll /
|
|
199
|
-
// onLayout / scrollEventThrottle on outerProps; here we override them
|
|
199
|
+
// onLayout / scrollEventThrottle on outerProps; here we override them per the shared
|
|
200
|
+
// resolveScrollForwarding DECISIONS (which path, the 1/16 throttle defaults, inverted capture).
|
|
200
201
|
const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
|
|
201
202
|
nativeStickyWanted = nativeStickyAvailable;
|
|
203
|
+
const userThrottle = typeof attrs.scrollEventThrottle === 'number' ? attrs.scrollEventThrottle : undefined;
|
|
204
|
+
const forwarding = resolveScrollForwarding({
|
|
205
|
+
hasStickyHeaders,
|
|
206
|
+
nativeStickyAvailable,
|
|
207
|
+
invertStickyHeaders,
|
|
208
|
+
scrollEventThrottle: userThrottle,
|
|
209
|
+
maintainVisibleContentPosition: attrs.maintainVisibleContentPosition,
|
|
210
|
+
snapToAlignment: attrs.snapToAlignment,
|
|
211
|
+
});
|
|
202
212
|
if (hasStickyHeaders) {
|
|
203
213
|
const userOnScroll = isHandler(attrs.onScroll) ? attrs.onScroll : undefined;
|
|
204
|
-
|
|
205
|
-
if (nativeStickyAvailable) {
|
|
206
|
-
// Native path (RN attachNativeEvent): the scroll value is driven on the UI thread by the
|
|
207
|
-
// post-commit watch above, so onScroll only forwards to the user: zero JS per frame. RN
|
|
208
|
-
// uses throttle 1 when sticky (ScrollView.js:1798); the native driver can afford it. The
|
|
209
|
-
// user onScroll is already on outerProps via forwardAttrs.
|
|
210
|
-
outerProps.scrollEventThrottle = userThrottle ?? 1;
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
214
|
+
if (forwarding.mode === 'sticky-js') {
|
|
213
215
|
// JS fallback (no native module): Animated.event drives the value each frame and forwards
|
|
214
216
|
// the user's handler as the listener passthrough. Correct, but lags a frame under fast
|
|
215
|
-
// scroll (the jitter), which the native path
|
|
217
|
+
// scroll (the jitter), which the native path removes on a real host.
|
|
216
218
|
outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], userOnScroll === undefined
|
|
217
219
|
? undefined
|
|
218
220
|
: { listener: (...args) => forwardScrollEvent(userOnScroll, args) });
|
|
219
|
-
|
|
221
|
+
}
|
|
222
|
+
// Native path: the value is driven on the UI thread by the post-commit watch above, so the
|
|
223
|
+
// user onScroll (already on outerProps via forwardAttrs) forwards untouched, zero JS/frame.
|
|
224
|
+
if (forwarding.scrollEventThrottle !== undefined) {
|
|
225
|
+
outerProps.scrollEventThrottle = forwarding.scrollEventThrottle;
|
|
220
226
|
}
|
|
221
227
|
// onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
|
|
222
228
|
// (RN _handleLayout), then call the user's handler. Non-inverted leaves onLayout as forwarded.
|
|
223
|
-
if (
|
|
229
|
+
if (forwarding.capturesViewportHeight) {
|
|
224
230
|
const userOnLayout = isHandler(attrs.onLayout) ? attrs.onLayout : undefined;
|
|
225
231
|
outerProps.onLayout = (event) => {
|
|
226
232
|
const height = readLayoutDimension(event, 'height');
|
|
@@ -238,8 +244,7 @@ export function createScrollView(platform) {
|
|
|
238
244
|
// false also preserves the cell views maintainVisibleContentPosition / snapToAlignment
|
|
239
245
|
// anchor against (RN preserveChildren). iOS never flattens; both are no-ops there.
|
|
240
246
|
const contentProps = { style: contentStyle, collapsable: false };
|
|
241
|
-
if (
|
|
242
|
-
attrs.snapToAlignment !== undefined) {
|
|
247
|
+
if (forwarding.collapsableChildren) {
|
|
243
248
|
contentProps.collapsableChildren = false;
|
|
244
249
|
}
|
|
245
250
|
// contentSizeChange is synthesized from the content view's own onLayout (RN
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// @symbiote-native/components (computeStickyInterpolation); this file holds the Vue component
|
|
12
12
|
// shell, the layout state, and the child-wrapping. Render shared verbatim with React via the math.
|
|
13
13
|
// Vue supplies only the reactive lifecycle (refs/watch instead of useState/useEffect).
|
|
14
|
-
import { defineComponent, h, isVNode, ref, shallowRef,
|
|
14
|
+
import { defineComponent, h, isVNode, onBeforeUnmount, ref, shallowRef, watchEffect, markRaw, } from '@vue/runtime-core';
|
|
15
15
|
import { AnimatedValue, AnimatedInterpolation, Platform, dlog, } from '@symbiote-native/engine';
|
|
16
|
-
import {
|
|
16
|
+
import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
|
|
17
17
|
import { Animated } from '../../modules/animated/index.js';
|
|
18
18
|
function isHandler(value) {
|
|
19
19
|
return typeof value === 'function';
|
|
@@ -49,79 +49,95 @@ export const ScrollViewStickyHeader = defineComponent({
|
|
|
49
49
|
const scrollAnimatedValue = isAnimatedValue(attrs.scrollAnimatedValue)
|
|
50
50
|
? attrs.scrollAnimatedValue
|
|
51
51
|
: markRaw(new AnimatedValue(0));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
52
|
+
// The one folded state cell, mutated in place by reduceSticky. A plain object (NOT a ref /
|
|
53
|
+
// reactive): the render reads it gated by the reactive `version` bump + `animatedTranslateY`
|
|
54
|
+
// shallowRef below, so it never needs Vue to proxy it. The DECISIONS — zero-swallow gate,
|
|
55
|
+
// debounce delay, rebuild ranges — all live in reduceSticky.
|
|
56
|
+
const state = createInitialStickyState();
|
|
57
|
+
// A reactive tick bumped when the reducer commits a new translateY, forcing the render to re-read
|
|
58
|
+
// state.translateY (the debounced committed value).
|
|
59
|
+
const version = ref(0);
|
|
60
|
+
// The animated node that drives the transform (RN's animatedTranslateY). Engine node →
|
|
61
|
+
// shallowRef (held by identity, the reactivity rule); the un-measured identity stub until the
|
|
62
|
+
// rebuild-interpolation effect below replaces it.
|
|
59
63
|
const animatedTranslateY = shallowRef(scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
|
|
60
|
-
// The debounced EXPLICIT translateY pushed to the committed transform via
|
|
61
|
-
// passthroughAnimatedPropExplicitValues, so the Fabric ShadowTree (hit-testing) knows the pinned
|
|
62
|
-
// position while the native driver animates. null until the listener first fires.
|
|
63
|
-
const translateY = ref(null);
|
|
64
|
-
let haveReceivedInitialZeroTranslateY = true;
|
|
65
64
|
let debounceTimer;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// The current interpolation node + its listener id, held so the next rebuild detaches the old
|
|
66
|
+
// listener (an engine call the reducer does not own) and onBeforeUnmount cleans up.
|
|
67
|
+
let interpolation;
|
|
68
|
+
let listenerId;
|
|
69
|
+
const inputs = () => ({
|
|
70
|
+
os: Platform.OS,
|
|
71
|
+
inverted: typeof attrs.inverted === 'boolean' ? attrs.inverted : undefined,
|
|
72
|
+
scrollViewHeight: typeof attrs.scrollViewHeight === 'number' ? attrs.scrollViewHeight : undefined,
|
|
73
|
+
nextHeaderLayoutY: typeof attrs.nextHeaderLayoutY === 'number' ? attrs.nextHeaderLayoutY : undefined,
|
|
69
74
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
const runEffects = (effects) => {
|
|
76
|
+
for (const effect of effects) {
|
|
77
|
+
switch (effect.kind) {
|
|
78
|
+
case 'rebuild-interpolation': {
|
|
79
|
+
// Detach the old listener, build a fresh interpolation onto the shared scroll value, and
|
|
80
|
+
// wire the settled-value listener (symbiote is always Fabric; RN attaches it only there).
|
|
81
|
+
if (interpolation !== undefined && listenerId !== undefined) {
|
|
82
|
+
interpolation.removeListener(listenerId);
|
|
83
|
+
listenerId = undefined;
|
|
84
|
+
}
|
|
85
|
+
const next = scrollAnimatedValue.interpolate({
|
|
86
|
+
inputRange: effect.inputRange,
|
|
87
|
+
outputRange: effect.outputRange,
|
|
88
|
+
});
|
|
89
|
+
listenerId = next.addListener(({ value }) => {
|
|
90
|
+
if (typeof value === 'number')
|
|
91
|
+
dispatch({ kind: 'animated-tick', value });
|
|
92
|
+
});
|
|
93
|
+
interpolation = next;
|
|
94
|
+
animatedTranslateY.value = next;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'schedule-debounce':
|
|
98
|
+
// The animated value updates several times per frame; debounce the settled value into the
|
|
99
|
+
// committed transform so hit detection stays current (a Fabric issue, worse on Android).
|
|
100
|
+
if (debounceTimer !== undefined)
|
|
101
|
+
clearTimeout(debounceTimer);
|
|
102
|
+
debounceTimer = setTimeout(() => {
|
|
103
|
+
debounceTimer = undefined;
|
|
104
|
+
dispatch({ kind: 'debounce-fired', value: effect.value });
|
|
105
|
+
}, effect.delay);
|
|
106
|
+
break;
|
|
107
|
+
case 'apply-passthrough':
|
|
108
|
+
version.value += 1;
|
|
109
|
+
break;
|
|
110
|
+
case 'record-header-y':
|
|
111
|
+
// Vue records through attrs.onLayout (the wrapper closure), which honors the public
|
|
112
|
+
// IStickyHeaderProps contract; the reducer emits no index for it.
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
81
115
|
}
|
|
82
|
-
if (debounceTimer !== undefined)
|
|
83
|
-
clearTimeout(debounceTimer);
|
|
84
|
-
debounceTimer = setTimeout(() => {
|
|
85
|
-
translateY.value = value;
|
|
86
|
-
}, timeout);
|
|
87
116
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
inverted,
|
|
100
|
-
scrollViewHeight,
|
|
101
|
-
layoutY: layoutY.value,
|
|
102
|
-
layoutHeight: layoutHeight.value,
|
|
103
|
-
nextHeaderLayoutY,
|
|
104
|
-
});
|
|
105
|
-
const interpolation = scrollAnimatedValue.interpolate({ inputRange, outputRange });
|
|
106
|
-
const listenerId = interpolation.addListener(animatedValueListener);
|
|
107
|
-
animatedTranslateY.value = interpolation;
|
|
108
|
-
onCleanup(() => {
|
|
117
|
+
const dispatch = (action) => {
|
|
118
|
+
runEffects(reduceSticky(state, action, inputs()).effects);
|
|
119
|
+
};
|
|
120
|
+
// Rebuild whenever the collision/viewport inputs change (the Vue twin of React's inputs-changed
|
|
121
|
+
// effect, tracking [inverted, scrollViewHeight, nextHeaderLayoutY] via inputs()); also the initial
|
|
122
|
+
// build on mount. scrollAnimatedValue is the stable const (never changes for one ScrollView).
|
|
123
|
+
watchEffect(() => {
|
|
124
|
+
dispatch({ kind: 'inputs-changed' });
|
|
125
|
+
});
|
|
126
|
+
onBeforeUnmount(() => {
|
|
127
|
+
if (interpolation !== undefined && listenerId !== undefined) {
|
|
109
128
|
interpolation.removeListener(listenerId);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
}
|
|
130
|
+
if (debounceTimer !== undefined)
|
|
131
|
+
clearTimeout(debounceTimer);
|
|
113
132
|
});
|
|
114
|
-
// Stable function ref (defined once, not re-created per render):
|
|
115
|
-
// measured, fire the wrapper's recorder (onHeaderLayoutY, in
|
|
116
|
-
// own onLayout. Matches RN ScrollViewStickyHeader.js._onLayout
|
|
133
|
+
// Stable function ref (defined once, not re-created per render): dispatch the layout (record
|
|
134
|
+
// own y/height, mark measured, rebuild), fire the wrapper's recorder (onHeaderLayoutY, in
|
|
135
|
+
// attrs.onLayout), then the child's own onLayout. Matches RN ScrollViewStickyHeader.js._onLayout.
|
|
117
136
|
const onLayout = (event) => {
|
|
118
137
|
const y = readLayoutNumber(event, 'y');
|
|
119
138
|
const height = readLayoutNumber(event, 'height');
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (height !== undefined)
|
|
123
|
-
layoutHeight.value = height;
|
|
124
|
-
measured.value = true;
|
|
139
|
+
// Keep the previous value when a field is absent (RN sets state only on a defined read).
|
|
140
|
+
dispatch({ kind: 'layout', y: y ?? state.layoutY, height: height ?? state.layoutHeight });
|
|
125
141
|
const recorder = attrs.onLayout;
|
|
126
142
|
if (isHandler(recorder))
|
|
127
143
|
recorder(event);
|
|
@@ -132,10 +148,12 @@ export const ScrollViewStickyHeader = defineComponent({
|
|
|
132
148
|
childOnLayout(event);
|
|
133
149
|
};
|
|
134
150
|
return () => {
|
|
151
|
+
// Read the version bump so a committed translateY re-runs render.
|
|
152
|
+
void version.value;
|
|
135
153
|
// The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
|
|
136
154
|
// `animatedTranslateY` does the smooth (native-driven) pin. See RN ScrollViewStickyHeader.js.
|
|
137
|
-
const passthroughAnimatedPropExplicitValues = translateY
|
|
138
|
-
? { style: { transform: [{ translateY: translateY
|
|
155
|
+
const passthroughAnimatedPropExplicitValues = state.translateY !== null
|
|
156
|
+
? { style: { transform: [{ translateY: state.translateY }] } }
|
|
139
157
|
: null;
|
|
140
158
|
// collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
|
|
141
159
|
// OVER the rows scrolling under it. The interpolation node passes inside the transform; the
|
|
@@ -11,14 +11,20 @@
|
|
|
11
11
|
// Inputs arrive as attrs (untyped), narrowed with runtime guards. The handlers read attrs LIVE
|
|
12
12
|
// (they fire on events, not render) so a re-supplied callback / timing is always honored.
|
|
13
13
|
import { defineComponent, h } from '@vue/runtime-core';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, highlightPressedStyle, DEFAULT_ACTIVE_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_MIN_PRESS_DURATION_MS, DEFAULT_UNDERLAY_COLOR, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, } from '@symbiote-native/components';
|
|
15
|
+
import {} from '@symbiote-native/engine';
|
|
16
16
|
import { Pressable, emitPressableEvents, PRESSABLE_EMITS, } from './pressable.js';
|
|
17
17
|
import { Animated } from '../modules/animated/index.js';
|
|
18
18
|
import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
|
|
19
19
|
function numberOr(value, fallback) {
|
|
20
20
|
return typeof value === 'number' ? value : fallback;
|
|
21
21
|
}
|
|
22
|
+
// The real setTimeout the shared feedback machine schedules its deferred activation/deactivation on
|
|
23
|
+
// (core/components has no timer globals). Returns a canceller so an early release flushes the timer.
|
|
24
|
+
function scheduleTimeout(callback, ms) {
|
|
25
|
+
const id = setTimeout(callback, ms);
|
|
26
|
+
return () => clearTimeout(id);
|
|
27
|
+
}
|
|
22
28
|
function forwardExcept(attrs, handled) {
|
|
23
29
|
const result = {};
|
|
24
30
|
for (const key of Object.keys(attrs)) {
|
|
@@ -41,8 +47,9 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
|
|
|
41
47
|
// One Animated.Value per mount, resting at full opacity. Held by identity in setup scope (an
|
|
42
48
|
// engine object, never a reactive ref). The Animated.View leaf commits it every frame.
|
|
43
49
|
const opacity = new Animated.Value(RESTING_OPACITY);
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// The shared press-scheduling cell (delayPressIn timer + activation clock), persisted across
|
|
51
|
+
// renders in setup scope; the handlers are rebuilt each render over live attrs.
|
|
52
|
+
const runtime = createTouchableFeedbackRuntime();
|
|
46
53
|
function setOpacityTo(toValue, duration) {
|
|
47
54
|
Animated.timing(opacity, {
|
|
48
55
|
toValue,
|
|
@@ -51,51 +58,27 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
|
|
|
51
58
|
useNativeDriver: false,
|
|
52
59
|
}).start();
|
|
53
60
|
}
|
|
54
|
-
function clearPressInTimer() {
|
|
55
|
-
if (pressInTimer !== undefined) {
|
|
56
|
-
clearTimeout(pressInTimer);
|
|
57
|
-
pressInTimer = undefined;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
function activate(event) {
|
|
61
|
-
activatedAt = Date.now();
|
|
62
|
-
setOpacityTo(numberOr(rawAttrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_DURATION_MS);
|
|
63
|
-
emit('pressIn', event);
|
|
64
|
-
}
|
|
65
|
-
function deactivate(event) {
|
|
66
|
-
activatedAt = undefined;
|
|
67
|
-
setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
|
|
68
|
-
emit('pressOut', event);
|
|
69
|
-
}
|
|
70
|
-
function handlePressIn(event) {
|
|
71
|
-
const delayPressIn = numberOr(rawAttrs.delayPressIn, 0);
|
|
72
|
-
if (delayPressIn > 0) {
|
|
73
|
-
dlog(`TouchableOpacity pressIn deferred ${delayPressIn}ms`);
|
|
74
|
-
pressInTimer = setTimeout(() => {
|
|
75
|
-
pressInTimer = undefined;
|
|
76
|
-
activate(event);
|
|
77
|
-
}, delayPressIn);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
activate(event);
|
|
81
|
-
}
|
|
82
|
-
function handlePressOut(event) {
|
|
83
|
-
if (pressInTimer !== undefined) {
|
|
84
|
-
clearPressInTimer();
|
|
85
|
-
activate(event);
|
|
86
|
-
}
|
|
87
|
-
const heldFor = activatedAt === undefined ? 0 : Date.now() - activatedAt;
|
|
88
|
-
const wait = computePressOutWait(heldFor, numberOr(rawAttrs.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS), numberOr(rawAttrs.delayPressOut, 0));
|
|
89
|
-
if (wait > 0) {
|
|
90
|
-
dlog(`TouchableOpacity pressOut deferred ${wait}ms`);
|
|
91
|
-
setTimeout(() => deactivate(event), wait);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
deactivate(event);
|
|
95
|
-
}
|
|
96
61
|
return () => {
|
|
97
62
|
const attrs = normalizeVueAttrs(rawAttrs);
|
|
98
63
|
const style = attrs.style;
|
|
64
|
+
// Rebuilt each render so a re-supplied delay/opacity is honored (Vue's live-attr idiom); the
|
|
65
|
+
// shared machine owns the scheduling, the adapter supplies the Animated fade + emit.
|
|
66
|
+
const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
|
|
67
|
+
delayPressIn: numberOr(attrs.delayPressIn, 0),
|
|
68
|
+
delayPressOut: numberOr(attrs.delayPressOut, 0),
|
|
69
|
+
minPressDuration: numberOr(attrs.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
|
|
70
|
+
schedule: scheduleTimeout,
|
|
71
|
+
now: Date.now,
|
|
72
|
+
}, runtime, {
|
|
73
|
+
activate(event) {
|
|
74
|
+
setOpacityTo(numberOr(attrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_DURATION_MS);
|
|
75
|
+
emit('pressIn', event);
|
|
76
|
+
},
|
|
77
|
+
deactivate(event) {
|
|
78
|
+
setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
|
|
79
|
+
emit('pressOut', event);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
99
82
|
const pressableProps = {
|
|
100
83
|
...forwardExcept(attrs, TOUCHABLE_OPACITY_HANDLED),
|
|
101
84
|
...emitPressableEvents(emit),
|
|
@@ -119,10 +102,7 @@ export const TouchableHighlight = defineComponent((_props, { slots, attrs: rawAt
|
|
|
119
102
|
const underlayColor = typeof attrs.underlayColor === 'string' ? attrs.underlayColor : DEFAULT_UNDERLAY_COLOR;
|
|
120
103
|
const style = attrs.style;
|
|
121
104
|
function pressedStyle({ pressed }) {
|
|
122
|
-
|
|
123
|
-
return style;
|
|
124
|
-
const overlay = { backgroundColor: underlayColor, opacity: activeOpacity };
|
|
125
|
-
return [style, overlay];
|
|
105
|
+
return highlightPressedStyle(pressed, style, underlayColor, activeOpacity);
|
|
126
106
|
}
|
|
127
107
|
const pressableProps = {
|
|
128
108
|
...forwardExcept(attrs, TOUCHABLE_HIGHLIGHT_HANDLED),
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// VirtualizedList, the Vue lifecycle half. The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// Vue supplies only the reactivity:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// VirtualizedList, the Vue lifecycle half. The orchestration — window recompute, batch throttle,
|
|
2
|
+
// viewability, edge-reached, initial-scroll, MVCP, the imperative scrolls — is the framework-agnostic
|
|
3
|
+
// `reduceList` state machine in @symbiote-native/components (state/virtualized-list-reducer), shared verbatim
|
|
4
|
+
// with React and Angular. Here Vue supplies only the reactivity: it turns native events into ACTIONS,
|
|
5
|
+
// holds ONE plain state cell (listState), runs the returned EFFECTS with Vue primitives (a native
|
|
6
|
+
// scrollTo, an emit, a setTimeout, a version bump), and builds the cell/spacer VNodes off the plan.
|
|
7
|
+
// A single `metrics` computed derives the window once per render (refresh-metrics); a single
|
|
8
|
+
// post-flush watcher on the windowing signature runs the after-commit `commit` pass. This is the Vue
|
|
9
|
+
// twin of the React adapter's one-state-cell + dispatch + runEffects. It drives the Vue ScrollView,
|
|
9
10
|
// exactly as the React list drives the React ScrollView.
|
|
10
11
|
//
|
|
11
12
|
// Typed-emits generic component (mirrors FlatList): a GENERIC setup function
|
|
@@ -23,9 +24,10 @@
|
|
|
23
24
|
//
|
|
24
25
|
// Reactivity gotcha: the ScrollView's exposed handle is held in a shallowRef so the engine
|
|
25
26
|
// node it closes over is reached by identity; a deep ref would proxy it and the imperative
|
|
26
|
-
// scroll commands would silently no-op.
|
|
27
|
+
// scroll commands would silently no-op. The folded listState is a PLAIN object (not reactive) —
|
|
28
|
+
// mutating it never triggers Vue; the `version` ref is bumped when a transition changes render state.
|
|
27
29
|
import { computed, defineComponent, getCurrentInstance, h, isVNode, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
|
|
28
|
-
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,
|
|
30
|
+
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';
|
|
29
31
|
import { dlog, } from '@symbiote-native/engine';
|
|
30
32
|
import { ScrollView } from '../scroll-view';
|
|
31
33
|
import { RefreshControl } from '../refresh-control.js';
|
|
@@ -120,17 +122,13 @@ function isScrollViewHandle(value) {
|
|
|
120
122
|
return isRecord(value) && typeof value.scrollTo === 'function';
|
|
121
123
|
}
|
|
122
124
|
export const VirtualizedList = defineComponent((props, { attrs, expose, emit, slots, }) => {
|
|
123
|
-
//
|
|
124
|
-
// re-
|
|
125
|
-
const
|
|
126
|
-
const
|
|
125
|
+
// Bumped when a transition changes render-relevant state, so the `metrics` computed and the
|
|
126
|
+
// render fn re-run (listState is plain, non-reactive, so mutating it triggers nothing itself).
|
|
127
|
+
const version = ref(EMPTY_OFFSET);
|
|
128
|
+
const separatorVersion = ref(EMPTY_OFFSET);
|
|
127
129
|
// The offset we are imperatively driving native to. A fresh object identity each push so the
|
|
128
130
|
// commit path re-applies it even when the numeric value repeats. undefined = none pending.
|
|
129
131
|
const commandedOffset = ref(undefined);
|
|
130
|
-
// Bumped when a NEW cell measurement lands or a batch tick fires, to request a re-render
|
|
131
|
-
// without thrashing on already-known cells (React's measuredRef + setMeasureVersion).
|
|
132
|
-
const measureVersion = ref(EMPTY_OFFSET);
|
|
133
|
-
const separatorVersion = ref(EMPTY_OFFSET);
|
|
134
132
|
// shallowRef, NOT ref: the ScrollView handle closes over the engine scroll node, reached by
|
|
135
133
|
// identity through the engine's WeakMap mirror. A deep ref would proxy the object and every
|
|
136
134
|
// imperative scroll (scrollToOffset/Index/…) would miss the node and silently no-op.
|
|
@@ -147,20 +145,12 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
147
145
|
const vnodeProps = instance?.vnode.props;
|
|
148
146
|
return vnodeProps != null && typeof vnodeProps[onName] === 'function';
|
|
149
147
|
};
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
let committedWindow = { first: FIRST_INDEX, last: -1 };
|
|
155
|
-
let sentEndForContentLength = NO_CONTENT_LENGTH_SENT;
|
|
156
|
-
let sentStartForContentLength = NO_CONTENT_LENGTH_SENT;
|
|
157
|
-
let lastViewable = new Map();
|
|
148
|
+
// The one folded state cell (the Vue twin of React's stateRef); plus the per-gap separator
|
|
149
|
+
// overrides + the adapter-owned debounce/fill timers.
|
|
150
|
+
const listState = createInitialListState();
|
|
151
|
+
const separatorOverrides = new Map();
|
|
158
152
|
let viewableTimer = null;
|
|
159
153
|
let batchTimer = null;
|
|
160
|
-
let hasInteracted = false;
|
|
161
|
-
const separatorOverrides = new Map();
|
|
162
|
-
let appliedInitialScroll = false;
|
|
163
|
-
let firstVisibleKey = null;
|
|
164
154
|
const narrowed = computed(() => {
|
|
165
155
|
// extraData has no field of its own; reading it tracks it so a change forces a re-render
|
|
166
156
|
// (RN's extraData contract).
|
|
@@ -217,47 +207,34 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
217
207
|
forwarded: forwardAttrs(folded),
|
|
218
208
|
};
|
|
219
209
|
});
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return p.keyExtractor ? p.keyExtractor(item, index) : String(index);
|
|
224
|
-
};
|
|
225
|
-
// The windowing math, run through the shared @symbiote-native/components functions. It mutates the
|
|
226
|
-
// non-reactive committedWindow as it throttles (the controlled side effect React runs during
|
|
227
|
-
// render): committedWindow is plain state, so the write triggers no reactivity loop.
|
|
228
|
-
const metrics = computed(() => {
|
|
210
|
+
// The reducer inputs, folded off `narrowed`. Rebuilt per call (cheap); the viewability pairs are
|
|
211
|
+
// rebuilt here so the reduce and the effect fire against the same set.
|
|
212
|
+
const buildInputs = () => {
|
|
229
213
|
const p = narrowed.value;
|
|
230
|
-
void measureVersion.value;
|
|
231
|
-
const count = p.getItemCount(p.data);
|
|
232
|
-
const gil = p.getItemLayout;
|
|
233
|
-
const data = p.data;
|
|
234
|
-
const fixedLayout = gil !== undefined
|
|
235
|
-
? (index) => {
|
|
236
|
-
const layout = gil(data, index);
|
|
237
|
-
return { length: layout.length, offset: layout.offset };
|
|
238
|
-
}
|
|
239
|
-
: undefined;
|
|
240
|
-
const averageLength = fixedLayout !== undefined
|
|
241
|
-
? count > FIRST_INDEX
|
|
242
|
-
? fixedLayout(FIRST_INDEX).length
|
|
243
|
-
: EMPTY_OFFSET
|
|
244
|
-
: averageMeasuredLength(measured);
|
|
245
|
-
const { offsets, lengths, total } = buildOffsets(count, measured, fixedLayout, averageLength);
|
|
246
|
-
const target = computeWindow(count, offsets, lengths, scrollOffset.value, viewportLength.value, p.windowSize, p.initialNumToRender);
|
|
247
|
-
const throttled = throttleWindow(target, committedWindow, p.maxToRenderPerBatch);
|
|
248
|
-
committedWindow = throttled;
|
|
249
214
|
return {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
215
|
+
data: p.data,
|
|
216
|
+
getItem: p.getItem,
|
|
217
|
+
getItemCount: p.getItemCount,
|
|
218
|
+
keyExtractor: p.keyExtractor,
|
|
219
|
+
getItemLayout: p.getItemLayout,
|
|
220
|
+
horizontal: p.horizontal,
|
|
221
|
+
windowSize: p.windowSize,
|
|
222
|
+
initialNumToRender: p.initialNumToRender,
|
|
223
|
+
maxToRenderPerBatch: p.maxToRenderPerBatch,
|
|
224
|
+
updateCellsBatchingPeriod: p.updateCellsBatchingPeriod,
|
|
225
|
+
onEndReachedThreshold: p.onEndReachedThreshold,
|
|
226
|
+
onStartReachedThreshold: p.onStartReachedThreshold,
|
|
227
|
+
onEndReachedActive: p.onEndReached !== undefined,
|
|
228
|
+
onStartReachedActive: p.onStartReached !== undefined,
|
|
229
|
+
viewabilityPairs: buildViewabilityPairs(p.onViewableItemsChanged, p.viewabilityConfig, p.viewabilityConfigCallbackPairs),
|
|
230
|
+
maintainVisibleContentPosition: p.maintainVisibleContentPosition,
|
|
231
|
+
initialScrollIndex: p.initialScrollIndex,
|
|
259
232
|
};
|
|
260
|
-
}
|
|
233
|
+
};
|
|
234
|
+
const keyFor = (index) => {
|
|
235
|
+
const p = narrowed.value;
|
|
236
|
+
return resolveItemKey(p.getItem(p.data, index), index, p.keyExtractor);
|
|
237
|
+
};
|
|
261
238
|
const scrollToPixel = (offset, animated) => {
|
|
262
239
|
const p = narrowed.value;
|
|
263
240
|
const clamped = Math.max(EMPTY_OFFSET, offset);
|
|
@@ -273,21 +250,92 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
273
250
|
dlog(`Vue VirtualizedList scrollTo offset=${clamped} pending-ref`);
|
|
274
251
|
commandedOffset.value = targetOffset;
|
|
275
252
|
};
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
253
|
+
// Execute the reducer's effects with Vue primitives. `inputs` is threaded through so a
|
|
254
|
+
// fire-viewable fires against the same pairs the reduce used.
|
|
255
|
+
const runEffects = (effects, inputs) => {
|
|
256
|
+
const p = narrowed.value;
|
|
257
|
+
for (const effect of effects) {
|
|
258
|
+
switch (effect.kind) {
|
|
259
|
+
case 'scroll-to':
|
|
260
|
+
scrollToPixel(effect.offset, effect.animated);
|
|
261
|
+
break;
|
|
262
|
+
case 'fire-end-reached':
|
|
263
|
+
p.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
|
|
264
|
+
break;
|
|
265
|
+
case 'fire-start-reached':
|
|
266
|
+
p.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
|
|
267
|
+
break;
|
|
268
|
+
case 'fire-scroll-to-index-failed':
|
|
269
|
+
p.onScrollToIndexFailed?.({
|
|
270
|
+
index: effect.index,
|
|
271
|
+
highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
|
|
272
|
+
averageItemLength: effect.averageItemLength,
|
|
273
|
+
});
|
|
274
|
+
break;
|
|
275
|
+
case 'schedule-refill': {
|
|
276
|
+
if (batchTimer !== null)
|
|
277
|
+
clearTimeout(batchTimer);
|
|
278
|
+
batchTimer = setTimeout(() => {
|
|
279
|
+
batchTimer = null;
|
|
280
|
+
dispatch({ kind: 'batch-tick' });
|
|
281
|
+
}, effect.delay);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'fire-viewable': {
|
|
285
|
+
const pairs = inputs.viewabilityPairs;
|
|
286
|
+
const info = effect.info;
|
|
287
|
+
const map = effect.map;
|
|
288
|
+
const fire = () => {
|
|
289
|
+
for (const pair of pairs)
|
|
290
|
+
pair.onViewableItemsChanged(info);
|
|
291
|
+
dispatch({ kind: 'viewable-fired', map });
|
|
292
|
+
};
|
|
293
|
+
if (viewableTimer !== null) {
|
|
294
|
+
clearTimeout(viewableTimer);
|
|
295
|
+
viewableTimer = null;
|
|
296
|
+
}
|
|
297
|
+
if (effect.delay > EMPTY_OFFSET) {
|
|
298
|
+
viewableTimer = setTimeout(() => {
|
|
299
|
+
viewableTimer = null;
|
|
300
|
+
fire();
|
|
301
|
+
}, effect.delay);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
fire();
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const dispatch = (action) => {
|
|
312
|
+
const inputs = buildInputs();
|
|
313
|
+
const result = reduceList(listState, action, inputs);
|
|
314
|
+
runEffects(result.effects, inputs);
|
|
315
|
+
if (result.changed)
|
|
316
|
+
version.value += 1;
|
|
279
317
|
};
|
|
318
|
+
// The single derive-per-render: the window is recomputed once here (refresh-metrics) and cached
|
|
319
|
+
// by the computed until `version` or the narrowed props change, so the render fn and the commit
|
|
320
|
+
// signature both read it without re-deriving (which would double-advance the throttle).
|
|
321
|
+
const metrics = computed(() => {
|
|
322
|
+
void version.value;
|
|
323
|
+
reduceList(listState, { kind: 'refresh-metrics' }, buildInputs());
|
|
324
|
+
return listState.metrics;
|
|
325
|
+
});
|
|
326
|
+
const commitSignature = computed(() => {
|
|
327
|
+
void metrics.value;
|
|
328
|
+
return listEffectSignature(listState);
|
|
329
|
+
});
|
|
280
330
|
const onScroll = (event) => {
|
|
281
331
|
const p = narrowed.value;
|
|
282
332
|
const offset = readScrollOffset(event, p.horizontal);
|
|
283
333
|
if (offset === undefined)
|
|
284
334
|
return;
|
|
285
335
|
dlog(`Vue VirtualizedList onScroll offset=${offset}`);
|
|
286
|
-
// First scroll is the interaction that ungates waitForInteraction configs.
|
|
287
|
-
hasInteracted = true;
|
|
288
|
-
scrollOffset.value = offset;
|
|
289
336
|
// A real native scroll supersedes any pending commanded offset.
|
|
290
337
|
commandedOffset.value = undefined;
|
|
338
|
+
dispatch({ kind: 'scroll', offset });
|
|
291
339
|
// Compose, don't clobber: internal windowing ran first, now the user's onScroll.
|
|
292
340
|
if (p.userOnScroll !== undefined)
|
|
293
341
|
p.userOnScroll(event);
|
|
@@ -297,24 +345,17 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
297
345
|
if (length === undefined)
|
|
298
346
|
return;
|
|
299
347
|
dlog(`Vue VirtualizedList onLayout viewport=${length}`);
|
|
300
|
-
|
|
348
|
+
dispatch({ kind: 'layout', length });
|
|
301
349
|
};
|
|
302
350
|
const makeCellMeasure = (index) => (event) => {
|
|
303
|
-
const
|
|
304
|
-
if (p.getItemLayout !== undefined)
|
|
305
|
-
return;
|
|
306
|
-
const length = readLayoutLength(event, p.horizontal);
|
|
351
|
+
const length = readLayoutLength(event, narrowed.value.horizontal);
|
|
307
352
|
if (length === undefined)
|
|
308
353
|
return;
|
|
309
|
-
if (measured.get(index) === length)
|
|
310
|
-
return;
|
|
311
|
-
measured.set(index, length);
|
|
312
354
|
dlog(`Vue VirtualizedList cell ${index} measured length=${length}`);
|
|
313
|
-
|
|
355
|
+
dispatch({ kind: 'measure', index, length });
|
|
314
356
|
};
|
|
315
357
|
const mergeSeparator = (gapIndex, patch) => {
|
|
316
|
-
|
|
317
|
-
if (gapIndex < FIRST_INDEX || gapIndex > count - 2)
|
|
358
|
+
if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
|
|
318
359
|
return;
|
|
319
360
|
separatorOverrides.set(gapIndex, { ...separatorOverrides.get(gapIndex), ...patch });
|
|
320
361
|
separatorVersion.value += 1;
|
|
@@ -337,38 +378,31 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
337
378
|
// ---- imperative handle (the shared IVirtualizedListHandle surface) ------
|
|
338
379
|
const handle = {
|
|
339
380
|
scrollToOffset: (params) => {
|
|
340
|
-
|
|
381
|
+
dispatch({
|
|
382
|
+
kind: 'scroll-to-offset',
|
|
383
|
+
offset: params.offset,
|
|
384
|
+
animated: params.animated ?? true,
|
|
385
|
+
});
|
|
341
386
|
},
|
|
342
387
|
scrollToIndex: (params) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
p.onScrollToIndexFailed?.({
|
|
351
|
-
index: params.index,
|
|
352
|
-
highestMeasuredFrameIndex: highestMeasuredIndex(measured),
|
|
353
|
-
averageItemLength: m.averageLength,
|
|
354
|
-
});
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
scrollToPixel(offsetForIndexLocal(params.index, params.viewPosition ?? FIRST_INDEX, params.viewOffset ?? EMPTY_OFFSET), params.animated ?? true);
|
|
388
|
+
dispatch({
|
|
389
|
+
kind: 'scroll-to-index',
|
|
390
|
+
index: params.index,
|
|
391
|
+
animated: params.animated ?? true,
|
|
392
|
+
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
393
|
+
viewOffset: params.viewOffset ?? EMPTY_OFFSET,
|
|
394
|
+
});
|
|
358
395
|
},
|
|
359
396
|
scrollToItem: (params) => {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
dlog('Vue VirtualizedList scrollToItem: item not found');
|
|
397
|
+
dispatch({
|
|
398
|
+
kind: 'scroll-to-item',
|
|
399
|
+
item: params.item,
|
|
400
|
+
animated: params.animated ?? true,
|
|
401
|
+
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
402
|
+
});
|
|
369
403
|
},
|
|
370
404
|
scrollToEnd: (params) => {
|
|
371
|
-
|
|
405
|
+
dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
|
|
372
406
|
},
|
|
373
407
|
flashScrollIndicators: () => {
|
|
374
408
|
scrollHandle.value?.flashScrollIndicators();
|
|
@@ -378,167 +412,17 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
378
412
|
getScrollResponder: () => scrollHandle.value,
|
|
379
413
|
getScrollNode: () => scrollHandle.value?.getScrollNode() ?? null,
|
|
380
414
|
recordInteraction: () => {
|
|
381
|
-
|
|
415
|
+
dispatch({ kind: 'record-interaction' });
|
|
382
416
|
},
|
|
383
417
|
};
|
|
384
418
|
expose(handle);
|
|
385
|
-
// ---- after-commit
|
|
386
|
-
//
|
|
387
|
-
// the
|
|
388
|
-
watch(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
if (m.first <= m.target.first && m.last >= m.target.last)
|
|
394
|
-
return;
|
|
395
|
-
batchTimer = setTimeout(() => {
|
|
396
|
-
batchTimer = null;
|
|
397
|
-
measureVersion.value += 1;
|
|
398
|
-
}, narrowed.value.updateCellsBatchingPeriod);
|
|
399
|
-
}, { flush: 'post' });
|
|
400
|
-
// onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
|
|
401
|
-
// content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
|
|
402
|
-
watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
|
|
403
|
-
const p = narrowed.value;
|
|
404
|
-
if (p.onEndReached === undefined || viewportLength.value <= EMPTY_OFFSET)
|
|
405
|
-
return;
|
|
406
|
-
const m = metrics.value;
|
|
407
|
-
const { distanceFromEnd, withinThreshold } = computeEndReached(m.total, scrollOffset.value, viewportLength.value, p.onEndReachedThreshold);
|
|
408
|
-
const lastCellRendered = m.last === m.count - 1;
|
|
409
|
-
if (withinThreshold && lastCellRendered && sentEndForContentLength !== m.total) {
|
|
410
|
-
sentEndForContentLength = m.total;
|
|
411
|
-
dlog(`Vue VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
|
|
412
|
-
`(last=${m.last} of ${m.count}, contentLength=${m.total})`);
|
|
413
|
-
p.onEndReached({ distanceFromEnd });
|
|
414
|
-
}
|
|
415
|
-
if (!withinThreshold)
|
|
416
|
-
sentEndForContentLength = NO_CONTENT_LENGTH_SENT;
|
|
417
|
-
}, { flush: 'post' });
|
|
418
|
-
// onStartReached: the top-edge twin of onEndReached.
|
|
419
|
-
watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
|
|
420
|
-
const p = narrowed.value;
|
|
421
|
-
if (p.onStartReached === undefined || viewportLength.value <= EMPTY_OFFSET)
|
|
422
|
-
return;
|
|
423
|
-
const m = metrics.value;
|
|
424
|
-
const { distanceFromStart, withinThreshold } = computeStartReached(scrollOffset.value, viewportLength.value, p.onStartReachedThreshold);
|
|
425
|
-
const firstCellRendered = m.first === FIRST_INDEX;
|
|
426
|
-
if (withinThreshold && firstCellRendered && sentStartForContentLength !== m.total) {
|
|
427
|
-
sentStartForContentLength = m.total;
|
|
428
|
-
dlog(`Vue VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
|
|
429
|
-
`(first=${m.first}, contentLength=${m.total})`);
|
|
430
|
-
p.onStartReached({ distanceFromStart });
|
|
431
|
-
}
|
|
432
|
-
if (!withinThreshold)
|
|
433
|
-
sentStartForContentLength = NO_CONTENT_LENGTH_SENT;
|
|
434
|
-
}, { flush: 'post' });
|
|
435
|
-
// Viewability: recompute which rendered cells clear the threshold and, if the viewable set
|
|
436
|
-
// changed, fire onViewableItemsChanged + every config/callback pair, honoring minimumViewTime.
|
|
437
|
-
watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
|
|
438
|
-
const p = narrowed.value;
|
|
439
|
-
const m = metrics.value;
|
|
440
|
-
const pairs = buildViewabilityPairs(p.onViewableItemsChanged, p.viewabilityConfig, p.viewabilityConfigCallbackPairs);
|
|
441
|
-
if (pairs.length === EMPTY_OFFSET || viewportLength.value <= EMPTY_OFFSET)
|
|
442
|
-
return;
|
|
443
|
-
if (m.count === FIRST_INDEX)
|
|
444
|
-
return;
|
|
445
|
-
const { tokens, map } = computeViewableSet({
|
|
446
|
-
first: m.first,
|
|
447
|
-
last: m.last,
|
|
448
|
-
count: m.count,
|
|
449
|
-
offsets: m.offsets,
|
|
450
|
-
lengths: m.lengths,
|
|
451
|
-
scrollOffset: scrollOffset.value,
|
|
452
|
-
viewportLength: viewportLength.value,
|
|
453
|
-
data: p.data,
|
|
454
|
-
getItem: p.getItem,
|
|
455
|
-
keyExtractor: p.keyExtractor,
|
|
456
|
-
pairs,
|
|
457
|
-
hasInteracted,
|
|
458
|
-
});
|
|
459
|
-
const diff = diffViewable(lastViewable, map, tokens);
|
|
460
|
-
if (!diff.hasChanged)
|
|
461
|
-
return;
|
|
462
|
-
const commitAndFire = () => {
|
|
463
|
-
lastViewable = map;
|
|
464
|
-
dlog(`Vue VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
|
|
465
|
-
`(window [${m.first}, ${m.last}])`);
|
|
466
|
-
const info = {
|
|
467
|
-
viewableItems: tokens,
|
|
468
|
-
changed: diff.changed,
|
|
469
|
-
};
|
|
470
|
-
for (const pair of pairs)
|
|
471
|
-
pair.onViewableItemsChanged(info);
|
|
472
|
-
};
|
|
473
|
-
const minimumViewTime = maxMinimumViewTime(pairs);
|
|
474
|
-
if (viewableTimer !== null) {
|
|
475
|
-
clearTimeout(viewableTimer);
|
|
476
|
-
viewableTimer = null;
|
|
477
|
-
}
|
|
478
|
-
if (minimumViewTime > EMPTY_OFFSET) {
|
|
479
|
-
dlog(`Vue VirtualizedList viewability debounce ${minimumViewTime}ms (window [${m.first}, ${m.last}])`);
|
|
480
|
-
viewableTimer = setTimeout(() => {
|
|
481
|
-
viewableTimer = null;
|
|
482
|
-
commitAndFire();
|
|
483
|
-
}, minimumViewTime);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
commitAndFire();
|
|
487
|
-
}, { flush: 'post' });
|
|
488
|
-
// initialScrollIndex: once the first viewport is known, jump to that index a single time.
|
|
489
|
-
watch(() => [narrowed.value, viewportLength.value, metrics.value], () => {
|
|
490
|
-
const p = narrowed.value;
|
|
491
|
-
if (p.initialScrollIndex === undefined || appliedInitialScroll)
|
|
492
|
-
return;
|
|
493
|
-
if (viewportLength.value <= EMPTY_OFFSET || metrics.value.count === FIRST_INDEX)
|
|
494
|
-
return;
|
|
495
|
-
appliedInitialScroll = true;
|
|
496
|
-
// The initial jump is instant (RN does not animate initialScrollIndex).
|
|
497
|
-
scrollToPixel(offsetForIndexLocal(p.initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET), false);
|
|
498
|
-
}, { flush: 'post' });
|
|
499
|
-
// maintainVisibleContentPosition JS anchor adjustment: track the key of the item at
|
|
500
|
-
// minIndexForVisible; when a prepend moves it down, add the inserted extent in the leading
|
|
501
|
-
// SPACER (the off-window items native MVCP cannot see) to scrollOffset so the anchored item
|
|
502
|
-
// does not jump (RN getDerivedStateFromProps). Runs post-flush so the correction lands fast.
|
|
503
|
-
watch(() => [narrowed.value, metrics.value, scrollOffset.value], () => {
|
|
504
|
-
const p = narrowed.value;
|
|
505
|
-
const m = metrics.value;
|
|
506
|
-
if (p.maintainVisibleContentPosition === undefined || m.count === FIRST_INDEX) {
|
|
507
|
-
firstVisibleKey = null;
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
const minIndexForVisible = p.maintainVisibleContentPosition.minIndexForVisible;
|
|
511
|
-
const newFirstVisibleKey = m.count > minIndexForVisible ? keyFor(minIndexForVisible) : null;
|
|
512
|
-
const prevKey = firstVisibleKey;
|
|
513
|
-
if (prevKey !== null && newFirstVisibleKey !== null && prevKey !== newFirstVisibleKey) {
|
|
514
|
-
let anchorIndex = -1;
|
|
515
|
-
for (let index = minIndexForVisible; index < m.count; index += 1) {
|
|
516
|
-
if (keyFor(index) === prevKey) {
|
|
517
|
-
anchorIndex = index;
|
|
518
|
-
break;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
if (anchorIndex > minIndexForVisible) {
|
|
522
|
-
const spacerEnd = Math.min(anchorIndex, committedWindow.first);
|
|
523
|
-
const insertedExtent = spacerEnd > minIndexForVisible
|
|
524
|
-
? m.offsets[spacerEnd] - m.offsets[minIndexForVisible]
|
|
525
|
-
: EMPTY_OFFSET;
|
|
526
|
-
if (insertedExtent > EMPTY_OFFSET) {
|
|
527
|
-
const autoThreshold = p.maintainVisibleContentPosition.autoscrollToTopThreshold;
|
|
528
|
-
const anchoredNearTop = autoThreshold !== undefined && scrollOffset.value <= autoThreshold;
|
|
529
|
-
if (anchoredNearTop) {
|
|
530
|
-
dlog(`Vue VirtualizedList MVCP autoscroll-to-top (offset=${scrollOffset.value} <= ${autoThreshold})`);
|
|
531
|
-
scrollToPixel(EMPTY_OFFSET, true);
|
|
532
|
-
}
|
|
533
|
-
else {
|
|
534
|
-
dlog(`Vue VirtualizedList MVCP adjust +${insertedExtent}px ` +
|
|
535
|
-
`(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
|
|
536
|
-
scrollToPixel(scrollOffset.value + insertedExtent, false);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
firstVisibleKey = newFirstVisibleKey;
|
|
419
|
+
// ---- after-commit pass (one post-flush watcher, the Vue twin of the layout effect) ----
|
|
420
|
+
// Runs the deferred effects (batch fill, edge-reached, viewability, initial-scroll, MVCP)
|
|
421
|
+
// whenever the windowing signature changes — the same dedup key every adapter shares.
|
|
422
|
+
watch(commitSignature, () => {
|
|
423
|
+
const inputs = buildInputs();
|
|
424
|
+
const result = reduceList(listState, { kind: 'commit' }, inputs);
|
|
425
|
+
runEffects(result.effects, inputs);
|
|
542
426
|
}, { flush: 'post' });
|
|
543
427
|
onBeforeUnmount(() => {
|
|
544
428
|
if (viewableTimer !== null)
|
|
@@ -554,7 +438,7 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
|
|
|
554
438
|
if (p.renderItem === undefined)
|
|
555
439
|
dlog('Vue VirtualizedList: no #item slot provided — cells render empty');
|
|
556
440
|
dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
|
|
557
|
-
`(offset=${scrollOffset
|
|
441
|
+
`(offset=${listState.scrollOffset}, viewport=${listState.viewportLength}, rendered=${Math.max(0, m.last - m.first + 1)})`);
|
|
558
442
|
const children = [];
|
|
559
443
|
const stickySet = p.stickyHeaderIndices !== undefined ? new Set(p.stickyHeaderIndices) : undefined;
|
|
560
444
|
const header = resolveElement(p.listHeaderComponent);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// when the consumer listens (read off the instance vnode props), so the inner VirtualizedList keeps
|
|
17
17
|
// building RefreshControl / computing edge-reached strictly on demand.
|
|
18
18
|
import { defineComponent, getCurrentInstance, h, shallowRef, } from '@vue/runtime-core';
|
|
19
|
-
import { flattenSections, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
|
|
19
|
+
import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
|
|
20
20
|
import { Platform, dlog, } from '@symbiote-native/engine';
|
|
21
21
|
import { VirtualizedList } from '../virtualized-list/index.js';
|
|
22
22
|
import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
|
|
@@ -88,9 +88,7 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
|
|
|
88
88
|
const { entries, headerIndices: indices } = flattenSections(sections, slots.sectionSeparator !== undefined);
|
|
89
89
|
headerIndices = indices;
|
|
90
90
|
// RN sticks section headers by default only on iOS; Android does not unless asked.
|
|
91
|
-
const
|
|
92
|
-
const stickyEnabled = props.stickySectionHeadersEnabled ?? stickyDefault;
|
|
93
|
-
const stickyHeaderIndices = stickyEnabled ? indices : undefined;
|
|
91
|
+
const stickyHeaderIndices = resolveStickySectionHeaders(props.stickySectionHeadersEnabled, indices, Platform.OS);
|
|
94
92
|
dlog(`Vue VirtualizedSectionList: ${sections.length} sections flattened to ${entries.length} entries`);
|
|
95
93
|
// The inner list's #item slot: dispatch each flattened entry to the matching consumer slot,
|
|
96
94
|
// returning its VNode[] (empty when the consumer left that slot off).
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/vue",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "SymbioteNative's Vue adapter — a @vue/runtime-core createRenderer driving real native iOS/Android views through the same engine as the React adapter.",
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "git+https://github.com/OneEyed1366/symbiote-native.git",
|
|
@@ -45,17 +46,17 @@
|
|
|
45
46
|
"@vue/compiler-sfc": "^3.5.13",
|
|
46
47
|
"@vue/runtime-core": "^3.5.13",
|
|
47
48
|
"typescript": "~6.0.0",
|
|
48
|
-
"@symbiote-native/
|
|
49
|
-
"@symbiote-native/
|
|
49
|
+
"@symbiote-native/components": "0.3.0",
|
|
50
|
+
"@symbiote-native/css-parser": "0.2.3"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
|
-
"@symbiote-native/engine": ">=0.1.
|
|
53
|
+
"@symbiote-native/engine": ">=0.1.7",
|
|
53
54
|
"vue": ">=3.5.0",
|
|
54
55
|
"react-native": ">=0.86"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
|
-
"@symbiote-native/engine": "0.1.
|
|
58
|
-
"@symbiote-native/test-utils": "0.1.
|
|
58
|
+
"@symbiote-native/engine": "0.1.7",
|
|
59
|
+
"@symbiote-native/test-utils": "0.1.6"
|
|
59
60
|
},
|
|
60
61
|
"scripts": {
|
|
61
62
|
"typecheck": "tsc --build",
|