@symbiote-native/react 0.3.0 → 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 +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 +1 -1
- package/build/components/pressable/index.js +1 -1
- 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 +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 +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.js +2 -2
- 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.js +13 -5
- package/build/index.d.ts +7 -7
- package/build/index.js +1 -1
- package/build/modules/animated/create-animated-component.js +3 -1
- 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 +14 -4
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ in **mutation mode** (`appendChild` / `insertBefore` / `removeChild` → the eng
|
|
|
6
6
|
mutation API); `@symbiote-native/engine` does the clone-on-write commit into Fabric.
|
|
7
7
|
|
|
8
8
|
This is the **reference adapter**: the known-good driver used to validate the native pipe and the
|
|
9
|
-
commit engine before any other framework lands, so a break in Vue/Svelte/Solid isolates to
|
|
9
|
+
commit engine before any other framework lands, so a break in Vue/Svelte/Solid isolates to _that_
|
|
10
10
|
adapter, not the core.
|
|
11
11
|
|
|
12
12
|
<div align="center">
|
|
@@ -16,7 +16,7 @@ adapter, not the core.
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
> New to SymbioteNative? The [root README](../../README.md) has the architecture and the one fact it
|
|
19
|
-
> rests on — React is just
|
|
19
|
+
> rests on — React is just _one client_ of `nativeFabricUIManager`.
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -46,7 +46,7 @@ export default function App() {
|
|
|
46
46
|
return (
|
|
47
47
|
<View style={{ padding: 24 }}>
|
|
48
48
|
<Text>Taps: {count}</Text>
|
|
49
|
-
<Pressable onPress={() => setCount(
|
|
49
|
+
<Pressable onPress={() => setCount(c => c + 1)}>
|
|
50
50
|
<Text>Tap me</Text>
|
|
51
51
|
</Pressable>
|
|
52
52
|
</View>
|
|
@@ -54,7 +54,7 @@ export default function App() {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
The native entry registers a low-level
|
|
57
|
+
The native entry registers a low-level _runnable_ instead of a React component: RN's Fabric host
|
|
58
58
|
calls it with the surface's `rootTag`, and the renderer takes over from there — `nativeFabric-
|
|
59
59
|
UIManager` is driven directly, RN's own renderer never runs.
|
|
60
60
|
|
|
@@ -94,7 +94,7 @@ path:
|
|
|
94
94
|
`Appearance` · `PixelRatio` · `AppState`, plus imperative `Alert` · `ActionSheetIOS` · `Share` ·
|
|
95
95
|
`Linking` · `Vibration` · `Keyboard` · `StatusBar` — each reaching its real native module on the
|
|
96
96
|
bridgeless host.
|
|
97
|
-
- **`Animated`, both drivers** — JS
|
|
97
|
+
- **`Animated`, both drivers** — JS _and_ native driver side by side (`timing` · `spring` · `loop` ·
|
|
98
98
|
`interpolate` · `ValueXY` · tracking · `diffClamp`). Native offload is proven by jamming the JS
|
|
99
99
|
thread 1.5 s: the native-driven animations keep moving, the JS-driven one stalls.
|
|
100
100
|
- **Third-party native views** — `@react-native-community/slider` used straight from the package
|
package/build/bootstrap.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// "./bootstrap" export) because it imports react-native directly, which Vitest's Flow-unaware
|
|
5
5
|
// transform can't parse (see @symbiote-native/components/bootstrap for the full reason).
|
|
6
6
|
import { AppRegistry as RNAppRegistry } from 'react-native';
|
|
7
|
-
import { bootstrapHost } from '@symbiote-native/components/bootstrap';
|
|
7
|
+
import { bootstrapHost, } from '@symbiote-native/components/bootstrap';
|
|
8
8
|
import { AppRegistry, setHostRegistrar } from './modules/app-registry/index.js';
|
|
9
9
|
// Mirrors bare RN's own `AppRegistry.registerComponent(appName, () => App)` idiom, minus the
|
|
10
10
|
// manual host wiring in front of it.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// index.ios.ts / index.android.ts each declare their OWN top-level `ActivityIndicator`
|
|
12
12
|
// function that calls this hook, rather than exporting whatever a factory here returns (same
|
|
13
13
|
// shape as switch/shared.ts's useSwitchLogic).
|
|
14
|
-
import { renderActivityIndicator, resolveAccessibilityProps } from '@symbiote-native/components';
|
|
14
|
+
import { renderActivityIndicator, resolveAccessibilityProps, } from '@symbiote-native/components';
|
|
15
15
|
export function useActivityIndicatorLogic(rawProps, platform) {
|
|
16
16
|
// The wrapper is a raw symbiote-view, not the View FC, so it never runs
|
|
17
17
|
// resolveAccessibilityProps itself, so fold aria/role here, then forward the resolved
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createElement } from 'react';
|
|
6
6
|
import { Text } from '../components.js';
|
|
7
7
|
import { TouchableOpacity } from './touchable/index.js';
|
|
8
|
-
import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonTextStyle } from '@symbiote-native/components';
|
|
8
|
+
import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonTextStyle, } from '@symbiote-native/components';
|
|
9
9
|
export const Button = props => {
|
|
10
10
|
const { title, onPress, color, disabled, touchSoundDisabled, testID, hasTVPreferredFocus, nextFocusDown, nextFocusForward, nextFocusLeft, nextFocusRight, nextFocusUp, ...accessibilityRest } = props;
|
|
11
11
|
const textStyle = resolveButtonTextStyle(color, disabled);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// transforms are shared from @symbiote-native/components. This file only adapts to React's lifecycle
|
|
7
7
|
// (element creation + ref threading).
|
|
8
8
|
import { createElement, } from 'react';
|
|
9
|
-
import { dlog, resolveClassName } from '@symbiote-native/engine';
|
|
9
|
+
import { dlog, resolveClassName, } from '@symbiote-native/engine';
|
|
10
10
|
import { SINGLE_COLUMN, chunkIntoRows, expandRowViewability, firstItemOfRow, lastItemOfRow, rowKeyExtractor, } from '@symbiote-native/components';
|
|
11
11
|
import { VirtualizedList, } from '../virtualized-list/index.js';
|
|
12
12
|
export function FlatList(props) {
|
|
@@ -38,7 +38,10 @@ export function FlatList(props) {
|
|
|
38
38
|
const resolvedColumnWrapperStyle = typeof columnWrapperStyle === 'string'
|
|
39
39
|
? resolveClassName(columnWrapperStyle)
|
|
40
40
|
: columnWrapperStyle;
|
|
41
|
-
const rowStyle = [
|
|
41
|
+
const rowStyle = [
|
|
42
|
+
{ flexDirection: 'row' },
|
|
43
|
+
resolvedColumnWrapperStyle,
|
|
44
|
+
];
|
|
42
45
|
const renderRow = (info) => {
|
|
43
46
|
const cells = info.item.items.map((item, column) => {
|
|
44
47
|
const index = info.item.startIndex + column;
|
|
@@ -8,7 +8,12 @@ export const InputAccessoryView = rawProps => {
|
|
|
8
8
|
// Owns its host element (symbiote-input-accessory-view), so it folds aria/role here; the
|
|
9
9
|
// resolved accessibility* surface rides the node via `...passthrough`.
|
|
10
10
|
const { nativeID, backgroundColor, style, children, ...passthrough } = resolveAccessibilityProps(rawProps);
|
|
11
|
-
const host = renderInputAccessoryView({
|
|
11
|
+
const host = renderInputAccessoryView({
|
|
12
|
+
nativeID,
|
|
13
|
+
backgroundColor,
|
|
14
|
+
style,
|
|
15
|
+
passthrough,
|
|
16
|
+
});
|
|
12
17
|
// host has no structural children; the user children nest directly under it.
|
|
13
18
|
return createElement(host.type, { key: host.key, ...host.props }, children);
|
|
14
19
|
};
|
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The inset math + the behavior → style/structure decision are framework-agnostic and live in
|
|
7
7
|
// @symbiote-native/components (render-keyboard-avoiding-view), shared verbatim with the Vue adapter.
|
|
8
|
-
// React supplies only the lifecycle: useState for the inset, useRef for the measured frame
|
|
9
|
-
// useEffect
|
|
8
|
+
// React supplies only the lifecycle: useState for the inset, useRef for the measured frame and
|
|
9
|
+
// the cross-fade setting, the useEffect subscriptions, and the descriptor-free element assembly
|
|
10
|
+
// around its children.
|
|
10
11
|
import { createElement, useEffect, useRef, useState, } from 'react';
|
|
11
|
-
import { dlog } from '@symbiote-native/engine';
|
|
12
|
-
import { computeInset, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
|
|
12
|
+
import { dlog, Keyboard, Platform, } from '@symbiote-native/engine';
|
|
13
|
+
import { computeInset, keyboardAvoidingEventNamesFor, readKeyboardFrame, readLayoutFrame, readPrefersCrossFadeTransitions, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
|
|
13
14
|
import { View } from '../../components.js';
|
|
14
|
-
|
|
15
|
+
// The two notifications to subscribe to, per host (iOS takes the `will` pair so the view rides
|
|
16
|
+
// up with the keyboard animation; Android has no will-notifications). Resolved ONCE at module
|
|
17
|
+
// scope: Platform.OS is fixed for the process, so recomputing it per render buys nothing.
|
|
18
|
+
const KEYBOARD_EVENTS = keyboardAvoidingEventNamesFor(Platform.OS);
|
|
15
19
|
export const KeyboardAvoidingView = props => {
|
|
16
20
|
const { behavior, enabled = true, keyboardVerticalOffset = DEFAULT_VERTICAL_OFFSET, contentContainerStyle, style, children, onLayout,
|
|
17
21
|
// The wrapper is the View FC, which runs resolveAccessibilityProps itself, so
|
|
@@ -23,27 +27,43 @@ export const KeyboardAvoidingView = props => {
|
|
|
23
27
|
// next keyboard event's inset math.
|
|
24
28
|
const frameRef = useRef(undefined);
|
|
25
29
|
const initialHeightRef = useRef(undefined);
|
|
30
|
+
// A device accessibility setting, not component state: it cannot change mid-session, and
|
|
31
|
+
// learning it must not re-render. Read once per mount, then fed to every computeInset call.
|
|
32
|
+
const prefersCrossFadeRef = useRef(false);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
void readPrefersCrossFadeTransitions().then(prefers => {
|
|
35
|
+
prefersCrossFadeRef.current = prefers;
|
|
36
|
+
});
|
|
37
|
+
}, []);
|
|
26
38
|
useEffect(() => {
|
|
27
39
|
const onShow = (payload) => {
|
|
28
40
|
const keyboard = readKeyboardFrame(payload);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
// Functional update, not a read of `inset`: this handler is built inside the effect, so a
|
|
42
|
+
// direct read would freeze the value from the render that created it — and 'height' mode
|
|
43
|
+
// feeds the LIVE inset back in to cancel the wrapper's own shrink.
|
|
44
|
+
setInset(previousInset => {
|
|
45
|
+
const next = computeInset(frameRef.current, keyboard, keyboardVerticalOffset, {
|
|
46
|
+
behavior,
|
|
47
|
+
previousInset,
|
|
48
|
+
prefersCrossFadeTransitions: prefersCrossFadeRef.current,
|
|
49
|
+
});
|
|
50
|
+
dlog(`KeyboardAvoidingView show -> inset ${next}`);
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
32
53
|
};
|
|
33
54
|
const onHide = () => {
|
|
34
55
|
dlog('KeyboardAvoidingView hide -> inset 0');
|
|
35
56
|
setInset(0);
|
|
36
57
|
};
|
|
37
58
|
const subscriptions = [
|
|
38
|
-
Keyboard.addListener(
|
|
39
|
-
Keyboard.addListener(
|
|
40
|
-
Keyboard.addListener(KEYBOARD_EVENT.didHide, onHide),
|
|
59
|
+
Keyboard.addListener(KEYBOARD_EVENTS.show, onShow),
|
|
60
|
+
Keyboard.addListener(KEYBOARD_EVENTS.hide, onHide),
|
|
41
61
|
];
|
|
42
62
|
return () => {
|
|
43
63
|
for (const subscription of subscriptions)
|
|
44
64
|
subscription.remove();
|
|
45
65
|
};
|
|
46
|
-
}, [keyboardVerticalOffset]);
|
|
66
|
+
}, [behavior, keyboardVerticalOffset]);
|
|
47
67
|
const handleLayout = (event) => {
|
|
48
68
|
const frame = readLayoutFrame(event.nativeEvent.layout);
|
|
49
69
|
if (frame !== undefined) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// host's onDismiss prop (it rides `...passthrough`), and the keep-alive holds the node mounted
|
|
12
12
|
// through the exit transition. The native exit-animation timing is what's deferred, not the
|
|
13
13
|
// callback contract.
|
|
14
|
-
import { createElement, useEffect, useReducer } from 'react';
|
|
14
|
+
import { createElement, useEffect, useReducer, } from 'react';
|
|
15
15
|
import { dlog } from '@symbiote-native/engine';
|
|
16
16
|
import { createInitialModalState, modalReducer, renderModal, resolveAccessibilityProps, shouldRenderModal, } from '@symbiote-native/components';
|
|
17
17
|
export const Modal = rawProps => {
|
|
@@ -2,7 +2,7 @@ import { type FC, type ReactNode } from 'react';
|
|
|
2
2
|
import { type IPressState, type IPressHandler, type IRectOffset, type IPressableAndroidRippleConfig } from '@symbiote-native/components';
|
|
3
3
|
import type { IAccessibilityProps, IAriaProps } from '@symbiote-native/components';
|
|
4
4
|
import type { IStyleProp, IViewStyle } from '../../utils/styles';
|
|
5
|
-
export type { IPressState, IPressableAndroidRippleConfig } from '@symbiote-native/components';
|
|
5
|
+
export type { IPressState, IPressableAndroidRippleConfig, } from '@symbiote-native/components';
|
|
6
6
|
type IPressableStyle = IStyleProp<IViewStyle> | ((state: IPressState) => IStyleProp<IViewStyle>);
|
|
7
7
|
type IChildrenProp = ReactNode | ((state: IPressState) => ReactNode);
|
|
8
8
|
export interface IPressableProps extends IAccessibilityProps, IAriaProps {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// entirely in JS: android_ripple (an Android native-feedback prop on a dedicated inner View, inert
|
|
12
12
|
// on iOS), unstable_pressDelay, and pressRetentionOffset (the drift region), all handled by the
|
|
13
13
|
// shared machine; here we only wire its host + render the View.
|
|
14
|
-
import { createElement, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import { createElement, useMemo, useRef, useState, } from 'react';
|
|
15
15
|
import { createPressHandlers, createPressRuntime, rippleProps, buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, DEFAULT_DELAY_LONG_PRESS_MS, } from '@symbiote-native/components';
|
|
16
16
|
import { View } from '../../components.js';
|
|
17
17
|
function resolveStyle(style, state) {
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// android branch (cloneElement(refreshControl, {style}, <ScrollView nestedScrollEnabled
|
|
7
7
|
// style={flex:1}>{content}</ScrollView>)). Metro picks this on an Android host; no
|
|
8
8
|
// Platform.OS read.
|
|
9
|
-
import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef } from 'react';
|
|
9
|
+
import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef, } from 'react';
|
|
10
10
|
import { dlog } from '@symbiote-native/engine';
|
|
11
|
-
import { buildScrollViewHandle, splitLayoutProps } from '@symbiote-native/components';
|
|
11
|
+
import { buildScrollViewHandle, splitLayoutProps, } from '@symbiote-native/components';
|
|
12
12
|
import { usePreparedScrollView, useNativeStickyScrollAttach, } from './shared.js';
|
|
13
13
|
export const ScrollView = forwardRef((props, forwardedRef) => {
|
|
14
14
|
const { scrollViewIntrinsic, scrollViewBaseStyle, outerProps, style, layoutSplitStyle, content, refreshControl, scrollAnimatedValue, nativeStickyAvailable, } = usePreparedScrollView(props);
|
|
@@ -21,11 +21,14 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
|
|
|
21
21
|
// Drive the sticky scroll value on the native UI thread (RN attachNativeEvent). No-op on a
|
|
22
22
|
// host without the native animated module. The JS sticky path stays in effect.
|
|
23
23
|
useNativeStickyScrollAttach(ref, scrollAnimatedValue, nativeStickyAvailable);
|
|
24
|
-
dlog('ScrollView.ANDROID refreshControl=' +
|
|
24
|
+
dlog('ScrollView.ANDROID refreshControl=' +
|
|
25
|
+
(refreshControl === undefined ? 'NONE(1child)' : 'WRAP'));
|
|
25
26
|
if (refreshControl === undefined) {
|
|
26
27
|
// Base style (flexDirection: row for horizontal) under user style; undefined base
|
|
27
28
|
// (vertical) passes the user style through unchanged.
|
|
28
|
-
const scrollStyle = scrollViewBaseStyle
|
|
29
|
+
const scrollStyle = scrollViewBaseStyle
|
|
30
|
+
? [scrollViewBaseStyle, style]
|
|
31
|
+
: style;
|
|
29
32
|
return createElement(scrollViewIntrinsic, { ...outerProps, style: scrollStyle, ref }, content);
|
|
30
33
|
}
|
|
31
34
|
// RN splits the flattened style across the two boxes (ScrollView.js android branch):
|
|
@@ -40,12 +43,19 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
|
|
|
40
43
|
// starves the wrapper of its layout style and it collapses to nothing. layoutSplitStyle is
|
|
41
44
|
// `style` merged with the resolved className style (see usePreparedScrollView).
|
|
42
45
|
const { outer: outerStyle, inner: innerStyle } = splitLayoutProps(layoutSplitStyle);
|
|
43
|
-
const scrollStyle = scrollViewBaseStyle
|
|
46
|
+
const scrollStyle = scrollViewBaseStyle
|
|
47
|
+
? [scrollViewBaseStyle, innerStyle]
|
|
48
|
+
: innerStyle;
|
|
44
49
|
// className is stripped from the spread here: layoutSplitStyle already folded its resolved
|
|
45
50
|
// value into `outer`/`inner` above, so forwarding the raw prop too would re-apply its LAYOUT
|
|
46
51
|
// half onto the inner scroll view a second time (on top of the outer wrapper).
|
|
47
52
|
const { className: _classNameAppliedViaSplit, ...innerOuterProps } = outerProps;
|
|
48
|
-
const scrollView = createElement(scrollViewIntrinsic, {
|
|
53
|
+
const scrollView = createElement(scrollViewIntrinsic, {
|
|
54
|
+
...innerOuterProps,
|
|
55
|
+
style: scrollStyle,
|
|
56
|
+
nestedScrollEnabled: true,
|
|
57
|
+
ref,
|
|
58
|
+
}, content);
|
|
49
59
|
return cloneElement(refreshControl, { style: outerStyle }, scrollView);
|
|
50
60
|
});
|
|
51
61
|
ScrollView.displayName = 'ScrollView';
|
|
@@ -17,7 +17,9 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
|
|
|
17
17
|
useNativeStickyScrollAttach(ref, scrollAnimatedValue, nativeStickyAvailable);
|
|
18
18
|
// Base style stays under user style so an explicit value wins; undefined base (vertical)
|
|
19
19
|
// passes the user style through unchanged.
|
|
20
|
-
const scrollStyle = scrollViewBaseStyle
|
|
20
|
+
const scrollStyle = scrollViewBaseStyle
|
|
21
|
+
? [scrollViewBaseStyle, style]
|
|
22
|
+
: style;
|
|
21
23
|
const scrollProps = { ...outerProps, style: scrollStyle, ref };
|
|
22
24
|
if (refreshControl === undefined) {
|
|
23
25
|
return createElement(scrollViewIntrinsic, scrollProps, content);
|
|
@@ -14,7 +14,7 @@ import { createElement, useEffect, useRef, useState, } from 'react';
|
|
|
14
14
|
import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, resolveClassName, } from '@symbiote-native/engine';
|
|
15
15
|
import { attachStickyScroll, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
|
|
16
16
|
import { resolveAccessibilityProps, } from '@symbiote-native/components';
|
|
17
|
-
import { wrapStickyHeaders } from './sticky-header.js';
|
|
17
|
+
import { wrapStickyHeaders, } from './sticky-header.js';
|
|
18
18
|
export function usePreparedScrollView(rawProps) {
|
|
19
19
|
// ScrollView forwards its outer props straight to the native scroll view (not a View
|
|
20
20
|
// wrapper), so it folds aria/role into accessibility* here before forwarding.
|
|
@@ -42,13 +42,56 @@ export function usePreparedScrollView(rawProps) {
|
|
|
42
42
|
if (headerLayoutYsRef.current === null)
|
|
43
43
|
headerLayoutYsRef.current = new Map();
|
|
44
44
|
const headerLayoutYs = headerLayoutYsRef.current;
|
|
45
|
-
|
|
45
|
+
// The mounted headers by child index (RN's _stickyHeaderRefs, ScrollView.js:750), so a fresh
|
|
46
|
+
// measurement reaches its predecessor directly.
|
|
47
|
+
const stickyHeaderRefsRef = useRef(null);
|
|
48
|
+
if (stickyHeaderRefsRef.current === null)
|
|
49
|
+
stickyHeaderRefsRef.current = new Map();
|
|
50
|
+
const stickyHeaderRefs = stickyHeaderRefsRef.current;
|
|
51
|
+
const onHeaderRef = (index, handle) => {
|
|
52
|
+
if (handle === null)
|
|
53
|
+
stickyHeaderRefs.delete(index);
|
|
54
|
+
else
|
|
55
|
+
stickyHeaderRefs.set(index, handle);
|
|
56
|
+
};
|
|
57
|
+
// Measurements waiting to be handed to their predecessors, flushed together (see below).
|
|
58
|
+
const pendingHeaderYsRef = useRef(null);
|
|
59
|
+
if (pendingHeaderYsRef.current === null)
|
|
60
|
+
pendingHeaderYsRef.current = new Map();
|
|
61
|
+
const pendingHeaderYs = pendingHeaderYsRef.current;
|
|
62
|
+
const flushScheduledRef = useRef(false);
|
|
63
|
+
const flushHeaderYs = () => {
|
|
64
|
+
flushScheduledRef.current = false;
|
|
65
|
+
if (stickyHeaderIndices === undefined)
|
|
66
|
+
return;
|
|
67
|
+
for (const [index, y] of pendingHeaderYs) {
|
|
68
|
+
const previousIndex = stickyHeaderIndices[stickyHeaderIndices.indexOf(index) - 1];
|
|
69
|
+
if (previousIndex === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
stickyHeaderRefs.get(previousIndex)?.setNextHeaderY(y);
|
|
72
|
+
}
|
|
73
|
+
pendingHeaderYs.clear();
|
|
74
|
+
};
|
|
75
|
+
// RN _onStickyHeaderLayout (ScrollView.js:1136): record this header's y, then hand it to the
|
|
76
|
+
// PREVIOUS sticky header as its push-off collision point.
|
|
77
|
+
//
|
|
78
|
+
// The handoff is COALESCED rather than immediate, and that is the point. Cross-talk runs BACKWARDS
|
|
79
|
+
// (a header learns its collision point from the one after it), so a mount measures every header
|
|
80
|
+
// and walks the chain end to start. Pushing each value the moment it arrives makes every link its
|
|
81
|
+
// own update generation - a setState on the predecessor, whose effect setStates again - and React
|
|
82
|
+
// caps nested updates at 50: three headers go unnoticed, two hundred abort the app with "Maximum
|
|
83
|
+
// update depth exceeded", which reads like an infinite loop but is really depth. Draining on a
|
|
84
|
+
// microtask puts one burst of layouts into ONE render pass, so depth stops tracking header count.
|
|
46
85
|
const onHeaderLayoutY = (index, y) => {
|
|
47
86
|
if (headerLayoutYs.get(index) === y)
|
|
48
87
|
return;
|
|
49
88
|
headerLayoutYs.set(index, y);
|
|
50
89
|
dlog(`ScrollView sticky-header layoutY index=${index} y=${y}`);
|
|
51
|
-
|
|
90
|
+
pendingHeaderYs.set(index, y);
|
|
91
|
+
if (flushScheduledRef.current)
|
|
92
|
+
return;
|
|
93
|
+
flushScheduledRef.current = true;
|
|
94
|
+
void Promise.resolve().then(flushHeaderYs);
|
|
52
95
|
};
|
|
53
96
|
// A class-name string resolves through the shared registry before it reaches the
|
|
54
97
|
// framework-agnostic selector below, which only understands style objects/arrays.
|
|
@@ -58,7 +101,7 @@ export function usePreparedScrollView(rawProps) {
|
|
|
58
101
|
// The per-axis intrinsics, base style, and content style come from the shared selector
|
|
59
102
|
// (@symbiote-native/components): on Android horizontal resolves to its own ViewManager, on iOS both
|
|
60
103
|
// map back to RCTScrollView; here we only pass the axis.
|
|
61
|
-
const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle } = selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle);
|
|
104
|
+
const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle, } = selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle);
|
|
62
105
|
const outerProps = { ...outer };
|
|
63
106
|
// className is pulled out above (unlike the rest of `...outer`) so `layoutSplitStyle` below can
|
|
64
107
|
// resolve it eagerly; re-added here so the simple single-node paths (iOS, Android with no
|
|
@@ -121,7 +164,10 @@ export function usePreparedScrollView(rawProps) {
|
|
|
121
164
|
// _handleContentOnLayout): read width/height off nativeEvent.layout and fire only when the
|
|
122
165
|
// size actually changed (dedupe via a ref, like RN). Composed with any content onLayout.
|
|
123
166
|
const lastContentSizeRef = useRef(null);
|
|
124
|
-
const contentProps = {
|
|
167
|
+
const contentProps = {
|
|
168
|
+
style: contentStyle,
|
|
169
|
+
collapsable: false,
|
|
170
|
+
};
|
|
125
171
|
// maintainVisibleContentPosition (and Android snapToAlignment) anchor against the metrics
|
|
126
172
|
// of MOUNTED cell views. Android Fabric view-flattens layout-only cells away, so the native
|
|
127
173
|
// MaintainVisibleScrollPositionHelper has nothing to anchor to and the list jumps on prepend.
|
|
@@ -147,7 +193,7 @@ export function usePreparedScrollView(rawProps) {
|
|
|
147
193
|
// Wrap the flagged children so they pin to the scroll offset (see the sticky-headers note
|
|
148
194
|
// above); no-op when none are flagged.
|
|
149
195
|
const contentChildren = hasStickyHeaders
|
|
150
|
-
? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY)
|
|
196
|
+
? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, { headerLayoutYs, onHeaderLayoutY, onHeaderRef })
|
|
151
197
|
: children;
|
|
152
198
|
// `collapsable: false` is load-bearing on Android. The content container is a
|
|
153
199
|
// layout-only View, which Android Fabric view-flattens away, hoisting the cells
|
|
@@ -159,7 +205,10 @@ export function usePreparedScrollView(rawProps) {
|
|
|
159
205
|
const content = createElement(contentIntrinsic, contentProps, contentChildren);
|
|
160
206
|
// resolveClassName(undefined) is a cheap {} no-op, so this is safe with no className too.
|
|
161
207
|
const resolvedClassName = isClassNameValue(className) ? className : undefined;
|
|
162
|
-
const layoutSplitStyle = [
|
|
208
|
+
const layoutSplitStyle = [
|
|
209
|
+
resolveClassName(resolvedClassName),
|
|
210
|
+
style,
|
|
211
|
+
];
|
|
163
212
|
return {
|
|
164
213
|
scrollViewIntrinsic,
|
|
165
214
|
scrollViewBaseStyle,
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import { type ComponentType, type ReactNode } from 'react';
|
|
1
|
+
import { type ComponentType, type ReactNode, type Ref } from 'react';
|
|
2
2
|
import { AnimatedValue } from '@symbiote-native/engine';
|
|
3
3
|
import { type IStickyHeaderProps } from '@symbiote-native/components';
|
|
4
|
+
export type IStickyHeaderHandle = {
|
|
5
|
+
setNextHeaderY: (y: number) => void;
|
|
6
|
+
};
|
|
7
|
+
export type IStickyCrossTalk = {
|
|
8
|
+
headerLayoutYs: ReadonlyMap<number, number>;
|
|
9
|
+
onHeaderLayoutY: (index: number, y: number) => void;
|
|
10
|
+
onHeaderRef: (index: number, handle: IStickyHeaderHandle | null) => void;
|
|
11
|
+
};
|
|
4
12
|
export type IStickyHeaderComponentProps = IStickyHeaderProps & {
|
|
5
13
|
children?: ReactNode;
|
|
14
|
+
ref?: Ref<IStickyHeaderHandle>;
|
|
6
15
|
};
|
|
7
16
|
export type IStickyHeaderComponentType = ComponentType<IStickyHeaderComponentProps>;
|
|
8
17
|
export declare const ScrollViewStickyHeader: IStickyHeaderComponentType;
|
|
9
|
-
export declare function wrapStickyHeaders(children: ReactNode, stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined,
|
|
18
|
+
export declare function wrapStickyHeaders(children: ReactNode, stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined, crossTalk: IStickyCrossTalk): ReactNode;
|
|
@@ -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) => {
|
|
@@ -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;
|