@symbiote-native/components 0.5.0 → 2.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 +23 -10
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -118
- package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.android.js +16 -0
- package/build/behaviors/activity-indicator/index.d.ts +3 -0
- package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.ios.js +14 -0
- package/build/behaviors/activity-indicator/index.js +5 -0
- package/build/behaviors/activity-indicator/shared.d.ts +18 -0
- package/build/behaviors/activity-indicator/shared.js +149 -0
- package/build/behaviors/button.d.ts +2 -0
- package/build/behaviors/button.js +328 -0
- package/build/behaviors/image-background.d.ts +2 -0
- package/build/behaviors/image-background.js +139 -0
- package/build/behaviors/image.d.ts +3 -0
- package/build/behaviors/image.js +123 -0
- package/build/behaviors/input-accessory-view.d.ts +3 -0
- package/build/behaviors/input-accessory-view.js +70 -0
- package/build/behaviors/pressable.d.ts +60 -0
- package/build/behaviors/pressable.js +385 -0
- package/build/behaviors/refresh-control.d.ts +2 -0
- package/build/behaviors/refresh-control.js +83 -0
- package/build/behaviors/scroll-view/index.android.d.ts +1 -0
- package/build/behaviors/scroll-view/index.android.js +52 -0
- package/build/behaviors/scroll-view/index.d.ts +2 -0
- package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
- package/build/behaviors/scroll-view/index.ios.js +10 -0
- package/build/behaviors/scroll-view/index.js +5 -0
- package/build/behaviors/scroll-view/shared.d.ts +11 -0
- package/build/behaviors/scroll-view/shared.js +291 -0
- package/build/behaviors/scroll-view/sticky.d.ts +17 -0
- package/build/behaviors/scroll-view/sticky.js +568 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +186 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +319 -0
- package/build/behaviors/touchable-highlight.d.ts +9 -0
- package/build/behaviors/touchable-highlight.js +192 -0
- package/build/behaviors/touchable-native-feedback.d.ts +20 -0
- package/build/behaviors/touchable-native-feedback.js +333 -0
- package/build/behaviors/touchable-opacity.d.ts +12 -0
- package/build/behaviors/touchable-opacity.js +227 -0
- package/build/behaviors/touchable-without-feedback.d.ts +2 -0
- package/build/behaviors/touchable-without-feedback.js +296 -0
- package/build/component-names/index.android.js +47 -15
- package/build/component-names/index.ios.js +35 -15
- package/build/component-names/shared.d.ts +2 -1
- package/build/component-names/shared.js +49 -3
- package/build/descriptor.js +4 -4
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +23 -12
- package/build/index.js +50 -9
- package/build/register.d.ts +1 -0
- package/build/register.js +55 -0
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/scroll-view-commands.d.ts +4 -0
- package/build/scroll-view-commands.js +30 -31
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +120 -34
- package/build/state/text-input.d.ts +10 -2
- package/build/state/text-input.js +11 -0
- package/build/text-props.js +2 -1
- package/build/view/render-button.d.ts +36 -1
- package/build/view/render-button.js +101 -12
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +29 -2
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +37 -5
- package/build/view/render-modal.js +3 -3
- package/build/view/render-pressable/index.d.ts +2 -0
- package/build/view/render-pressable/index.js +24 -0
- package/build/view/render-scroll-view.d.ts +1 -0
- package/build/view/render-scroll-view.js +18 -9
- package/build/view/render-switch.d.ts +4 -1
- package/build/view/render-switch.js +8 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-native-feedback.d.ts +19 -0
- package/build/view/render-touchable-native-feedback.js +19 -0
- package/host-primitives.cjs +432 -0
- package/host-primitives.d.cts +33 -0
- package/package.json +33 -5
- package/build/view/render-activity-indicator.d.ts +0 -25
- package/build/view/render-activity-indicator.js +0 -62
- package/build/view/render-image-background.d.ts +0 -9
- package/build/view/render-image-background.js +0 -48
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// InputAccessoryView's host behavior — a prop fold and nothing else. No listeners, no timers, no
|
|
2
|
+
// commit hook: the wrapper's whole body was prop mapping, so its lowered form owes exactly that.
|
|
3
|
+
//
|
|
4
|
+
// The fold is NOT written here. `mapInputAccessoryViewProps` in
|
|
5
|
+
// `../view/render-input-accessory-view` is the one implementation and this file only narrows a flat
|
|
6
|
+
// bag into the typed view it takes, the `stringOf` / `styleOf` guard idiom `behaviors/image.ts`
|
|
7
|
+
// uses. Counted before starting, which is step one of the fold-only recipe: unlike Image, which had
|
|
8
|
+
// THREE implementations, all five adapters already call the shared render fn and Svelte's
|
|
9
|
+
// `input-accessory-view-props.ts` is a type declaration rather than a second mapping. So there was
|
|
10
|
+
// one implementation to extract, not three to collapse.
|
|
11
|
+
//
|
|
12
|
+
// SHARES THE WRAPPER'S TAG, no `-managed` twin. The two questions the recipe separates both answer
|
|
13
|
+
// no: the fold is idempotent (there is no aliasing at all — every consumed name leaves under the
|
|
14
|
+
// same name, so a second pass rewrites the same values), and the behavior carries no machine, so
|
|
15
|
+
// nothing can end up with two owners on one node. Idempotence is asserted rather than reasoned.
|
|
16
|
+
//
|
|
17
|
+
// PLATFORM. This is the only primitive in its group that is not platform-invariant in what it
|
|
18
|
+
// COMMITS TO: `input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to a
|
|
19
|
+
// plain `RCTView` on Android. The fold itself is platform-invariant on purpose — it reproduces the
|
|
20
|
+
// wrapper's mapping exactly, on both platforms, so the lowered and wrapped paths cannot diverge
|
|
21
|
+
// per-platform. What it does NOT do is fix the pre-existing Android divergence underneath it:
|
|
22
|
+
// upstream RN renders NOTHING there (`InputAccessoryView.js` — `console.warn('<InputAccessoryView>
|
|
23
|
+
// is only supported on iOS.'); return null`), while we commit an RCTView, and `backgroundColor` is
|
|
24
|
+
// a declared prop of the iOS view but a style key on RCTView. Both predate this behavior and are
|
|
25
|
+
// identical on both paths; neither is in scope here, and a fold that "fixed" one silently would
|
|
26
|
+
// make the two paths disagree.
|
|
27
|
+
import { registerHostBehavior, } from '@symbiote-native/engine';
|
|
28
|
+
import { INPUT_ACCESSORY_VIEW_PROP_NAMES, mapInputAccessoryViewProps, } from '../view/render-input-accessory-view.js';
|
|
29
|
+
export const INPUT_ACCESSORY_VIEW_TAG = 'input-accessory-view';
|
|
30
|
+
const CONSUMED = new Set(INPUT_ACCESSORY_VIEW_PROP_NAMES);
|
|
31
|
+
function stringOf(value) {
|
|
32
|
+
return typeof value === 'string' ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
// A StyleProp is an object, an array of them, or a registered class array — all shapes
|
|
35
|
+
// `flattenStyle` already handles. Only a scalar has to be excluded.
|
|
36
|
+
function styleOf(value) {
|
|
37
|
+
if (typeof value !== 'object' || value === null)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (Array.isArray(value))
|
|
40
|
+
return value;
|
|
41
|
+
return { ...value };
|
|
42
|
+
}
|
|
43
|
+
export function foldInputAccessoryViewPayload(props) {
|
|
44
|
+
const passthrough = {};
|
|
45
|
+
for (const key of Object.keys(props)) {
|
|
46
|
+
if (!CONSUMED.has(key))
|
|
47
|
+
passthrough[key] = props[key];
|
|
48
|
+
}
|
|
49
|
+
return mapInputAccessoryViewProps({
|
|
50
|
+
nativeID: stringOf(props.nativeID),
|
|
51
|
+
backgroundColor: stringOf(props.backgroundColor),
|
|
52
|
+
style: styleOf(props.style),
|
|
53
|
+
passthrough,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Required by IHostBehavior and deliberately empty: this primitive owns no per-node runtime.
|
|
57
|
+
// Written out rather than shared with a `noop` so the emptiness reads as a decision.
|
|
58
|
+
function attach(_node) {
|
|
59
|
+
// nothing to set up
|
|
60
|
+
}
|
|
61
|
+
function detach(_node) {
|
|
62
|
+
// nothing to release
|
|
63
|
+
}
|
|
64
|
+
export function registerInputAccessoryViewBehavior() {
|
|
65
|
+
registerHostBehavior(INPUT_ACCESSORY_VIEW_TAG, {
|
|
66
|
+
attach,
|
|
67
|
+
detach,
|
|
68
|
+
foldPayload: foldInputAccessoryViewPayload,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine';
|
|
2
|
+
import { type IPressMachineConfig } from '../state/pressable';
|
|
3
|
+
import type { IAccessibilityStateValue } from '../accessibility-props';
|
|
4
|
+
export declare const PRESSABLE_TAG = "pressable";
|
|
5
|
+
/**
|
|
6
|
+
* A last look at the machine's config before its handlers are built, for a tag that IS a pressable
|
|
7
|
+
* plus something — TouchableOpacity, whose fade has to run between the machine and the app's own
|
|
8
|
+
* `onPressIn`.
|
|
9
|
+
*
|
|
10
|
+
* Called from `rebuild`, so once per gesture rather than once per mount: it sees the config the
|
|
11
|
+
* props actually hold by the time a finger lands, and anything it captures is discarded with the
|
|
12
|
+
* gesture. Per-node state that must OUTLIVE a gesture belongs on the caller's own WeakMap.
|
|
13
|
+
*/
|
|
14
|
+
export type IPressConfigRefinement = (node: ISymbioteNode, config: IPressMachineConfig) => IPressMachineConfig;
|
|
15
|
+
/**
|
|
16
|
+
* What the machine reads as `disabled`, for a tag whose spelling of it is not the raw prop.
|
|
17
|
+
*
|
|
18
|
+
* There is no resolver by default because RN's Pressable hands Pressability the RAW prop
|
|
19
|
+
* (`Pressable.js:266`) — `aria-disabled` there changes only what is ANNOUNCED. Button is the one
|
|
20
|
+
* primitive that differs: it resolves `disabled ?? aria-disabled ?? accessibilityState.disabled` in
|
|
21
|
+
* the component and passes the ANSWER down as the touchable's own prop (`Button.js:337` -> `:386`),
|
|
22
|
+
* which a single tag has no second node to pass to.
|
|
23
|
+
*
|
|
24
|
+
* Reads the bag and returns the answer; it must never write one back. `resolveButtonDisabled`
|
|
25
|
+
* short-circuits on an authored `disabled`, so a resolved value stored in `node.props.disabled`
|
|
26
|
+
* would answer the NEXT resolution as if the app had written it and the tag could never re-enable.
|
|
27
|
+
*/
|
|
28
|
+
export type IDisabledResolver = (props: Readonly<Record<string, unknown>>) => boolean | undefined;
|
|
29
|
+
export declare function booleanOr(value: unknown): boolean | undefined;
|
|
30
|
+
export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
|
|
31
|
+
export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The machine on `node`, reading its props and the app's callbacks off `options.source` when that
|
|
34
|
+
* is a different node.
|
|
35
|
+
*
|
|
36
|
+
* Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
|
|
37
|
+
* whose tag commits nothing and adopts the app's single child as the responder. Every other caller
|
|
38
|
+
* goes through `createPressBehavior`, where source and node are the same.
|
|
39
|
+
*
|
|
40
|
+
* Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
|
|
41
|
+
* a re-arm after `detachPressMachine` needs.
|
|
42
|
+
*/
|
|
43
|
+
export declare function attachPressMachine(node: ISymbioteNode, options?: {
|
|
44
|
+
readonly refine?: IPressConfigRefinement;
|
|
45
|
+
readonly disabledOf?: IDisabledResolver;
|
|
46
|
+
readonly source?: ISymbioteNode;
|
|
47
|
+
}): void;
|
|
48
|
+
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
49
|
+
export declare function detachPressMachine(node: ISymbioteNode): void;
|
|
50
|
+
/**
|
|
51
|
+
* The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
|
|
52
|
+
* instead of re-implementing it.
|
|
53
|
+
*
|
|
54
|
+
* Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
|
|
55
|
+
* family needs a per-node Animated value opened before the machine and closed after it. The
|
|
56
|
+
* WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
|
|
57
|
+
* these; a tag composing it therefore must not also register the plain `pressable` behavior.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createPressBehavior(refine?: IPressConfigRefinement, disabledOf?: IDisabledResolver): Pick<IHostBehavior, 'attach' | 'detach' | 'foldPayload' | 'ownedListeners'>;
|
|
60
|
+
export declare function registerPressableBehavior(): void;
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// The press machine as an ENGINE-NODE behavior, so a pressable can be an intrinsic tag instead of
|
|
2
|
+
// a framework component (`.claude/rules/host-primitive-tier.md`, tier 2). Written once; every
|
|
3
|
+
// adapter inherits it by registering, and none re-implements it.
|
|
4
|
+
//
|
|
5
|
+
// The machine itself is unchanged and still shared with the component path — `createPressRuntime`
|
|
6
|
+
// / `createPressHandlers` in `../state/pressable`. What is new is only WHERE its lifecycle lives:
|
|
7
|
+
// on the engine node rather than in a component instance.
|
|
8
|
+
//
|
|
9
|
+
// REGISTRATION IS THE HAZARD, not the machine. Metro enables `inlineRequires` in production only,
|
|
10
|
+
// moving a `require` to the first place its binding is used as a VALUE, and a barrel's
|
|
11
|
+
// `export { X } from './x'` compiles to a lazy getter. A module whose only job is a side effect is
|
|
12
|
+
// never named as a value, so re-exporting it means it NEVER RUNS in a release build — dev perfect,
|
|
13
|
+
// release silently pressless. Each adapter therefore keeps its own `src/register.ts` calling
|
|
14
|
+
// `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
|
|
15
|
+
// does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
|
|
16
|
+
// either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
|
|
17
|
+
import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
|
|
18
|
+
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, rippleProps, } from '../state/pressable.js';
|
|
19
|
+
import { buildPressableListeners, resolveDisabledAccessibilityState, resolvePressableFocusable, } from '../view/render-pressable/index.js';
|
|
20
|
+
export const PRESSABLE_TAG = 'pressable';
|
|
21
|
+
const states = new WeakMap();
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function numberOr(value, fallback) {
|
|
26
|
+
return typeof value === 'number' ? value : fallback;
|
|
27
|
+
}
|
|
28
|
+
// The bag arrives as `unknown` off `node.props`; every RN default below is written against
|
|
29
|
+
// `boolean | undefined`. Exported to the sibling behaviors folding the same bag, and deliberately
|
|
30
|
+
// NOT to the shared barrel — same reasoning as `asAccessibilityState`.
|
|
31
|
+
export function booleanOr(value) {
|
|
32
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
// A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
|
|
35
|
+
// turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
|
|
36
|
+
// because the component's version narrows Vue attrs, and this one narrows engine props.
|
|
37
|
+
function asRectOffset(value) {
|
|
38
|
+
if (typeof value === 'number')
|
|
39
|
+
return value;
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
return undefined;
|
|
42
|
+
const rect = {};
|
|
43
|
+
if (typeof value.top === 'number')
|
|
44
|
+
rect.top = value.top;
|
|
45
|
+
if (typeof value.left === 'number')
|
|
46
|
+
rect.left = value.left;
|
|
47
|
+
if (typeof value.bottom === 'number')
|
|
48
|
+
rect.bottom = value.bottom;
|
|
49
|
+
if (typeof value.right === 'number')
|
|
50
|
+
rect.right = value.right;
|
|
51
|
+
return rect;
|
|
52
|
+
}
|
|
53
|
+
// A predicate rather than a bare `typeof`: `typeof x === 'function'` narrows to `Function`, which
|
|
54
|
+
// carries no call signature and cannot satisfy IPressHandler. Guard, never cast. Also used for the
|
|
55
|
+
// listener bag, whose values are `unknown` for the same reason.
|
|
56
|
+
function isPressHandler(value) {
|
|
57
|
+
return typeof value === 'function';
|
|
58
|
+
}
|
|
59
|
+
// The props the MACHINE consumes and the host must never see. The wrapper drops them by
|
|
60
|
+
// destructuring — they go into `createPressHandlers` / `buildPressableListeners` and are simply
|
|
61
|
+
// absent from the object it spreads onto its View. A lowered element has no destructure, so every
|
|
62
|
+
// one of them rode into the payload as a key no ViewConfig declares.
|
|
63
|
+
const MACHINE_ONLY_KEYS = [
|
|
64
|
+
// Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
|
|
65
|
+
// `nativeForegroundAndroid`; the raw config is not a native prop.
|
|
66
|
+
'android_ripple',
|
|
67
|
+
'disabled',
|
|
68
|
+
'cancelable',
|
|
69
|
+
'delayLongPress',
|
|
70
|
+
'minPressDuration',
|
|
71
|
+
'unstable_pressDelay',
|
|
72
|
+
'pressRetentionOffset',
|
|
73
|
+
'delayHoverIn',
|
|
74
|
+
'delayHoverOut',
|
|
75
|
+
];
|
|
76
|
+
// Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
|
|
77
|
+
// twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — exported
|
|
78
|
+
// to the sibling behaviors that fold the same bag, and deliberately NOT to the shared barrel, which
|
|
79
|
+
// every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
|
|
80
|
+
export function asAccessibilityState(value) {
|
|
81
|
+
if (!isRecord(value))
|
|
82
|
+
return undefined;
|
|
83
|
+
const state = {};
|
|
84
|
+
if (typeof value.disabled === 'boolean')
|
|
85
|
+
state.disabled = value.disabled;
|
|
86
|
+
if (typeof value.selected === 'boolean')
|
|
87
|
+
state.selected = value.selected;
|
|
88
|
+
if (value.checked === 'mixed' || typeof value.checked === 'boolean')
|
|
89
|
+
state.checked = value.checked;
|
|
90
|
+
if (typeof value.busy === 'boolean')
|
|
91
|
+
state.busy = value.busy;
|
|
92
|
+
if (typeof value.expanded === 'boolean')
|
|
93
|
+
state.expanded = value.expanded;
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
// Narrowed field by field, same reason as the accessibility guard above: the config arrives as
|
|
97
|
+
// `unknown` off `node.props`.
|
|
98
|
+
function asRippleConfig(value) {
|
|
99
|
+
if (!isRecord(value))
|
|
100
|
+
return undefined;
|
|
101
|
+
const config = {};
|
|
102
|
+
if (typeof value.color === 'string')
|
|
103
|
+
config.color = value.color;
|
|
104
|
+
if (typeof value.borderless === 'boolean')
|
|
105
|
+
config.borderless = value.borderless;
|
|
106
|
+
if (typeof value.radius === 'number')
|
|
107
|
+
config.radius = value.radius;
|
|
108
|
+
if (typeof value.foreground === 'boolean')
|
|
109
|
+
config.foreground = value.foreground;
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
// `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
|
|
113
|
+
// View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
|
|
114
|
+
// forwards the composite. Lowering dropped that fold: press suppression still worked, because the
|
|
115
|
+
// machine reads `node.props.disabled` directly, so the button behaved correctly and announced
|
|
116
|
+
// itself as enabled. An accessibility regression with no visual tell and no failing test.
|
|
117
|
+
//
|
|
118
|
+
// Found by the wrapper-vs-behavior import audit (`.claude/rules/adapter-parity-audit.md`).
|
|
119
|
+
function foldPayload(props) {
|
|
120
|
+
const resolved = resolveDisabledAccessibilityState(asAccessibilityState(props.accessibilityState), typeof props.disabled === 'boolean' ? props.disabled : undefined);
|
|
121
|
+
// The Android ripple. Our WRAPPER paints it through a dedicated inner View, mirroring
|
|
122
|
+
// TouchableNativeFeedback — and that reading is what made this look unfixable for a lowered
|
|
123
|
+
// element, which has no child to put it on. RN's own `Pressable` does NOT do that: it spreads
|
|
124
|
+
// `useAndroidRippleForView`'s `viewProps` onto its own View (`Pressable.js:251`), so the ripple
|
|
125
|
+
// background is an ordinary prop of the responder itself and a single node carries it fine.
|
|
126
|
+
//
|
|
127
|
+
// `rippleProps` returns undefined off Android, so this whole branch is inert on iOS.
|
|
128
|
+
//
|
|
129
|
+
// STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
|
|
130
|
+
// `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
|
|
131
|
+
// pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
|
|
132
|
+
// wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
|
|
133
|
+
const rippleConfig = asRippleConfig(props.android_ripple);
|
|
134
|
+
const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
|
|
135
|
+
const out = { ...props };
|
|
136
|
+
for (const key of MACHINE_ONLY_KEYS)
|
|
137
|
+
delete out[key];
|
|
138
|
+
if (ripple !== undefined)
|
|
139
|
+
Object.assign(out, ripple);
|
|
140
|
+
// Written only when the fold produced something: an unconditional assignment would put an
|
|
141
|
+
// `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
|
|
142
|
+
// skipping undefined is a coincidence to lean on, not a contract to rely on here.
|
|
143
|
+
if (resolved !== undefined)
|
|
144
|
+
out.accessibilityState = resolved;
|
|
145
|
+
out.accessible = accessibleUnlessOptedOut(props);
|
|
146
|
+
// Pressable.js:258 — the plain form, with no press-handler or disabled leg. A Touchable composing
|
|
147
|
+
// this tag has already resolved its own three-leg formula and passed the answer in as `focusable`,
|
|
148
|
+
// which `!== false` leaves alone.
|
|
149
|
+
out.focusable = resolvePressableFocusable(booleanOr(props.focusable));
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
// RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
|
|
153
|
+
// (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
|
|
154
|
+
// (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
|
|
155
|
+
// only a literal `false` opts out and an explicit `undefined` still reads as accessible.
|
|
156
|
+
//
|
|
157
|
+
// Nothing in this repo did it until 2026-09-09, on either path, so a Pressable reached a screen
|
|
158
|
+
// reader as a plain view unless the app wrote the prop. Landing it in the fold above alone reddens
|
|
159
|
+
// four equivalence arms — correctly, since those compare the wrapper against the lowered path — so
|
|
160
|
+
// the behavior and every adapter's wrapper have to move in ONE change. Exported for the wrappers
|
|
161
|
+
// that need to say it themselves, and for the tags whose behavior is their only path.
|
|
162
|
+
export function accessibleUnlessOptedOut(props) {
|
|
163
|
+
return props.accessible !== false;
|
|
164
|
+
}
|
|
165
|
+
// From the STASH, not from `node.props`. Every name below is in `ownedListeners`, so `routeProp`
|
|
166
|
+
// diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
|
|
167
|
+
// dispatcher) and into the stash — which makes the stash the only place it exists. Reading
|
|
168
|
+
// `node.props` here returns undefined for every callback and every press silently does nothing:
|
|
169
|
+
// the behavior runs, the machine runs, and it calls nobody.
|
|
170
|
+
function callbackAt(node, event) {
|
|
171
|
+
const value = appListenerFor(node, event);
|
|
172
|
+
return isPressHandler(value) ? value : undefined;
|
|
173
|
+
}
|
|
174
|
+
// Callbacks come from `callbackAt` (the stash), scalars from `node.props`. The split is not
|
|
175
|
+
// cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
|
|
176
|
+
// keys, while `onPress` and friends are OWNED event names that never reach `node.props` at all.
|
|
177
|
+
function configFor(node) {
|
|
178
|
+
return {
|
|
179
|
+
onPress: callbackAt(node, 'press'),
|
|
180
|
+
onPressIn: callbackAt(node, 'pressIn'),
|
|
181
|
+
onPressOut: callbackAt(node, 'pressOut'),
|
|
182
|
+
onPressMove: callbackAt(node, 'pressMove'),
|
|
183
|
+
onLongPress: callbackAt(node, 'longPress'),
|
|
184
|
+
delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
|
|
185
|
+
unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
|
|
186
|
+
// RN's Touchables own the deactivation floor in their OWN machine and hand Pressability
|
|
187
|
+
// `minPressDuration: 0` (TouchableOpacity.js:195). While they were wrappers they passed it as
|
|
188
|
+
// an internal input; on the tag there is nowhere else to say it, so the floor has to be a
|
|
189
|
+
// readable prop or every Touchable holds its fade for the machine's 130 ms default.
|
|
190
|
+
minPressDuration: numberOr(node.props.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
|
|
191
|
+
hitSlop: asRectOffset(node.props.hitSlop),
|
|
192
|
+
pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
|
|
196
|
+
// is a bug that looks like working code. `attach` runs inside `createElement`, before a single
|
|
197
|
+
// prop has been routed — `node.props` is literally `{}` there — so a machine built at attach would
|
|
198
|
+
// capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
|
|
199
|
+
// destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
|
|
200
|
+
// once the props exist. A gesture is one interaction, so a handful of closures per press is
|
|
201
|
+
// invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
|
|
202
|
+
function rebuild(node, state) {
|
|
203
|
+
// `state.source` for everything READ, `node` for the refinement, which acts on the responder
|
|
204
|
+
// (dispatching a view command needs the committed node, not the one holding the props).
|
|
205
|
+
const source = state.source;
|
|
206
|
+
const base = configFor(source);
|
|
207
|
+
const handlers = createPressHandlers(state.refine === undefined ? base : state.refine(node, base), state.runtime, state.host);
|
|
208
|
+
state.isBuilt = true;
|
|
209
|
+
// Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
|
|
210
|
+
// `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
|
|
211
|
+
const disabled = state.disabledOf === undefined
|
|
212
|
+
? source.props.disabled
|
|
213
|
+
: state.disabledOf(source.props);
|
|
214
|
+
state.listeners = buildPressableListeners(handlers, {
|
|
215
|
+
disabled: disabled === true ? true : undefined,
|
|
216
|
+
cancelable: typeof source.props.cancelable === 'boolean'
|
|
217
|
+
? source.props.cancelable
|
|
218
|
+
: undefined,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
// WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
|
|
222
|
+
// `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
|
|
223
|
+
// bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
|
|
224
|
+
// gesture. Rebuilding only on the responder claim therefore handed the first `pressIn` an empty
|
|
225
|
+
// listener bag — the press-in half of every press was dropped, the pressed style never reached
|
|
226
|
+
// Fabric, and `onPress` still fired because by then the machine existed. That is exactly the
|
|
227
|
+
// device report: the callback works, the button does not light up.
|
|
228
|
+
//
|
|
229
|
+
// So the trigger is a FLAG, not a name: build if this gesture has not built yet, and clear it when
|
|
230
|
+
// the gesture ends. Order-independent, and it survives the engine reordering its own events.
|
|
231
|
+
//
|
|
232
|
+
// A key `buildPressableListeners` omitted — every one of them when `disabled` is true — resolves to
|
|
233
|
+
// undefined here and the dispatcher returns undefined, which is what an absent listener would do.
|
|
234
|
+
const GESTURE_END_KEYS = new Set([
|
|
235
|
+
'onPressOut',
|
|
236
|
+
'onResponderTerminationRequest',
|
|
237
|
+
]);
|
|
238
|
+
function dispatch(node, state, key, args) {
|
|
239
|
+
if (!state.isBuilt)
|
|
240
|
+
rebuild(node, state);
|
|
241
|
+
const listener = state.listeners[key];
|
|
242
|
+
const result = isPressHandler(listener)
|
|
243
|
+
? Reflect.apply(listener, undefined, args)
|
|
244
|
+
: undefined;
|
|
245
|
+
// AFTER the handler, not before: `handlePressOut` is what settles the machine, and clearing the
|
|
246
|
+
// flag first would let a re-entrant dispatch rebuild mid-gesture.
|
|
247
|
+
if (GESTURE_END_KEYS.has(key))
|
|
248
|
+
state.isBuilt = false;
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
// Installed straight into the listener slot rather than through `routeProp`: the behavior OWNS
|
|
252
|
+
// these names, and `setEventListener` diverts an owned name into the app stash — routing its own
|
|
253
|
+
// dispatcher through there would stash it and leave the slot empty.
|
|
254
|
+
//
|
|
255
|
+
// The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
|
|
256
|
+
// spelling, so the two are mapped here rather than guessed at either end.
|
|
257
|
+
const KEY_BY_EVENT = new Map([
|
|
258
|
+
['press', 'onPress'],
|
|
259
|
+
['pressIn', 'onPressIn'],
|
|
260
|
+
['pressOut', 'onPressOut'],
|
|
261
|
+
['startShouldSetResponder', 'onStartShouldSetResponder'],
|
|
262
|
+
['responderMove', 'onResponderMove'],
|
|
263
|
+
['responderTerminationRequest', 'onResponderTerminationRequest'],
|
|
264
|
+
]);
|
|
265
|
+
function installListeners(node, state) {
|
|
266
|
+
for (const [event, key] of KEY_BY_EVENT) {
|
|
267
|
+
setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function attachWith(refine, disabledOf) {
|
|
271
|
+
return node => attach(node, { refine, disabledOf });
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The machine on `node`, reading its props and the app's callbacks off `options.source` when that
|
|
275
|
+
* is a different node.
|
|
276
|
+
*
|
|
277
|
+
* Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
|
|
278
|
+
* whose tag commits nothing and adopts the app's single child as the responder. Every other caller
|
|
279
|
+
* goes through `createPressBehavior`, where source and node are the same.
|
|
280
|
+
*
|
|
281
|
+
* Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
|
|
282
|
+
* a re-arm after `detachPressMachine` needs.
|
|
283
|
+
*/
|
|
284
|
+
export function attachPressMachine(node, options = {}) {
|
|
285
|
+
attach(node, options);
|
|
286
|
+
}
|
|
287
|
+
function attach(node, options) {
|
|
288
|
+
const timers = new Set();
|
|
289
|
+
const runtime = createPressRuntime();
|
|
290
|
+
const host = {
|
|
291
|
+
// The whole reason this tier is possible: the pressed state resolves BELOW the framework,
|
|
292
|
+
// through the style registry's `:active` variant, and never crosses into it.
|
|
293
|
+
setPressed: pressed => {
|
|
294
|
+
setNodePressed(node, pressed);
|
|
295
|
+
// Dirtying is not publishing. A press arrives from a native event, outside every renderer
|
|
296
|
+
// mutation path, so nothing schedules a commit — `native-events.ts` requests none, and no
|
|
297
|
+
// adapter does either. `setNodeHidden`'s React twin never hit this because the reconciler is
|
|
298
|
+
// already in its commit phase when it calls.
|
|
299
|
+
requestCommitFor(node);
|
|
300
|
+
},
|
|
301
|
+
// `measure` needs a committed Fabric tag, which a node has by the time a human can touch it.
|
|
302
|
+
// Returning undefined before then makes the machine fall back to its radius test rather than
|
|
303
|
+
// throwing, which is the behaviour the component path already relies on.
|
|
304
|
+
getMeasureFn: () => callback => node.measure(callback),
|
|
305
|
+
// Tracked so `detach` can cancel them. An un-cancelled long-press timer outlives the tree that
|
|
306
|
+
// owned it, and `removeChild` visits only a subtree ROOT — a pressable is normally nested.
|
|
307
|
+
schedule: (callback, ms) => {
|
|
308
|
+
const id = setTimeout(() => {
|
|
309
|
+
timers.delete(id);
|
|
310
|
+
callback();
|
|
311
|
+
}, ms);
|
|
312
|
+
timers.add(id);
|
|
313
|
+
return () => {
|
|
314
|
+
clearTimeout(id);
|
|
315
|
+
timers.delete(id);
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
now: Date.now,
|
|
319
|
+
};
|
|
320
|
+
const state = {
|
|
321
|
+
runtime,
|
|
322
|
+
host,
|
|
323
|
+
refine: options.refine,
|
|
324
|
+
disabledOf: options.disabledOf,
|
|
325
|
+
source: options.source ?? node,
|
|
326
|
+
timers,
|
|
327
|
+
listeners: {},
|
|
328
|
+
isBuilt: false,
|
|
329
|
+
};
|
|
330
|
+
states.set(node, state);
|
|
331
|
+
installListeners(node, state);
|
|
332
|
+
}
|
|
333
|
+
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
334
|
+
export function detachPressMachine(node) {
|
|
335
|
+
detach(node);
|
|
336
|
+
}
|
|
337
|
+
function detach(node) {
|
|
338
|
+
const state = states.get(node);
|
|
339
|
+
if (state === undefined)
|
|
340
|
+
return;
|
|
341
|
+
// The machine's own teardown, which every wrapper calls from its destroy hook. Not load-bearing
|
|
342
|
+
// here and no test can make it so: `host.schedule` puts every timer the machine arms into
|
|
343
|
+
// `state.timers` — the 130ms floor's deferred `pressOut` included — so the loop below already
|
|
344
|
+
// cancels them. Kept as the contract, and for a timer armed by some future route.
|
|
345
|
+
disposePressRuntime(state.runtime);
|
|
346
|
+
for (const id of state.timers)
|
|
347
|
+
clearTimeout(id);
|
|
348
|
+
state.timers.clear();
|
|
349
|
+
states.delete(node);
|
|
350
|
+
dlog('pressable behavior detached');
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
|
|
354
|
+
* instead of re-implementing it.
|
|
355
|
+
*
|
|
356
|
+
* Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
|
|
357
|
+
* family needs a per-node Animated value opened before the machine and closed after it. The
|
|
358
|
+
* WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
|
|
359
|
+
* these; a tag composing it therefore must not also register the plain `pressable` behavior.
|
|
360
|
+
*/
|
|
361
|
+
export function createPressBehavior(refine, disabledOf) {
|
|
362
|
+
return {
|
|
363
|
+
attach: attachWith(refine, disabledOf),
|
|
364
|
+
detach,
|
|
365
|
+
foldPayload,
|
|
366
|
+
// Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
|
|
367
|
+
// gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
|
|
368
|
+
// of ViewConfig, so they collide exactly like `press` does.
|
|
369
|
+
ownedListeners: [
|
|
370
|
+
'press',
|
|
371
|
+
'pressIn',
|
|
372
|
+
'pressOut',
|
|
373
|
+
'pressMove',
|
|
374
|
+
'longPress',
|
|
375
|
+
'startShouldSetResponder',
|
|
376
|
+
'responderMove',
|
|
377
|
+
'responderTerminationRequest',
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
382
|
+
// same tag with an equivalent behavior must not double-install anything.
|
|
383
|
+
export function registerPressableBehavior() {
|
|
384
|
+
registerHostBehavior(PRESSABLE_TAG, createPressBehavior());
|
|
385
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// RefreshControl's machine, on the engine node instead of inside a framework component.
|
|
2
|
+
//
|
|
3
|
+
// WHAT THE FIVE WRAPPERS ACTUALLY DO, counted before writing this — the audit rule's instruction to
|
|
4
|
+
// grep the fold's OUTPUT rather than trust one wrapper. Four of the five (react, vue, solid,
|
|
5
|
+
// svelte) fold exactly `resolveAccessibilityProps` and forward, and that fold ALREADY runs in the
|
|
6
|
+
// engine at `fabricProps` on every path (the aria fold `SafeAreaView`'s spec entry cites). So there
|
|
7
|
+
// is nothing left for a `foldPayload` here to do, and this behavior deliberately declares none.
|
|
8
|
+
//
|
|
9
|
+
// The fifth is Angular, and it is the whole reason this file exists: it alone reproduces RN's
|
|
10
|
+
// CONTROLLED handshake (`RefreshControl.js:145-166`) — mirror what native last reported, and when
|
|
11
|
+
// the app's `refreshing` disagrees, command native back with `setNativeRefreshing`. React, Vue,
|
|
12
|
+
// Solid and Svelte have never had it, so a pull whose handler leaves `refreshing` false spins
|
|
13
|
+
// forever on four of five adapters. Moving it here closes that as a P0 parity gap rather than
|
|
14
|
+
// porting it four more times.
|
|
15
|
+
//
|
|
16
|
+
// ONE COMMAND NAME ON BOTH PLATFORMS — no `Platform.OS` branch, unlike Switch's snap-back
|
|
17
|
+
// (`setValue` / `setNativeValue`). RN sends `setNativeRefreshing` through both
|
|
18
|
+
// `PullToRefreshCommands` and `AndroidSwipeRefreshLayoutCommands` (`RefreshControl.js:152,157`).
|
|
19
|
+
//
|
|
20
|
+
// PLACEMENT IS NOT THIS FILE'S PROBLEM, though the tier audit once filed the primitive as
|
|
21
|
+
// impossible over it. iOS puts the control BESIDE the scroll view's content and Android makes it
|
|
22
|
+
// the scroll view's PARENT, and that decision belongs to the ScrollView, which now states it as
|
|
23
|
+
// data — `claimedChildren: { [REFRESH_CONTROL]: platform.claimMode }` in
|
|
24
|
+
// `behaviors/scroll-view/shared.ts`, honoured by the engine's `appendChild`. A claim is keyed on
|
|
25
|
+
// the child's FABRIC name and needs nothing from the child's own behavior, so the two are
|
|
26
|
+
// independent; `refresh-control-placement.test.ts` pins that both ways round.
|
|
27
|
+
//
|
|
28
|
+
// WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, and why `afterCommit` alone is not enough:
|
|
29
|
+
// `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
|
|
30
|
+
// an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
|
|
31
|
+
// app writes no prop at all, so the commit that `afterCommit` waits for never comes.
|
|
32
|
+
import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
|
|
33
|
+
export const REFRESH_CONTROL_TAG = 'refresh-control';
|
|
34
|
+
// RN's own name for the command, sent to whichever of the two native views the platform resolved.
|
|
35
|
+
const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
|
|
36
|
+
// What native LAST reported, absent until it has reported at all. Absent is not `false`: native is
|
|
37
|
+
// optimistic — it spins on the gesture before JS approves — so only a report can make the mirror
|
|
38
|
+
// authoritative, and an app that drives `refreshing` on its own initiative must never be corrected
|
|
39
|
+
// against a value native never claimed.
|
|
40
|
+
const reported = new WeakMap();
|
|
41
|
+
// Shared by both triggers — see the module header for why there are two.
|
|
42
|
+
function evaluateSnapBack(node) {
|
|
43
|
+
const lastNativeReport = reported.get(node);
|
|
44
|
+
if (lastNativeReport === undefined)
|
|
45
|
+
return; // no report yet, nothing to disagree with
|
|
46
|
+
const refreshing = node.props.refreshing === true;
|
|
47
|
+
if (lastNativeReport === refreshing) {
|
|
48
|
+
dlog(`RefreshControl behavior snap-back no-op refreshing=${refreshing}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
dlog(`RefreshControl behavior ${SET_NATIVE_REFRESHING} reported=${lastNativeReport} refreshing=${refreshing}`);
|
|
52
|
+
dispatchViewCommand(node, SET_NATIVE_REFRESHING, [refreshing]);
|
|
53
|
+
reported.set(node, refreshing);
|
|
54
|
+
}
|
|
55
|
+
function onRefresh(node, event) {
|
|
56
|
+
// Native has already started spinning by the time this arrives (RefreshControl.js:180), so the
|
|
57
|
+
// mirror moves BEFORE the app's handler runs — a handler that flips `refreshing` to true then
|
|
58
|
+
// agrees with it, and one that does nothing is what the deferred check corrects.
|
|
59
|
+
reported.set(node, true);
|
|
60
|
+
const listener = appListenerFor(node, 'refresh');
|
|
61
|
+
if (typeof listener === 'function')
|
|
62
|
+
listener(event);
|
|
63
|
+
queueMicrotask(() => evaluateSnapBack(node));
|
|
64
|
+
}
|
|
65
|
+
function attach(node) {
|
|
66
|
+
setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
|
|
67
|
+
}
|
|
68
|
+
function detach(node) {
|
|
69
|
+
reported.delete(node);
|
|
70
|
+
}
|
|
71
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
72
|
+
// same tag with an equivalent behavior must not double-install anything.
|
|
73
|
+
export function registerRefreshControlBehavior() {
|
|
74
|
+
registerHostBehavior(REFRESH_CONTROL_TAG, {
|
|
75
|
+
attach,
|
|
76
|
+
// Closes the case a microtask scheduled from `onRefresh` cannot: the app moves `refreshing` on
|
|
77
|
+
// its own while a past report is still unresolved. Costs nothing extra — it fires only on a
|
|
78
|
+
// commit that already changed something.
|
|
79
|
+
afterCommit: evaluateSnapBack,
|
|
80
|
+
detach,
|
|
81
|
+
ownedListeners: ['refresh'],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerScrollViewBehavior(): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ScrollView's behavior on Android, where a RefreshControl is not a child at all.
|
|
2
|
+
//
|
|
3
|
+
// An Android ScrollView holds exactly ONE child, so a sibling refresh control is an `addViewAt`
|
|
4
|
+
// crash rather than a layout mistake. RN inverts the tree instead: `AndroidSwipeRefreshLayout`
|
|
5
|
+
// WRAPS the scroll view, and the scroll view's style is split across the two boxes — layout on the
|
|
6
|
+
// wrapper's frame, visual on the scroller (`ScrollView.js:1856`). `nestedScrollEnabled` goes on the
|
|
7
|
+
// inner view so it consumes the gesture before the refresh parent sees it.
|
|
8
|
+
//
|
|
9
|
+
// The two folds below are what neither node can work out alone: the wrapper is the APP's node, so
|
|
10
|
+
// it carries whatever the app wrote on `<RefreshControl>` and knows nothing about the scroll view's
|
|
11
|
+
// style. RN reaches the same place through `cloneElement`, which likewise OVERRIDES the refresh
|
|
12
|
+
// control's own `style` — so replacing it here is parity, not a liberty.
|
|
13
|
+
import {} from '@symbiote-native/engine';
|
|
14
|
+
import { splitScrollViewStyle } from '../../scroll-view-commands.js';
|
|
15
|
+
import { ownerFold, registerScrollViewBehaviors, } from './shared.js';
|
|
16
|
+
// The owner under a wrap: the ordinary fold with the VISUAL half of its own style in place of the
|
|
17
|
+
// composed one. Delegating rather than restating is what keeps `decelerationRate`, `horizontal` and
|
|
18
|
+
// `nestedScrollEnabled` in ONE place — none of the three has anything to do with the wrap, and the
|
|
19
|
+
// hand-written copy this replaced had already lost the first of them.
|
|
20
|
+
function wrappedOwnerFold(base, horizontal) {
|
|
21
|
+
const plain = ownerFold(base, horizontal);
|
|
22
|
+
return props => ({
|
|
23
|
+
...plain(props),
|
|
24
|
+
style: splitScrollViewStyle(base, props.style).inner,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// The wrapper: the LAYOUT half of the OWNER's style, read off the owner because that is where the
|
|
28
|
+
// app wrote it. Kept in step by `slotDerived` naming `style`, which marks the wrapper dirty on an
|
|
29
|
+
// owner style write.
|
|
30
|
+
function wrapperFold(owner, base) {
|
|
31
|
+
return props => ({
|
|
32
|
+
...props,
|
|
33
|
+
style: splitScrollViewStyle(base, owner.props.style).outer,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const android = {
|
|
37
|
+
claimMode: 'wrap',
|
|
38
|
+
slotDerived: ['style'],
|
|
39
|
+
onWrapChange: (base, horizontal) => (owner, wrapper) => {
|
|
40
|
+
// Back to the ordinary composition, not to `undefined` — the plain fold carries the axis and
|
|
41
|
+
// the gesture props, which have nothing to do with the wrap.
|
|
42
|
+
owner.payloadFold =
|
|
43
|
+
wrapper === undefined
|
|
44
|
+
? ownerFold(base, horizontal)
|
|
45
|
+
: wrappedOwnerFold(base, horizontal);
|
|
46
|
+
if (wrapper !== undefined)
|
|
47
|
+
wrapper.payloadFold = wrapperFold(owner, base);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
export function registerScrollViewBehavior() {
|
|
51
|
+
registerScrollViewBehaviors(android);
|
|
52
|
+
}
|