@symbiote-native/react 0.3.0 → 1.0.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 +24 -9
- package/build/bootstrap.js +1 -1
- 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.js +1 -1
- package/build/components/pressable/index.d.ts +2 -1
- package/build/components/pressable/index.js +17 -5
- package/build/components/safe-area-view/index.d.ts +1 -0
- package/build/components/scroll-view/index.android.js +16 -6
- package/build/components/scroll-view/index.ios.js +3 -1
- package/build/components/scroll-view/shared.js +56 -7
- package/build/components/scroll-view/sticky-header.d.ts +11 -2
- package/build/components/scroll-view/sticky-header.js +53 -11
- package/build/components/text-input/index.js +15 -2
- package/build/components/touchable/index.d.ts +2 -0
- package/build/components/touchable/index.js +131 -29
- package/build/components/touchable-native-feedback/index.js +1 -1
- package/build/components/virtualized-list/index.js +64 -24
- package/build/components/virtualized-section-list/index.d.ts +5 -0
- package/build/components/virtualized-section-list/index.js +24 -4
- package/build/components.d.ts +4 -4
- package/build/components.js +19 -17
- package/build/create-portal/index.js +1 -1
- package/build/create-tunnel/index.js +1 -1
- package/build/hooks/use-window-dimensions.js +1 -1
- package/build/host-config.d.ts +2 -2
- package/build/host-config.js +42 -11
- package/build/host-instance.js +1 -1
- package/build/index.d.ts +8 -7
- package/build/index.js +1 -1
- package/build/jsx.d.ts +18 -0
- package/build/jsx.js +1 -0
- package/build/modules/animated/create-animated-component.d.ts +2 -1
- package/build/modules/animated/create-animated-component.js +10 -2
- 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 +16 -8
- package/build/state-style.d.ts +1 -0
- package/build/state-style.js +13 -0
- package/package.json +24 -8
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// layer instead. The interpolation mirrors ScrollViewStickyHeader.js and now lives,
|
|
8
8
|
// framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this file
|
|
9
9
|
// holds only the React component shell, layout state, and child-wrapping.
|
|
10
|
-
import { Children, createElement, isValidElement, useCallback, useEffect, useReducer, useRef, useState, } from 'react';
|
|
10
|
+
import { Children, createElement, isValidElement, useCallback, useEffect, useImperativeHandle, useReducer, useRef, useState, } from 'react';
|
|
11
11
|
import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
|
|
12
12
|
import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
|
|
13
13
|
import { Animated } from '../../modules/animated/index.js';
|
|
@@ -30,7 +30,24 @@ function firstChild(children) {
|
|
|
30
30
|
// (@symbiote-native/components); this component supplies only the React lifecycle: the ONE folded
|
|
31
31
|
// state cell, the interpolation-node + listener wiring, the debounce setTimeout, and the re-render.
|
|
32
32
|
export const ScrollViewStickyHeader = props => {
|
|
33
|
-
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
|
+
}
|
|
34
51
|
// The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
|
|
35
52
|
// mutated in place by reduceSticky. Lazily created once.
|
|
36
53
|
const stateRef = useRef(null);
|
|
@@ -39,7 +56,10 @@ export const ScrollViewStickyHeader = props => {
|
|
|
39
56
|
// The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
|
|
40
57
|
// rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
|
|
41
58
|
// UI thread: the smooth pin.
|
|
42
|
-
const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
|
|
59
|
+
const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
|
|
60
|
+
inputRange: [-1, 0],
|
|
61
|
+
outputRange: [0, 0],
|
|
62
|
+
}));
|
|
43
63
|
const debounceTimer = useRef(null);
|
|
44
64
|
// The current interpolation node + its settled-value listener id, held so the next rebuild can
|
|
45
65
|
// detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
|
|
@@ -49,9 +69,14 @@ export const ScrollViewStickyHeader = props => {
|
|
|
49
69
|
os: Platform.OS,
|
|
50
70
|
inverted,
|
|
51
71
|
scrollViewHeight,
|
|
52
|
-
nextHeaderLayoutY,
|
|
72
|
+
nextHeaderLayoutY: nextHeaderY,
|
|
53
73
|
});
|
|
54
|
-
inputsRef.current = {
|
|
74
|
+
inputsRef.current = {
|
|
75
|
+
os: Platform.OS,
|
|
76
|
+
inverted,
|
|
77
|
+
scrollViewHeight,
|
|
78
|
+
nextHeaderLayoutY: nextHeaderY,
|
|
79
|
+
};
|
|
55
80
|
// dispatch reaches through a ref because the effect executors dispatch follow-up actions
|
|
56
81
|
// (the listener -> animated-tick, the debounce timer -> debounce-fired).
|
|
57
82
|
const dispatchRef = useRef(() => { });
|
|
@@ -85,7 +110,10 @@ export const ScrollViewStickyHeader = props => {
|
|
|
85
110
|
clearTimeout(debounceTimer.current);
|
|
86
111
|
debounceTimer.current = setTimeout(() => {
|
|
87
112
|
debounceTimer.current = null;
|
|
88
|
-
dispatchRef.current({
|
|
113
|
+
dispatchRef.current({
|
|
114
|
+
kind: 'debounce-fired',
|
|
115
|
+
value: effect.value,
|
|
116
|
+
});
|
|
89
117
|
}, effect.delay);
|
|
90
118
|
break;
|
|
91
119
|
}
|
|
@@ -110,7 +138,7 @@ export const ScrollViewStickyHeader = props => {
|
|
|
110
138
|
// dispatches 'layout' itself); also does the initial identity build on mount.
|
|
111
139
|
useEffect(() => {
|
|
112
140
|
dispatchRef.current({ kind: 'inputs-changed' });
|
|
113
|
-
}, [inverted, scrollViewHeight,
|
|
141
|
+
}, [inverted, scrollViewHeight, nextHeaderY, scrollAnimatedValue]);
|
|
114
142
|
// Detach the listener + clear the debounce on unmount.
|
|
115
143
|
useEffect(() => () => {
|
|
116
144
|
const previous = interpolationRef.current;
|
|
@@ -123,7 +151,11 @@ export const ScrollViewStickyHeader = props => {
|
|
|
123
151
|
const y = readLayoutNumber(event, 'y');
|
|
124
152
|
const height = readLayoutNumber(event, 'height');
|
|
125
153
|
// Keep the previous value when a field is absent (RN sets state only on a defined read).
|
|
126
|
-
dispatch({
|
|
154
|
+
dispatch({
|
|
155
|
+
kind: 'layout',
|
|
156
|
+
y: y ?? state.layoutY,
|
|
157
|
+
height: height ?? state.layoutHeight,
|
|
158
|
+
});
|
|
127
159
|
props.onLayout(event);
|
|
128
160
|
const child = firstChild(children);
|
|
129
161
|
const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
|
|
@@ -131,12 +163,17 @@ export const ScrollViewStickyHeader = props => {
|
|
|
131
163
|
};
|
|
132
164
|
// The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
|
|
133
165
|
// `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
|
|
134
|
-
const passthroughAnimatedPropExplicitValues = state.translateY !== null
|
|
166
|
+
const passthroughAnimatedPropExplicitValues = state.translateY !== null
|
|
167
|
+
? { style: { transform: [{ translateY: state.translateY }] } }
|
|
168
|
+
: null;
|
|
135
169
|
// collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
|
|
136
170
|
// OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
|
|
137
171
|
// transform passes with no cast.
|
|
138
172
|
return createElement(Animated.View, {
|
|
139
|
-
style: {
|
|
173
|
+
style: {
|
|
174
|
+
transform: [{ translateY: animatedTranslateY }],
|
|
175
|
+
zIndex: STICKY_HEADER_Z_INDEX,
|
|
176
|
+
},
|
|
140
177
|
onLayout,
|
|
141
178
|
collapsable: false,
|
|
142
179
|
passthroughAnimatedPropExplicitValues,
|
|
@@ -153,7 +190,8 @@ ScrollViewStickyHeader.displayName = 'ScrollViewStickyHeader';
|
|
|
153
190
|
// header (the collision point past which it scrolls off) by looking up its successor's index in
|
|
154
191
|
// `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY`
|
|
155
192
|
// stays undefined and it sticks indefinitely (correct).
|
|
156
|
-
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;
|
|
157
195
|
if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
|
|
158
196
|
return children;
|
|
159
197
|
const Wrapper = StickyHeaderComponent ?? ScrollViewStickyHeader;
|
|
@@ -168,6 +206,10 @@ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedV
|
|
|
168
206
|
dlog(`ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
|
|
169
207
|
return createElement(Wrapper, {
|
|
170
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),
|
|
171
213
|
nextHeaderLayoutY,
|
|
172
214
|
// RN _onStickyHeaderLayout: record this header's own y, then push it to the previous
|
|
173
215
|
// header as its nextHeaderLayoutY. We record into the parent map; the lookup above feeds
|
|
@@ -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 '@symbiote-native/engine';
|
|
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) => {
|
|
@@ -119,6 +119,14 @@ export const TextInput = forwardRef((rawProps, forwardedRef) => {
|
|
|
119
119
|
// setSelection reuse setTextAndSelection (the same stale-safe path as a controlled write),
|
|
120
120
|
// echoing the acknowledged event count so native applies them.
|
|
121
121
|
useImperativeHandle(forwardedRef, () => ({
|
|
122
|
+
// Forwarded from the node so a TextInput ref is not poorer than any other host ref. An
|
|
123
|
+
// `expose`/`useImperativeHandle` that lists only the five TextInput methods CLOSES the
|
|
124
|
+
// node off, and the lowered path loses the opposite four — two different surfaces an app
|
|
125
|
+
// crosses between without touching its ref code. `ITextInputHandle` carries the account.
|
|
126
|
+
measure: (callback) => ref.current?.measure(callback),
|
|
127
|
+
measureInWindow: (callback) => ref.current?.measureInWindow(callback),
|
|
128
|
+
measureLayout: (relativeToNativeNode, onSuccess, onFail) => ref.current?.measureLayout(relativeToNativeNode, onSuccess, onFail),
|
|
129
|
+
setNativeProps: (nativeProps) => ref.current?.setNativeProps(nativeProps),
|
|
122
130
|
focus: () => {
|
|
123
131
|
const node = ref.current;
|
|
124
132
|
if (node !== null)
|
|
@@ -132,7 +140,12 @@ export const TextInput = forwardRef((rawProps, forwardedRef) => {
|
|
|
132
140
|
const node = ref.current;
|
|
133
141
|
if (node === null)
|
|
134
142
|
return;
|
|
135
|
-
dispatchViewCommand(node, 'setTextAndSelection', [
|
|
143
|
+
dispatchViewCommand(node, 'setTextAndSelection', [
|
|
144
|
+
mostRecentEventCount,
|
|
145
|
+
'',
|
|
146
|
+
0,
|
|
147
|
+
0,
|
|
148
|
+
]);
|
|
136
149
|
lastNativeText.current = '';
|
|
137
150
|
},
|
|
138
151
|
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
|
-
|
|
12
|
-
import {
|
|
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';
|
|
14
|
+
import { TouchablePressable as 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
|
};
|
|
@@ -10,7 +10,7 @@ import { createElement } from 'react';
|
|
|
10
10
|
import { dlog } from '@symbiote-native/engine';
|
|
11
11
|
import { backgroundProps, canUseNativeForeground, rippleBackground, selectableBackground, selectableBackgroundBorderless, } from '@symbiote-native/components';
|
|
12
12
|
import { View } from '../../components.js';
|
|
13
|
-
import { Pressable } from '../pressable/index.js';
|
|
13
|
+
import { TouchablePressable as Pressable, } from '../pressable/index.js';
|
|
14
14
|
const TouchableNativeFeedbackImpl = props => {
|
|
15
15
|
const { background, useForeground = false, children, ...rest } = props;
|
|
16
16
|
// RN defaults a missing background to SelectableBackground() so the touchable always has
|
|
@@ -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,14 @@ 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 },
|
|
347
362
|
}));
|
|
348
363
|
}
|
|
349
364
|
// The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
|
|
@@ -362,13 +377,19 @@ export function VirtualizedList(props) {
|
|
|
362
377
|
children.push(createElement('symbiote-view', {
|
|
363
378
|
key: `cell-${plan.forcedStickyCell.key}`,
|
|
364
379
|
onLayout: makeCellMeasure(forcedIndex),
|
|
365
|
-
style: inverted
|
|
380
|
+
style: inverted
|
|
381
|
+
? horizontal
|
|
382
|
+
? INVERTED_X_STYLE
|
|
383
|
+
: INVERTED_Y_STYLE
|
|
384
|
+
: undefined,
|
|
366
385
|
}, forcedCell));
|
|
367
386
|
}
|
|
368
387
|
if (plan.gapExtent > EMPTY_OFFSET) {
|
|
369
388
|
children.push(createElement('symbiote-view', {
|
|
370
389
|
key: 'spacer-gap',
|
|
371
|
-
style: horizontal
|
|
390
|
+
style: horizontal
|
|
391
|
+
? { width: plan.gapExtent }
|
|
392
|
+
: { height: plan.gapExtent },
|
|
372
393
|
}));
|
|
373
394
|
}
|
|
374
395
|
for (const planCell of plan.cells) {
|
|
@@ -379,24 +400,42 @@ export function VirtualizedList(props) {
|
|
|
379
400
|
index: planCell.index,
|
|
380
401
|
separators: makeSeparators(planCell.index),
|
|
381
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;
|
|
382
421
|
// Wrap each cell in a measuring View. When inverted, each cell carries the counter-flip so
|
|
383
422
|
// its content reads upright inside the flipped content container.
|
|
384
423
|
children.push(createElement('symbiote-view', {
|
|
385
424
|
key: `cell-${planCell.key}`,
|
|
386
425
|
onLayout: makeCellMeasure(planCell.index),
|
|
387
|
-
style: inverted
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
children.push(createElement('symbiote-view', { key: `sep-${planCell.key}` }, separator));
|
|
394
|
-
}
|
|
426
|
+
style: inverted
|
|
427
|
+
? horizontal
|
|
428
|
+
? INVERTED_X_STYLE
|
|
429
|
+
: INVERTED_Y_STYLE
|
|
430
|
+
: undefined,
|
|
431
|
+
}, cell, separator));
|
|
395
432
|
}
|
|
396
433
|
if (plan.trailingExtent > EMPTY_OFFSET) {
|
|
397
434
|
children.push(createElement('symbiote-view', {
|
|
398
435
|
key: 'spacer-trailing',
|
|
399
|
-
style: horizontal
|
|
436
|
+
style: horizontal
|
|
437
|
+
? { width: plan.trailingExtent }
|
|
438
|
+
: { height: plan.trailingExtent },
|
|
400
439
|
}));
|
|
401
440
|
}
|
|
402
441
|
}
|
|
@@ -448,7 +487,8 @@ export function VirtualizedList(props) {
|
|
|
448
487
|
if (maintainVisibleContentPosition !== undefined) {
|
|
449
488
|
scrollProps.maintainVisibleContentPosition = {
|
|
450
489
|
...maintainVisibleContentPosition,
|
|
451
|
-
minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
|
|
490
|
+
minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
|
|
491
|
+
(header !== undefined ? 1 : 0),
|
|
452
492
|
};
|
|
453
493
|
}
|
|
454
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>>;
|