@symbiote-native/react 0.2.8 → 0.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/README.md +5 -5
- package/build/bootstrap.js +5 -6
- package/build/components/activity-indicator/index.android.js +5 -7
- package/build/components/activity-indicator/shared.js +1 -1
- package/build/components/button.js +1 -1
- package/build/components/flat-list/index.js +5 -2
- package/build/components/input-accessory-view/index.js +6 -1
- package/build/components/keyboard-avoiding-view/index.js +32 -12
- package/build/components/modal/index.d.ts +3 -2
- package/build/components/modal/index.js +1 -1
- package/build/components/pressable/index.d.ts +1 -1
- package/build/components/pressable/index.js +1 -1
- package/build/components/scroll-view/index.android.js +16 -8
- package/build/components/scroll-view/index.ios.js +13 -11
- package/build/components/scroll-view/shared.js +58 -9
- package/build/components/scroll-view/sticky-header.d.ts +11 -2
- package/build/components/scroll-view/sticky-header.js +60 -20
- package/build/components/section-list/index.js +4 -6
- package/build/components/text-input/index.js +7 -2
- package/build/components/touchable/index.d.ts +2 -0
- package/build/components/touchable/index.js +130 -28
- package/build/components/virtualized-list/index.js +87 -22
- package/build/components/virtualized-section-list/index.d.ts +5 -0
- package/build/components/virtualized-section-list/index.js +24 -4
- package/build/components.js +2 -2
- package/build/create-portal/index.js +1 -1
- package/build/create-tunnel/index.js +15 -21
- package/build/hooks/use-color-scheme.d.ts +1 -1
- package/build/hooks/use-color-scheme.js +1 -1
- package/build/hooks/use-window-dimensions.d.ts +1 -1
- package/build/hooks/use-window-dimensions.js +1 -1
- package/build/host-config.js +13 -5
- package/build/host-instance.js +5 -6
- package/build/index.d.ts +12 -37
- package/build/index.js +16 -26
- package/build/modules/animated/create-animated-component.js +27 -66
- package/build/modules/animated/index.d.ts +1 -1
- package/build/modules/animated/index.js +1 -1
- package/build/modules/status-bar/index.ios.js +1 -1
- package/build/render.js +30 -2
- package/metro-css-parser.cjs +7 -12
- package/package.json +5 -5
- package/build/modules/action-sheet-ios.d.ts +0 -2
- package/build/modules/action-sheet-ios.js +0 -4
- package/build/modules/alert.d.ts +0 -2
- package/build/modules/alert.js +0 -5
- package/build/modules/app-state.d.ts +0 -2
- package/build/modules/app-state.js +0 -3
- package/build/modules/appearance.d.ts +0 -2
- package/build/modules/appearance.js +0 -3
- package/build/modules/back-handler.d.ts +0 -2
- package/build/modules/back-handler.js +0 -3
- package/build/modules/dimensions.d.ts +0 -2
- package/build/modules/dimensions.js +0 -3
- package/build/modules/i18n-manager.d.ts +0 -2
- package/build/modules/i18n-manager.js +0 -4
- package/build/modules/keyboard.d.ts +0 -2
- package/build/modules/keyboard.js +0 -3
- package/build/modules/layout-animation.d.ts +0 -2
- package/build/modules/layout-animation.js +0 -3
- package/build/modules/linking.d.ts +0 -2
- package/build/modules/linking.js +0 -4
- package/build/modules/permissions-android.d.ts +0 -2
- package/build/modules/permissions-android.js +0 -3
- package/build/modules/settings.d.ts +0 -1
- package/build/modules/settings.js +0 -4
- package/build/modules/share.d.ts +0 -2
- package/build/modules/share.js +0 -4
- package/build/modules/text-input-state.d.ts +0 -1
- package/build/modules/text-input-state.js +0 -3
- package/build/modules/toast-android.d.ts +0 -1
- package/build/modules/toast-android.js +0 -4
- package/build/modules/vibration.d.ts +0 -1
- package/build/modules/vibration.js +0 -4
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// Sticky headers: the JS layer RN implements in ScrollView.js / ScrollViewStickyHeader.js.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
// file holds the React component shell, the layout state, and the child-wrapping.
|
|
12
|
-
import { Children, createElement, isValidElement, useCallback, useEffect, useReducer, useRef, useState, } from 'react';
|
|
3
|
+
// RN does stickiness purely in JS: ScrollView.js (~line 1690) wraps each flagged child in a
|
|
4
|
+
// ScrollViewStickyHeader, fed by a single `_scrollAnimatedValue` that Animated.event drives
|
|
5
|
+
// from `onScroll` (~line 1095). The native Fabric scroll view does not honor the index array,
|
|
6
|
+
// so forwarding `stickyHeaderIndices` to native would be a silent no-op - we replicate the JS
|
|
7
|
+
// layer instead. The interpolation mirrors ScrollViewStickyHeader.js and now lives,
|
|
8
|
+
// framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this file
|
|
9
|
+
// holds only the React component shell, layout state, and child-wrapping.
|
|
10
|
+
import { Children, createElement, isValidElement, useCallback, useEffect, useImperativeHandle, useReducer, useRef, useState, } from 'react';
|
|
13
11
|
import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
|
|
14
12
|
import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
|
|
15
13
|
import { Animated } from '../../modules/animated/index.js';
|
|
@@ -32,7 +30,24 @@ function firstChild(children) {
|
|
|
32
30
|
// (@symbiote-native/components); this component supplies only the React lifecycle: the ONE folded
|
|
33
31
|
// state cell, the interpolation-node + listener wiring, the debounce setTimeout, and the re-render.
|
|
34
32
|
export const ScrollViewStickyHeader = props => {
|
|
35
|
-
const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children } = props;
|
|
33
|
+
const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children, ref, } = props;
|
|
34
|
+
// The next header's y lives HERE, not in a prop the parent re-renders to update (RN keeps it in
|
|
35
|
+
// the child too, ScrollViewStickyHeader.js:65). The prop only seeds the first render; afterwards
|
|
36
|
+
// the ScrollView pushes updates through setNextHeaderY. That keeps the cost linear - a
|
|
37
|
+
// parent-state round trip re-renders every child on every header's layout, so N headers cost N
|
|
38
|
+
// re-renders of N children. At N=200 it churned enough native animated nodes to trip Fabric's view
|
|
39
|
+
// registry ("Attempt to dequeue already registered component") seconds after mount.
|
|
40
|
+
const [nextHeaderY, setNextHeaderY] = useState(nextHeaderLayoutY);
|
|
41
|
+
useImperativeHandle(ref, () => ({ setNextHeaderY }), []);
|
|
42
|
+
// The imperative push is a fast path, not the source of truth. When the parent DOES re-render
|
|
43
|
+
// with a different value the prop wins, because the successor may be gone: a VirtualizedList
|
|
44
|
+
// unmounts headers as they scroll out, and a value pushed in earlier would otherwise stick
|
|
45
|
+
// around as a collision point that no longer exists.
|
|
46
|
+
const lastNextHeaderProp = useRef(nextHeaderLayoutY);
|
|
47
|
+
if (lastNextHeaderProp.current !== nextHeaderLayoutY) {
|
|
48
|
+
lastNextHeaderProp.current = nextHeaderLayoutY;
|
|
49
|
+
setNextHeaderY(nextHeaderLayoutY);
|
|
50
|
+
}
|
|
36
51
|
// The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
|
|
37
52
|
// mutated in place by reduceSticky. Lazily created once.
|
|
38
53
|
const stateRef = useRef(null);
|
|
@@ -41,7 +56,10 @@ export const ScrollViewStickyHeader = props => {
|
|
|
41
56
|
// The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
|
|
42
57
|
// rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
|
|
43
58
|
// UI thread: the smooth pin.
|
|
44
|
-
const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
|
|
59
|
+
const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
|
|
60
|
+
inputRange: [-1, 0],
|
|
61
|
+
outputRange: [0, 0],
|
|
62
|
+
}));
|
|
45
63
|
const debounceTimer = useRef(null);
|
|
46
64
|
// The current interpolation node + its settled-value listener id, held so the next rebuild can
|
|
47
65
|
// detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
|
|
@@ -51,9 +69,14 @@ export const ScrollViewStickyHeader = props => {
|
|
|
51
69
|
os: Platform.OS,
|
|
52
70
|
inverted,
|
|
53
71
|
scrollViewHeight,
|
|
54
|
-
nextHeaderLayoutY,
|
|
72
|
+
nextHeaderLayoutY: nextHeaderY,
|
|
55
73
|
});
|
|
56
|
-
inputsRef.current = {
|
|
74
|
+
inputsRef.current = {
|
|
75
|
+
os: Platform.OS,
|
|
76
|
+
inverted,
|
|
77
|
+
scrollViewHeight,
|
|
78
|
+
nextHeaderLayoutY: nextHeaderY,
|
|
79
|
+
};
|
|
57
80
|
// dispatch reaches through a ref because the effect executors dispatch follow-up actions
|
|
58
81
|
// (the listener -> animated-tick, the debounce timer -> debounce-fired).
|
|
59
82
|
const dispatchRef = useRef(() => { });
|
|
@@ -87,7 +110,10 @@ export const ScrollViewStickyHeader = props => {
|
|
|
87
110
|
clearTimeout(debounceTimer.current);
|
|
88
111
|
debounceTimer.current = setTimeout(() => {
|
|
89
112
|
debounceTimer.current = null;
|
|
90
|
-
dispatchRef.current({
|
|
113
|
+
dispatchRef.current({
|
|
114
|
+
kind: 'debounce-fired',
|
|
115
|
+
value: effect.value,
|
|
116
|
+
});
|
|
91
117
|
}, effect.delay);
|
|
92
118
|
break;
|
|
93
119
|
}
|
|
@@ -112,7 +138,7 @@ export const ScrollViewStickyHeader = props => {
|
|
|
112
138
|
// dispatches 'layout' itself); also does the initial identity build on mount.
|
|
113
139
|
useEffect(() => {
|
|
114
140
|
dispatchRef.current({ kind: 'inputs-changed' });
|
|
115
|
-
}, [inverted, scrollViewHeight,
|
|
141
|
+
}, [inverted, scrollViewHeight, nextHeaderY, scrollAnimatedValue]);
|
|
116
142
|
// Detach the listener + clear the debounce on unmount.
|
|
117
143
|
useEffect(() => () => {
|
|
118
144
|
const previous = interpolationRef.current;
|
|
@@ -125,7 +151,11 @@ export const ScrollViewStickyHeader = props => {
|
|
|
125
151
|
const y = readLayoutNumber(event, 'y');
|
|
126
152
|
const height = readLayoutNumber(event, 'height');
|
|
127
153
|
// Keep the previous value when a field is absent (RN sets state only on a defined read).
|
|
128
|
-
dispatch({
|
|
154
|
+
dispatch({
|
|
155
|
+
kind: 'layout',
|
|
156
|
+
y: y ?? state.layoutY,
|
|
157
|
+
height: height ?? state.layoutHeight,
|
|
158
|
+
});
|
|
129
159
|
props.onLayout(event);
|
|
130
160
|
const child = firstChild(children);
|
|
131
161
|
const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
|
|
@@ -133,12 +163,17 @@ export const ScrollViewStickyHeader = props => {
|
|
|
133
163
|
};
|
|
134
164
|
// The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
|
|
135
165
|
// `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
|
|
136
|
-
const passthroughAnimatedPropExplicitValues = state.translateY !== null
|
|
166
|
+
const passthroughAnimatedPropExplicitValues = state.translateY !== null
|
|
167
|
+
? { style: { transform: [{ translateY: state.translateY }] } }
|
|
168
|
+
: null;
|
|
137
169
|
// collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
|
|
138
170
|
// OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
|
|
139
171
|
// transform passes with no cast.
|
|
140
172
|
return createElement(Animated.View, {
|
|
141
|
-
style: {
|
|
173
|
+
style: {
|
|
174
|
+
transform: [{ translateY: animatedTranslateY }],
|
|
175
|
+
zIndex: STICKY_HEADER_Z_INDEX,
|
|
176
|
+
},
|
|
142
177
|
onLayout,
|
|
143
178
|
collapsable: false,
|
|
144
179
|
passthroughAnimatedPropExplicitValues,
|
|
@@ -155,7 +190,8 @@ ScrollViewStickyHeader.displayName = 'ScrollViewStickyHeader';
|
|
|
155
190
|
// header (the collision point past which it scrolls off) by looking up its successor's index in
|
|
156
191
|
// `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY`
|
|
157
192
|
// stays undefined and it sticks indefinitely (correct).
|
|
158
|
-
export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent,
|
|
193
|
+
export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, crossTalk) {
|
|
194
|
+
const { headerLayoutYs, onHeaderLayoutY, onHeaderRef } = crossTalk;
|
|
159
195
|
if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
|
|
160
196
|
return children;
|
|
161
197
|
const Wrapper = StickyHeaderComponent ?? ScrollViewStickyHeader;
|
|
@@ -170,6 +206,10 @@ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedV
|
|
|
170
206
|
dlog(`ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
|
|
171
207
|
return createElement(Wrapper, {
|
|
172
208
|
key: child.key ?? `sticky-${index}`,
|
|
209
|
+
// Registers this header so the ScrollView can push the NEXT header's y straight in
|
|
210
|
+
// (RN ScrollView.js:1109). Without it a measurement would have to travel through parent
|
|
211
|
+
// state, re-rendering every child.
|
|
212
|
+
ref: (handle) => onHeaderRef(index, handle),
|
|
173
213
|
nextHeaderLayoutY,
|
|
174
214
|
// RN _onStickyHeaderLayout: record this header's own y, then push it to the previous
|
|
175
215
|
// header as its nextHeaderLayoutY. We record into the parent map; the lookup above feeds
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// SectionList:
|
|
2
|
-
//
|
|
3
|
-
// (SectionList -> VirtualizedSectionList -> VirtualizedList). All the
|
|
1
|
+
// SectionList: public wrapper over VirtualizedSectionList, mirroring RN's
|
|
2
|
+
// SectionList -> VirtualizedSectionList -> VirtualizedList layering. All
|
|
4
3
|
// section-flattening / windowing / imperative-scroll logic lives in
|
|
5
|
-
// VirtualizedSectionList; this
|
|
6
|
-
// SectionList name
|
|
7
|
-
// here, threading the imperative ref straight through.
|
|
4
|
+
// VirtualizedSectionList; this re-exposes the same surface under the
|
|
5
|
+
// SectionList name, threading the imperative ref straight through.
|
|
8
6
|
import { createElement } from 'react';
|
|
9
7
|
import { VirtualizedSectionList, } from '../virtualized-section-list/index.js';
|
|
10
8
|
export function SectionList(props) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (setTextAndSelection), never a plain prop re-push. See @symbiote-native/components/state/text-input.
|
|
11
11
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
|
|
12
12
|
import { dispatchViewCommand, dlog, } from '@symbiote-native/engine';
|
|
13
|
-
import { blurTextInput, setInputBlurred, setInputFocused } from '
|
|
13
|
+
import { blurTextInput, setInputBlurred, setInputFocused, } from '@symbiote-native/engine';
|
|
14
14
|
import { resolveAccessibilityProps, resolveTextInputProps, renderTextInput, foldText, textFromChange, eventCountFromChange, shouldCommandText, INITIAL_EVENT_COUNT, SELECTION_NONE, } from '@symbiote-native/components';
|
|
15
15
|
import { descriptorToReact } from '../../descriptor-to-react/index.js';
|
|
16
16
|
export const TextInput = forwardRef((rawProps, forwardedRef) => {
|
|
@@ -132,7 +132,12 @@ export const TextInput = forwardRef((rawProps, forwardedRef) => {
|
|
|
132
132
|
const node = ref.current;
|
|
133
133
|
if (node === null)
|
|
134
134
|
return;
|
|
135
|
-
dispatchViewCommand(node, 'setTextAndSelection', [
|
|
135
|
+
dispatchViewCommand(node, 'setTextAndSelection', [
|
|
136
|
+
mostRecentEventCount,
|
|
137
|
+
'',
|
|
138
|
+
0,
|
|
139
|
+
0,
|
|
140
|
+
]);
|
|
136
141
|
lastNativeText.current = '';
|
|
137
142
|
},
|
|
138
143
|
isFocused: () => focused.current,
|
|
@@ -13,6 +13,8 @@ export declare const TouchableOpacity: FC<ITouchableOpacityProps>;
|
|
|
13
13
|
export interface ITouchableHighlightProps extends ITouchableBaseProps {
|
|
14
14
|
activeOpacity?: number;
|
|
15
15
|
underlayColor?: string;
|
|
16
|
+
onShowUnderlay?: () => void;
|
|
17
|
+
onHideUnderlay?: () => void;
|
|
16
18
|
}
|
|
17
19
|
export declare const TouchableHighlight: FC<ITouchableHighlightProps>;
|
|
18
20
|
export type ITouchableWithoutFeedbackProps = ITouchableBaseProps;
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
// The Touchable* family, all built on Pressable.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// TouchableOpacity:
|
|
6
|
-
//
|
|
7
|
-
// TouchableHighlight:
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
// The Touchable* family, all built on Pressable. Ported against RN's own sources
|
|
2
|
+
// (.vendors/react-native/.../Components/Touchable/*.js) after the 2026-08-19 audit found ten
|
|
3
|
+
// divergences; the shared halves live in @symbiote-native/components and React owns only the
|
|
4
|
+
// lifecycle — refs, effects, the real timers, the Animated wiring, and the child clone:
|
|
5
|
+
// TouchableOpacity: an Animated.Value fades toward activeOpacity on press-in and back to the
|
|
6
|
+
// style's own resting opacity on press-out, driven from onPressIn/onPressOut.
|
|
7
|
+
// TouchableHighlight: the underlay machine drives a `shown` flag (NOT `pressed` — RN holds the
|
|
8
|
+
// underlay for delayPressOut past the tap); the container takes the underlay color and the
|
|
9
|
+
// child takes the lowered opacity, RN's split.
|
|
10
|
+
// TouchableWithoutFeedback: the same press-timing machine with the visual half left empty —
|
|
11
|
+
// "without feedback" means no VISUAL, not no timing.
|
|
12
|
+
import { Children, cloneElement, createElement, isValidElement, useEffect, useRef, useState, } from 'react';
|
|
13
|
+
import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, hasTouchablePressHandler, resolveHighlightExtraStyles, restingOpacityFromStyle, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
|
|
12
14
|
import { Pressable } from '../pressable/index.js';
|
|
13
15
|
import { Animated } from '../../modules/animated/index.js';
|
|
14
|
-
// The real setTimeout the shared
|
|
15
|
-
//
|
|
16
|
+
// The real setTimeout the shared machines schedule on (core/components has no timer globals).
|
|
17
|
+
// Returns a canceller so an early release flushes the timer.
|
|
16
18
|
function scheduleTimeout(callback, ms) {
|
|
17
19
|
const id = setTimeout(callback, ms);
|
|
18
20
|
return () => clearTimeout(id);
|
|
@@ -22,42 +24,142 @@ export const TouchableOpacity = props => {
|
|
|
22
24
|
// className is pulled out here, like style, and applied to the inner Animated.View below —
|
|
23
25
|
// left in ...rest it would land on the outer Pressable instead, which is not where `style`
|
|
24
26
|
// (the opacity-fade node) goes.
|
|
25
|
-
className, children, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0,
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
27
|
+
className, children, disabled, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0,
|
|
28
|
+
// RN's Touchables override Pressability's 130ms floor with 0 (TouchableOpacity.js:195); the
|
|
29
|
+
// active visual is held by the fade's own duration instead.
|
|
30
|
+
minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS, ...rest } = props;
|
|
31
|
+
// RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style
|
|
32
|
+
// asks for, not at a hard 1 — and must START there, or the first paint jumps to fully opaque.
|
|
33
|
+
const restingOpacity = restingOpacityFromStyle(style);
|
|
34
|
+
// One Animated.Value per mount. The Animated.View leaf commits its current value every frame,
|
|
35
|
+
// so timing it animates the real view.
|
|
36
|
+
const opacity = useRef(new Animated.Value(restingOpacity)).current;
|
|
29
37
|
// The shared press-scheduling cell (delayPressIn timer + activation clock), held across renders.
|
|
30
38
|
const runtime = useRef(createTouchableFeedbackRuntime()).current;
|
|
31
39
|
function setOpacityTo(toValue, duration) {
|
|
40
|
+
// useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable
|
|
41
|
+
// and the fade then survives a busy JS thread, which is the whole point of the feedback.
|
|
32
42
|
Animated.timing(opacity, {
|
|
33
43
|
toValue,
|
|
34
44
|
duration,
|
|
35
45
|
easing: Animated.Easing.inOut(Animated.Easing.quad),
|
|
36
|
-
useNativeDriver:
|
|
46
|
+
useNativeDriver: true,
|
|
37
47
|
}).start();
|
|
38
48
|
}
|
|
49
|
+
// RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
|
|
50
|
+
// so a Touchable disabled mid-press does not stay stuck at its active opacity.
|
|
51
|
+
const hasRendered = useRef(false);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
// RN runs this on UPDATE only. Firing at mount would animate over the value the
|
|
54
|
+
// Animated.Value was just constructed with.
|
|
55
|
+
if (!hasRendered.current) {
|
|
56
|
+
hasRendered.current = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- setOpacityTo is re-made per render
|
|
61
|
+
}, [disabled, restingOpacity]);
|
|
62
|
+
// RN's componentWillUnmount: stop the fade so a teardown mid-animation leaves no driver running
|
|
63
|
+
// against a node that is gone.
|
|
64
|
+
useEffect(() => () => {
|
|
65
|
+
opacity.resetAnimation();
|
|
66
|
+
}, [opacity]);
|
|
39
67
|
// The shared machine owns the delayPressIn/minPressDuration scheduling; the adapter supplies only
|
|
40
68
|
// the native seam — the Animated opacity fade + the framework emit — as activate/deactivate.
|
|
41
|
-
const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
|
|
69
|
+
const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
|
|
70
|
+
delayPressIn,
|
|
71
|
+
delayPressOut,
|
|
72
|
+
minPressDuration,
|
|
73
|
+
schedule: scheduleTimeout,
|
|
74
|
+
now: Date.now,
|
|
75
|
+
}, runtime, {
|
|
42
76
|
activate(event) {
|
|
43
|
-
setOpacityTo(activeOpacity,
|
|
77
|
+
setOpacityTo(activeOpacity, OPACITY_ACTIVE_GRANT_DURATION_MS);
|
|
44
78
|
onPressIn?.(event);
|
|
45
79
|
},
|
|
46
80
|
deactivate(event) {
|
|
47
|
-
setOpacityTo(
|
|
81
|
+
setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
|
|
48
82
|
onPressOut?.(event);
|
|
49
83
|
},
|
|
50
84
|
});
|
|
51
|
-
return createElement(Pressable, { ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut }, createElement(Animated.View, { style: [style, { opacity }], className }, children));
|
|
85
|
+
return createElement(Pressable, { ...rest, disabled, onPressIn: handlePressIn, onPressOut: handlePressOut }, createElement(Animated.View, { style: [style, { opacity }], className }, children));
|
|
52
86
|
};
|
|
87
|
+
// RN's render: the lowered opacity is cloned onto the CHILD while the container keeps an opaque
|
|
88
|
+
// underlay (TouchableHighlight.js). Fold both onto the container instead and the opacity fades the
|
|
89
|
+
// very underlay it is meant to reveal — `underlayColor: 'black'` paints grey. React is the one
|
|
90
|
+
// adapter that can reach the child without a wrapper view perturbing the flex chain.
|
|
91
|
+
// A non-element child (text, a fragment, nothing at all) has no style prop to compose onto, so it
|
|
92
|
+
// passes through untouched rather than throwing the way RN's Children.only would.
|
|
93
|
+
function withChildStyle(children, extra) {
|
|
94
|
+
const child = Children.count(children) === 1 ? children : undefined;
|
|
95
|
+
if (!isValidElement(child))
|
|
96
|
+
return children;
|
|
97
|
+
return cloneElement(child, { style: [child.props.style, extra] });
|
|
98
|
+
}
|
|
53
99
|
export const TouchableHighlight = props => {
|
|
54
|
-
const { activeOpacity
|
|
55
|
-
|
|
56
|
-
|
|
100
|
+
const { activeOpacity, underlayColor, style, children, onPress, onPressIn, onPressOut, onShowUnderlay, onHideUnderlay, delayPressOut = 0, ...rest } = props;
|
|
101
|
+
// `shown` is NOT `pressed`: RN holds the underlay for delayPressOut past the tap so a press too
|
|
102
|
+
// fast to see still flashes, and the pressed flag is already false by then.
|
|
103
|
+
const [shown, setShown] = useState(false);
|
|
104
|
+
const runtime = useRef(createHighlightUnderlayRuntime()).current;
|
|
105
|
+
// RN's _hasPressHandler gate: no press handler, no underlay. onLongPress is only READ here —
|
|
106
|
+
// it stays in `rest` for Pressable to fire directly.
|
|
107
|
+
const hasPressHandler = hasTouchablePressHandler({
|
|
108
|
+
onPress,
|
|
109
|
+
onPressIn,
|
|
110
|
+
onPressOut,
|
|
111
|
+
onLongPress: rest.onLongPress,
|
|
112
|
+
});
|
|
113
|
+
const underlay = createHighlightUnderlayHandlers({ delayPressOut, hasPressHandler, schedule: scheduleTimeout }, runtime, { setShown, onShowUnderlay, onHideUnderlay });
|
|
114
|
+
const extraStyles = resolveHighlightExtraStyles({
|
|
115
|
+
shown,
|
|
116
|
+
hasPressHandler,
|
|
117
|
+
underlayColor,
|
|
118
|
+
activeOpacity,
|
|
119
|
+
});
|
|
120
|
+
// Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
|
|
121
|
+
function handlePressIn(event) {
|
|
122
|
+
underlay.handlePressIn(event);
|
|
123
|
+
onPressIn?.(event);
|
|
124
|
+
}
|
|
125
|
+
function handlePress(event) {
|
|
126
|
+
underlay.handlePress(event);
|
|
127
|
+
onPress?.(event);
|
|
57
128
|
}
|
|
58
|
-
|
|
129
|
+
function handlePressOut(event) {
|
|
130
|
+
underlay.handlePressOut(event);
|
|
131
|
+
onPressOut?.(event);
|
|
132
|
+
}
|
|
133
|
+
return createElement(Pressable, {
|
|
134
|
+
...rest,
|
|
135
|
+
style: extraStyles === undefined ? style : [style, extraStyles.underlay],
|
|
136
|
+
onPress: handlePress,
|
|
137
|
+
onPressIn: handlePressIn,
|
|
138
|
+
onPressOut: handlePressOut,
|
|
139
|
+
}, extraStyles === undefined
|
|
140
|
+
? children
|
|
141
|
+
: withChildStyle(children, extraStyles.child));
|
|
59
142
|
};
|
|
60
143
|
export const TouchableWithoutFeedback = props => {
|
|
61
|
-
const { children, ...rest } = props;
|
|
62
|
-
|
|
144
|
+
const { children, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0, minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS, ...rest } = props;
|
|
145
|
+
// RN's TouchableWithoutFeedback builds a FULL Pressability config with delayPressIn /
|
|
146
|
+
// delayPressOut / minPressDuration: 0 — the same shared machine TouchableOpacity uses, with the
|
|
147
|
+
// visual half left empty. Spreading the delay props onto Pressable instead did nothing: it does
|
|
148
|
+
// not read them, and they leaked to the host as unknown props.
|
|
149
|
+
const runtime = useRef(createTouchableFeedbackRuntime()).current;
|
|
150
|
+
const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
|
|
151
|
+
delayPressIn,
|
|
152
|
+
delayPressOut,
|
|
153
|
+
minPressDuration,
|
|
154
|
+
schedule: scheduleTimeout,
|
|
155
|
+
now: Date.now,
|
|
156
|
+
}, runtime, {
|
|
157
|
+
activate(event) {
|
|
158
|
+
onPressIn?.(event);
|
|
159
|
+
},
|
|
160
|
+
deactivate(event) {
|
|
161
|
+
onPressOut?.(event);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
return createElement(Pressable, { ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut }, children);
|
|
63
165
|
};
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
// scrollTo command via its handle ref, animated by default. The `contentOffset` prop is only a
|
|
19
19
|
// fallback for the pre-mount window (handle not yet attached).
|
|
20
20
|
import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
|
|
21
|
-
import { dlog } from '@symbiote-native/engine';
|
|
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';
|
|
23
|
-
import { ScrollView } from '../scroll-view';
|
|
21
|
+
import { dlog, } from '@symbiote-native/engine';
|
|
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, readLayoutOffset, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
23
|
+
import { ScrollView, } from '../scroll-view';
|
|
24
24
|
import { RefreshControl } from '../refresh-control/index.js';
|
|
25
25
|
function resolveElement(component) {
|
|
26
26
|
if (component === undefined)
|
|
@@ -88,7 +88,12 @@ export function VirtualizedList(props) {
|
|
|
88
88
|
onScrollToIndexFailed,
|
|
89
89
|
viewabilityPairs,
|
|
90
90
|
});
|
|
91
|
-
handlersRef.current = {
|
|
91
|
+
handlersRef.current = {
|
|
92
|
+
onEndReached,
|
|
93
|
+
onStartReached,
|
|
94
|
+
onScrollToIndexFailed,
|
|
95
|
+
viewabilityPairs,
|
|
96
|
+
};
|
|
92
97
|
// The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
|
|
93
98
|
const stateRef = useRef(null);
|
|
94
99
|
const state = (stateRef.current ??= createInitialListState());
|
|
@@ -109,7 +114,9 @@ export function VirtualizedList(props) {
|
|
|
109
114
|
const scrollToPixel = useCallback((offset, animated) => {
|
|
110
115
|
const clamped = Math.max(EMPTY_OFFSET, offset);
|
|
111
116
|
const isHorizontal = inputsRef.current.horizontal;
|
|
112
|
-
const target = isHorizontal
|
|
117
|
+
const target = isHorizontal
|
|
118
|
+
? { x: clamped, y: EMPTY_OFFSET }
|
|
119
|
+
: { x: EMPTY_OFFSET, y: clamped };
|
|
113
120
|
if (scrollViewRef.current !== null) {
|
|
114
121
|
dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
|
|
115
122
|
scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
|
|
@@ -129,10 +136,14 @@ export function VirtualizedList(props) {
|
|
|
129
136
|
scrollToPixel(effect.offset, effect.animated);
|
|
130
137
|
break;
|
|
131
138
|
case 'fire-end-reached':
|
|
132
|
-
handlers.onEndReached?.({
|
|
139
|
+
handlers.onEndReached?.({
|
|
140
|
+
distanceFromEnd: effect.distanceFromEnd,
|
|
141
|
+
});
|
|
133
142
|
break;
|
|
134
143
|
case 'fire-start-reached':
|
|
135
|
-
handlers.onStartReached?.({
|
|
144
|
+
handlers.onStartReached?.({
|
|
145
|
+
distanceFromStart: effect.distanceFromStart,
|
|
146
|
+
});
|
|
136
147
|
break;
|
|
137
148
|
case 'fire-scroll-to-index-failed':
|
|
138
149
|
handlers.onScrollToIndexFailed?.({
|
|
@@ -217,8 +228,9 @@ export function VirtualizedList(props) {
|
|
|
217
228
|
const length = readLayoutLength(event, horizontal);
|
|
218
229
|
if (length === undefined)
|
|
219
230
|
return;
|
|
220
|
-
|
|
221
|
-
|
|
231
|
+
const offset = readLayoutOffset(event, horizontal);
|
|
232
|
+
dlog(`VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
|
|
233
|
+
dispatch({ kind: 'measure', index, length, offset });
|
|
222
234
|
}, [horizontal, dispatch]);
|
|
223
235
|
// Merge an override onto the separator at a given gap and request a re-render. A gap index
|
|
224
236
|
// outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
|
|
@@ -313,7 +325,9 @@ export function VirtualizedList(props) {
|
|
|
313
325
|
// extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
|
|
314
326
|
void extraData;
|
|
315
327
|
const children = [];
|
|
316
|
-
const stickySet = stickyHeaderIndices !== undefined
|
|
328
|
+
const stickySet = stickyHeaderIndices !== undefined
|
|
329
|
+
? new Set(stickyHeaderIndices)
|
|
330
|
+
: undefined;
|
|
317
331
|
let renderedStickyIndices = [];
|
|
318
332
|
const header = resolveElement(ListHeaderComponent);
|
|
319
333
|
if (header !== undefined) {
|
|
@@ -337,13 +351,45 @@ export function VirtualizedList(props) {
|
|
|
337
351
|
keyFor: keyForIndex,
|
|
338
352
|
stickyIndices: stickySet,
|
|
339
353
|
hasHeader: header !== undefined,
|
|
340
|
-
hasSeparators: ItemSeparatorComponent !== undefined,
|
|
341
354
|
});
|
|
342
355
|
renderedStickyIndices = plan.stickyChildPositions;
|
|
343
356
|
if (plan.leadingExtent > EMPTY_OFFSET) {
|
|
344
357
|
children.push(createElement('symbiote-view', {
|
|
345
358
|
key: 'spacer-leading',
|
|
346
|
-
style: horizontal
|
|
359
|
+
style: horizontal
|
|
360
|
+
? { width: plan.leadingExtent }
|
|
361
|
+
: { height: plan.leadingExtent },
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
// The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
|
|
365
|
+
// below the window, kept alive outside [first,last] so it never gets destroyed/recreated
|
|
366
|
+
// as the window slides back over its origin index. Rendered exactly like an in-window
|
|
367
|
+
// cell — same measuring wrapper, same key format (so a transition between forced and
|
|
368
|
+
// in-window rendering of the SAME index keeps the same React key and never remounts).
|
|
369
|
+
if (plan.forcedStickyCell !== undefined) {
|
|
370
|
+
const forcedIndex = plan.forcedStickyCell.index;
|
|
371
|
+
const forcedItem = getItem(data, forcedIndex);
|
|
372
|
+
const forcedCell = renderItem({
|
|
373
|
+
item: forcedItem,
|
|
374
|
+
index: forcedIndex,
|
|
375
|
+
separators: makeSeparators(forcedIndex),
|
|
376
|
+
});
|
|
377
|
+
children.push(createElement('symbiote-view', {
|
|
378
|
+
key: `cell-${plan.forcedStickyCell.key}`,
|
|
379
|
+
onLayout: makeCellMeasure(forcedIndex),
|
|
380
|
+
style: inverted
|
|
381
|
+
? horizontal
|
|
382
|
+
? INVERTED_X_STYLE
|
|
383
|
+
: INVERTED_Y_STYLE
|
|
384
|
+
: undefined,
|
|
385
|
+
}, forcedCell));
|
|
386
|
+
}
|
|
387
|
+
if (plan.gapExtent > EMPTY_OFFSET) {
|
|
388
|
+
children.push(createElement('symbiote-view', {
|
|
389
|
+
key: 'spacer-gap',
|
|
390
|
+
style: horizontal
|
|
391
|
+
? { width: plan.gapExtent }
|
|
392
|
+
: { height: plan.gapExtent },
|
|
347
393
|
}));
|
|
348
394
|
}
|
|
349
395
|
for (const planCell of plan.cells) {
|
|
@@ -354,24 +400,42 @@ export function VirtualizedList(props) {
|
|
|
354
400
|
index: planCell.index,
|
|
355
401
|
separators: makeSeparators(planCell.index),
|
|
356
402
|
});
|
|
403
|
+
// RN renders the separator INSIDE the cell's own measuring wrapper
|
|
404
|
+
// (VirtualizedListCellRenderer.js:218-221), and that placement is load-bearing rather than
|
|
405
|
+
// cosmetic. As a SIBLING it is an extra flex child, so the chrome between two cells becomes
|
|
406
|
+
// gap + separator + gap while a spacer collapsing that region replaces it with one gap — the
|
|
407
|
+
// leading spacer then lands every cell below it short by (separator + gap), and the content
|
|
408
|
+
// visibly jumps by that amount each time the window's first index moves. Measured at exactly
|
|
409
|
+
// 17px on device 2026-08-19 (a 1px divider under a 16px container gap); see
|
|
410
|
+
// .claude/rules/list-geometry-feedback-loop.md. Inside the wrapper it is part of the cell's
|
|
411
|
+
// measured length instead, and every cell stays exactly one child.
|
|
412
|
+
const separator =
|
|
413
|
+
// RN gates the separator on the last index of the DATA, not of the WINDOW
|
|
414
|
+
// (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
|
|
415
|
+
// lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
|
|
416
|
+
// would make a cell's own measured height change as the window slides past it. Device-measured
|
|
417
|
+
// 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
|
|
418
|
+
planCell.index < count - 1
|
|
419
|
+
? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
|
|
420
|
+
: undefined;
|
|
357
421
|
// Wrap each cell in a measuring View. When inverted, each cell carries the counter-flip so
|
|
358
422
|
// its content reads upright inside the flipped content container.
|
|
359
423
|
children.push(createElement('symbiote-view', {
|
|
360
424
|
key: `cell-${planCell.key}`,
|
|
361
425
|
onLayout: makeCellMeasure(planCell.index),
|
|
362
|
-
style: inverted
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
children.push(createElement('symbiote-view', { key: `sep-${planCell.key}` }, separator));
|
|
369
|
-
}
|
|
426
|
+
style: inverted
|
|
427
|
+
? horizontal
|
|
428
|
+
? INVERTED_X_STYLE
|
|
429
|
+
: INVERTED_Y_STYLE
|
|
430
|
+
: undefined,
|
|
431
|
+
}, cell, separator));
|
|
370
432
|
}
|
|
371
433
|
if (plan.trailingExtent > EMPTY_OFFSET) {
|
|
372
434
|
children.push(createElement('symbiote-view', {
|
|
373
435
|
key: 'spacer-trailing',
|
|
374
|
-
style: horizontal
|
|
436
|
+
style: horizontal
|
|
437
|
+
? { width: plan.trailingExtent }
|
|
438
|
+
: { height: plan.trailingExtent },
|
|
375
439
|
}));
|
|
376
440
|
}
|
|
377
441
|
}
|
|
@@ -423,7 +487,8 @@ export function VirtualizedList(props) {
|
|
|
423
487
|
if (maintainVisibleContentPosition !== undefined) {
|
|
424
488
|
scrollProps.maintainVisibleContentPosition = {
|
|
425
489
|
...maintainVisibleContentPosition,
|
|
426
|
-
minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
|
|
490
|
+
minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
|
|
491
|
+
(header !== undefined ? 1 : 0),
|
|
427
492
|
};
|
|
428
493
|
}
|
|
429
494
|
// Pull-to-refresh: when onRefresh is set, build a RefreshControl for the ScrollView's
|
|
@@ -22,6 +22,11 @@ export interface IVirtualizedSectionListProps<ItemT> extends IAccessibilityProps
|
|
|
22
22
|
}) => ReactNode;
|
|
23
23
|
SectionSeparatorComponent?: ComponentType<Record<string, never>> | ReactElement;
|
|
24
24
|
keyExtractor?: (item: ItemT, index: number) => string;
|
|
25
|
+
getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
|
|
26
|
+
length: number;
|
|
27
|
+
offset: number;
|
|
28
|
+
index: number;
|
|
29
|
+
};
|
|
25
30
|
stickySectionHeadersEnabled?: boolean;
|
|
26
31
|
extraData?: unknown;
|
|
27
32
|
ItemSeparatorComponent?: ComponentType<ISeparatorProps<ItemT>>;
|