@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 @@
|
|
|
1
|
+
export declare function registerScrollViewBehavior(): void;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ScrollView's behavior on iOS: a RefreshControl is a SIBLING of the content view, rendered before
|
|
2
|
+
// it (`ScrollView.js:1844`). That is the whole platform half — a claim in `beside` mode, and the
|
|
3
|
+
// engine's placement rule already puts a claimed child before the slot.
|
|
4
|
+
//
|
|
5
|
+
// This file is also the base the folder's `index.ts` re-exports for headless, matching
|
|
6
|
+
// `render-scroll-view`'s own choice to make iOS the default for anything platform-branched.
|
|
7
|
+
import { registerScrollViewBehaviors } from './shared.js';
|
|
8
|
+
export function registerScrollViewBehavior() {
|
|
9
|
+
registerScrollViewBehaviors({ claimMode: 'beside' });
|
|
10
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The base of the folder-as-module group: Metro picks `index.ios` / `index.android` per platform,
|
|
2
|
+
// and everything else (tsx, vitest, headless) lands here. iOS is the default for the same reason
|
|
3
|
+
// `render-scroll-view`'s own `Platform.select` defaults to it.
|
|
4
|
+
export { registerScrollViewBehavior } from './index.ios.js';
|
|
5
|
+
export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type IClaimMode, type IHostBehavior, type IPayloadFold, type IViewStyle } from '@symbiote-native/engine';
|
|
2
|
+
export declare const SCROLL_VIEW_TAG = "scroll-view";
|
|
3
|
+
export declare const HORIZONTAL_SCROLL_VIEW_TAG = "horizontal-scroll-view";
|
|
4
|
+
export declare const REFRESH_CONTROL: string;
|
|
5
|
+
export declare function ownerFold(base: IViewStyle, horizontal: boolean): IPayloadFold;
|
|
6
|
+
export interface IScrollPlatform {
|
|
7
|
+
claimMode: IClaimMode;
|
|
8
|
+
onWrapChange?: (base: IViewStyle, horizontal: boolean) => IHostBehavior['onWrapChange'];
|
|
9
|
+
slotDerived?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function registerScrollViewBehaviors(platform: IScrollPlatform): void;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// ScrollView's host behavior, the platform-invariant half — and the pilot for four of the engine's
|
|
2
|
+
// composed-primitive seams: `buildStructure` + `childHost`, `slotProps`, `slotDerived`, and
|
|
3
|
+
// `claimedChildren`.
|
|
4
|
+
//
|
|
5
|
+
// THE PLATFORM HALF IS THE REFRESHCONTROL, and only that. `index.ios` claims it `beside` the
|
|
6
|
+
// content view; `index.android` claims it as a `wrap`, because an Android ScrollView holds exactly
|
|
7
|
+
// one child. Everything else here is shared, including the tags, the folds and the content-size
|
|
8
|
+
// synthesis.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IS WIRED. Structure, the style compositions, `decelerationRate` resolution,
|
|
11
|
+
// `collapsableChildren`, the synthesized `onContentSizeChange`, the RefreshControl on both
|
|
12
|
+
// platforms — and, since the sticky half landed, the raised `scrollEventThrottle`, the scroll
|
|
13
|
+
// value that drives the pins, the owner layout an inverted pin needs, and the per-commit walk that
|
|
14
|
+
// turns `stickyHeaderIndices` into those same headers. The sticky machinery itself lives in
|
|
15
|
+
// `./sticky`, because a `<StickyHeader>` is a CHILD and the three props above are functions of
|
|
16
|
+
// whether one registered.
|
|
17
|
+
//
|
|
18
|
+
// WHAT A COMPOSED PRIMITIVE COSTS TODAY. Every adapter's ScrollView wrapper builds the same two
|
|
19
|
+
// nodes: `selectScrollIntrinsics` picks a scroll intrinsic and a content intrinsic, and the
|
|
20
|
+
// wrapper's body nests `<content>{children}</content>` inside `<scroll>`. That body is a framework
|
|
21
|
+
// component instance per ScrollView — a Vue instance, a Solid props Proxy, Svelte anchors, an
|
|
22
|
+
// Angular LView — which is precisely the currency host-primitive lowering exists to delete
|
|
23
|
+
// (`.claude/rules/host-primitive-tier.md`). `foldPayload` gave a lowered primitive its wrapper's
|
|
24
|
+
// PROP MAPPING; nothing gave it the wrapper's COMPOSITION, so a composed primitive could not be
|
|
25
|
+
// lowered at all no matter what its props did. This is that half.
|
|
26
|
+
//
|
|
27
|
+
// WHY THE TAG CARRIES THE AXIS. `buildStructure` runs at `createElement`, before a single prop is
|
|
28
|
+
// routed, so it cannot read `horizontal`. It does not need to: horizontal scroll is already a
|
|
29
|
+
// SEPARATE intrinsic (`horizontal-scroll-view` — a different native ViewManager on
|
|
30
|
+
// Android, not RCTScrollView with a flag), so the decision the behavior needs is in the tag it was
|
|
31
|
+
// looked up by. One behavior per tag, each knowing its own content intrinsic. That is the same
|
|
32
|
+
// shape `intrinsicWhen` gives TextInput's `multiline`, arrived at from the other side.
|
|
33
|
+
//
|
|
34
|
+
// REGISTERED BY SVELTE SINCE 2026-09-07 (`adapters/svelte/src/register.ts`), and by no other
|
|
35
|
+
// adapter — this paragraph read "NOT REGISTERED BY ANY ADAPTER" for three days after that stopped
|
|
36
|
+
// being true. The hazard still holds for the four that have not registered: `scroll-view` is the
|
|
37
|
+
// tag their WRAPPERS emit, and a wrapper builds its own content node from `selectScrollIntrinsics`,
|
|
38
|
+
// as does `VirtualizedList`. Registering while either stands silently gives those trees a SECOND
|
|
39
|
+
// content node: `RCTScrollView > RCTScrollContentView > RCTScrollContentView`. So the precondition
|
|
40
|
+
// per adapter is that nothing else builds the content node.
|
|
41
|
+
//
|
|
42
|
+
// A SECOND TAG IS NOT THE ANSWER, and this reverses what this header said until 2026-09-07. The
|
|
43
|
+
// `text-input` / `text-input-managed` split is debt with a deletion date, not a technique
|
|
44
|
+
// (`.claude/rules/fold-only-primitive-recipe.md` §4): each pair exists only to keep two owners
|
|
45
|
+
// apart while a wrapper and a lowered element both emit a tag, and minting one here would buy a
|
|
46
|
+
// rename across every call site now and a second rename when the wrapper dies. The owner's decision
|
|
47
|
+
// is that the ENGINE becomes the single owner of the content node — every adapter's list and
|
|
48
|
+
// wrapper stops building one — so registration waits on that cut rather than on a new spelling.
|
|
49
|
+
// That cut LANDED 2026-09-11: no adapter's wrapper or list builds a content node any more, and all
|
|
50
|
+
// five register this behavior through `@symbiote-native/components/register`.
|
|
51
|
+
//
|
|
52
|
+
// STYLE, on both nodes, and the precedence is the part that is easy to get silently wrong. The
|
|
53
|
+
// wrapper composes exactly two arrays, and this reproduces both:
|
|
54
|
+
//
|
|
55
|
+
// owner [scrollViewBaseStyle, style] base UNDER the app's, so an explicit
|
|
56
|
+
// flexDirection still wins
|
|
57
|
+
// slot [contentContainerStyle, {flexDirection:'row'}] row OVER the app's, on horizontal only
|
|
58
|
+
//
|
|
59
|
+
// Which is why the two halves use different seams rather than one. `contentContainerStyle` is
|
|
60
|
+
// written by the app on the OWNER and belongs to the slot, so it travels through `slotProps` — a
|
|
61
|
+
// pure RENAME (`contentContainerStyle` -> the slot's `style`) that goes through the slot's own
|
|
62
|
+
// `routeProp` and inherits style merging, class merging and the already-published guard. The
|
|
63
|
+
// CONSTANT half is a `payloadFold`, because a fold is where precedence can be expressed: the
|
|
64
|
+
// owner's puts the base first, the slot's puts the row direction last. A redirect that also tried
|
|
65
|
+
// to compose would have to pick one order for both.
|
|
66
|
+
//
|
|
67
|
+
// The slot's fold is assigned to the node inside `buildStructure`, not declared on the behavior:
|
|
68
|
+
// `IHostBehavior.foldPayload` is the OWNER's, wired by `attachHostBehavior`, and a behavior that
|
|
69
|
+
// builds a node owns what that node carries.
|
|
70
|
+
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, } from '@symbiote-native/engine';
|
|
71
|
+
import { descriptorFor } from '../../component-names';
|
|
72
|
+
import { didContentSizeChange, preservesContentChildren, readLayoutDimension, resolveDecelerationRate, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from '../../view/render-scroll-view.js';
|
|
73
|
+
import { handleOwnerScroll, markScrollOwner, reconcileStickyIndices, releaseStickyOwner, stickyHeaderBehavior, STICKY_HEADER_TAG, syncOwnerLayout, } from './sticky.js';
|
|
74
|
+
export const SCROLL_VIEW_TAG = 'scroll-view';
|
|
75
|
+
export const HORIZONTAL_SCROLL_VIEW_TAG = 'horizontal-scroll-view';
|
|
76
|
+
// The app writes it on the ScrollView; it styles the content view. One entry, and it is the whole
|
|
77
|
+
// reason `slotProps` exists.
|
|
78
|
+
const SLOT_PROPS = {
|
|
79
|
+
contentContainerStyle: 'style',
|
|
80
|
+
};
|
|
81
|
+
// Owner props the SLOT's payload reads. Declared so a write to one dirties the slot — see
|
|
82
|
+
// `IHostBehavior.slotDerived` for why nothing else makes that happen.
|
|
83
|
+
const SLOT_DERIVED = ['maintainVisibleContentPosition', 'snapToAlignment'];
|
|
84
|
+
// A `<RefreshControl>` written among the app's children is claimed, and WHAT the owner does with
|
|
85
|
+
// it is the one thing that genuinely differs per platform — see the platform files. Resolved
|
|
86
|
+
// through `descriptorFor`, so this is `PullToRefreshView` on iOS and `AndroidSwipeRefreshLayout`
|
|
87
|
+
// on Android without either name appearing here.
|
|
88
|
+
export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
|
|
89
|
+
// The OWNER's fold: the per-axis base style UNDER the app's (so an explicit `flexDirection` still
|
|
90
|
+
// wins), `decelerationRate` resolved from RN's two words to the platform's friction constant, and
|
|
91
|
+
// the two props a lowered element has no wrapper to write for it. The resolution has to happen here
|
|
92
|
+
// because 'normal'/'fast' reach Fabric as strings it cannot read.
|
|
93
|
+
//
|
|
94
|
+
// `horizontal` is a real C++ prop (`BaseScrollViewProps.h:56`) and the separate ViewManager is
|
|
95
|
+
// ANDROID's — on iOS both tags resolve to RCTScrollView, so the PROP is what turns the axis there
|
|
96
|
+
// and a bare `<horizontal-scroll-view>` would otherwise scroll vertically. Written from the tag
|
|
97
|
+
// rather than read off props, which is the same source `buildStructure` picked the content
|
|
98
|
+
// intrinsic from; an app that also writes `horizontal` on the vertical tag is contradicting the
|
|
99
|
+
// element it chose, and the tag wins.
|
|
100
|
+
//
|
|
101
|
+
// `nestedScrollEnabled` defaults ON because every wrapper writes it on every ScrollView, both
|
|
102
|
+
// platforms. RN itself only defaults it on the Android RefreshControl WRAP path
|
|
103
|
+
// (`ScrollView.js:1862`) — parity here is with the wrapper, which is what the lowered path replaces.
|
|
104
|
+
export function ownerFold(base, horizontal) {
|
|
105
|
+
return props => {
|
|
106
|
+
const next = {
|
|
107
|
+
...props,
|
|
108
|
+
style: [base, props.style],
|
|
109
|
+
nestedScrollEnabled: props.nestedScrollEnabled ?? true,
|
|
110
|
+
};
|
|
111
|
+
// The tag is the ONLY axis input, which is what keeps the three halves of the axis from
|
|
112
|
+
// disagreeing. RN derives all three from one prop, so a mismatch is unrepresentable there, and
|
|
113
|
+
// on iOS both tags really are RCTScrollView — a stray `horizontal` would turn a vertical
|
|
114
|
+
// scroller over a content node with no row style, a shape RN cannot produce.
|
|
115
|
+
if (props.horizontal !== undefined && props.horizontal !== horizontal) {
|
|
116
|
+
dlog(`ScrollView: horizontal=${String(props.horizontal)} ignored — the axis comes from the ` +
|
|
117
|
+
`tag; write <${horizontal ? SCROLL_VIEW_TAG : HORIZONTAL_SCROLL_VIEW_TAG}> instead`);
|
|
118
|
+
}
|
|
119
|
+
delete next.horizontal;
|
|
120
|
+
if (horizontal)
|
|
121
|
+
next.horizontal = true;
|
|
122
|
+
// The bounce pair is the axis's other consequence (`ScrollView.js:1753-1761`), ASYMMETRIC
|
|
123
|
+
// because RN falls back to `this.props.horizontal` — unset on a vertical view, so the
|
|
124
|
+
// horizontal key resolves to undefined and never reaches the payload. Both names are declared
|
|
125
|
+
// in every adapter's prop type and computed in none: vertical never bounced by default.
|
|
126
|
+
if (props.alwaysBounceHorizontal === undefined && horizontal)
|
|
127
|
+
next.alwaysBounceHorizontal = true;
|
|
128
|
+
if (props.alwaysBounceVertical === undefined)
|
|
129
|
+
next.alwaysBounceVertical = !horizontal;
|
|
130
|
+
// Consumed by the behavior and declared by no ViewConfig — neither name appears anywhere under
|
|
131
|
+
// `ReactCommon/react/renderer/components/scrollview`. `stickyHeaderIndices` decides which
|
|
132
|
+
// children get a `sticky-header`, `invertStickyHeaders` feeds the pin. Every wrapper strips both
|
|
133
|
+
// (Vue's `HANDLED_ATTRS` is the reference list), and a key Fabric does not know throws nothing,
|
|
134
|
+
// logs nothing and paints nothing — so the strip has to be here or it is never noticed.
|
|
135
|
+
delete next.stickyHeaderIndices;
|
|
136
|
+
delete next.invertStickyHeaders;
|
|
137
|
+
const rate = props.decelerationRate;
|
|
138
|
+
if (rate === 'normal' || rate === 'fast' || typeof rate === 'number')
|
|
139
|
+
next.decelerationRate = resolveDecelerationRate(rate);
|
|
140
|
+
return next;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// The SLOT's fold. Two halves with different sources, which is why it takes the owner:
|
|
144
|
+
//
|
|
145
|
+
// rowStyle a CONSTANT, horizontal only, composed OVER the app's contentContainerStyle
|
|
146
|
+
// (the wrapper writes `[contentContainerStyle, {flexDirection:'row'}]`)
|
|
147
|
+
// collapsableChildren DERIVED from props that stay on the OWNER, so it is read back off it
|
|
148
|
+
//
|
|
149
|
+
// Written only when false, matching every wrapper — RN sends `collapsableChildren={!preserveChildren}`
|
|
150
|
+
// and therefore an explicit `true`, which is the native default anyway.
|
|
151
|
+
function contentFold(owner, rowStyle) {
|
|
152
|
+
return props => {
|
|
153
|
+
const preserve = preservesContentChildren(owner.props.maintainVisibleContentPosition, owner.props.snapToAlignment);
|
|
154
|
+
// The identity return IPayloadFold's contract asks for: a vertical content view with neither
|
|
155
|
+
// prop set has nothing to add, which is the common case.
|
|
156
|
+
if (rowStyle === undefined && !preserve)
|
|
157
|
+
return props;
|
|
158
|
+
const next = { ...props };
|
|
159
|
+
if (rowStyle !== undefined)
|
|
160
|
+
next.style = [props.style, rowStyle];
|
|
161
|
+
if (preserve)
|
|
162
|
+
next.collapsableChildren = false;
|
|
163
|
+
return next;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function buildContent(contentIntrinsic, rowStyle) {
|
|
167
|
+
return (node) => {
|
|
168
|
+
const descriptor = descriptorFor(contentIntrinsic);
|
|
169
|
+
const content = createElement(descriptor.component, descriptor.isText, contentIntrinsic);
|
|
170
|
+
// The wrapper sets it on every content node, both axes (react's `contentProps`). Yoga may
|
|
171
|
+
// collapse a view that only groups children, and a collapsed content node takes the scroll
|
|
172
|
+
// metrics with it.
|
|
173
|
+
content.props = { collapsable: false };
|
|
174
|
+
content.payloadFold = contentFold(node, rowStyle);
|
|
175
|
+
// Lands directly on the owner, because `node.childHost` is still undefined here: the engine
|
|
176
|
+
// assigns it from what this returns. That ordering is why `buildStructure` RETURNS the slot
|
|
177
|
+
// instead of setting the field itself — a behavior that set it first would redirect its own
|
|
178
|
+
// structure into the slot it was building.
|
|
179
|
+
appendChild(node, content);
|
|
180
|
+
return content;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// The last size each owner reported, so a layout pass that did not change the content size does not
|
|
184
|
+
// fire the app's handler — RN dedupes the same way (`_handleContentOnLayout`). Off the node: this
|
|
185
|
+
// exists only for the ScrollViews an app wired a handler to.
|
|
186
|
+
const lastContentSize = new WeakMap();
|
|
187
|
+
// RN synthesizes onContentSizeChange from the CONTENT view's own onLayout — there is no native
|
|
188
|
+
// content-size event (ScrollView.js:1675 `contentSizeChangeProps`). The wrapper wired that by
|
|
189
|
+
// rendering an `onLayout` onto its inner node; a lowered element has no inner node of its own, so
|
|
190
|
+
// the behavior installs it on the slot it built.
|
|
191
|
+
//
|
|
192
|
+
// The app's callback takes `(width, height)`, not an event, which is why `contentSizeChange` is an
|
|
193
|
+
// OWNED listener: `setEventListener` wraps an ordinary listener as `(event) => handler(event)` and
|
|
194
|
+
// would call a two-number handler with one event. Owned names are stashed raw instead.
|
|
195
|
+
function contentSizeListener(owner) {
|
|
196
|
+
return (event) => {
|
|
197
|
+
const handler = appListenerFor(owner, 'contentSizeChange');
|
|
198
|
+
if (typeof handler !== 'function')
|
|
199
|
+
return;
|
|
200
|
+
const width = readLayoutDimension(event, 'width');
|
|
201
|
+
const height = readLayoutDimension(event, 'height');
|
|
202
|
+
if (width === undefined || height === undefined)
|
|
203
|
+
return;
|
|
204
|
+
if (!didContentSizeChange(lastContentSize.get(owner) ?? null, {
|
|
205
|
+
width,
|
|
206
|
+
height,
|
|
207
|
+
}))
|
|
208
|
+
return;
|
|
209
|
+
lastContentSize.set(owner, { width, height });
|
|
210
|
+
dlog(`ScrollView onContentSizeChange ${width}x${height}`);
|
|
211
|
+
handler(width, height);
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// RN installs the content `onLayout` only when the app passed `onContentSizeChange`, and so does
|
|
215
|
+
// every wrapper — `onLayout` is a gated event, so wiring it unconditionally would put `onLayout:
|
|
216
|
+
// true` in the payload of every lowered ScrollView's content node and buy a native event nobody
|
|
217
|
+
// reads. A lowering that changes the committed surface in EITHER direction is a bug, so the wiring
|
|
218
|
+
// has to follow the prop.
|
|
219
|
+
//
|
|
220
|
+
// It follows the LISTENER rather than a commit, which is what `onOwnedListenerChange` is for: a
|
|
221
|
+
// listener flip changes no payload by itself, so the commit after it is a no-op and a post-commit
|
|
222
|
+
// hook would never fire. Measured on exactly this — the wire worked (mount commits for other
|
|
223
|
+
// reasons) and the UNWIRE silently did not.
|
|
224
|
+
function syncContentSizeWiring(owner, wired) {
|
|
225
|
+
const slot = owner.childHost;
|
|
226
|
+
if (slot === undefined)
|
|
227
|
+
return;
|
|
228
|
+
if (wired) {
|
|
229
|
+
setEventListener(slot, 'layout', contentSizeListener(owner));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
lastContentSize.delete(owner);
|
|
233
|
+
setEventListener(slot, 'layout', undefined);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Two owned names answer to a flip, and they answer on DIFFERENT nodes: `contentSizeChange` wires
|
|
237
|
+
// the SLOT's layout, `layout` wires the owner's own — which an inverted sticky header also wants,
|
|
238
|
+
// so the two claims are resolved in one place (`syncOwnerLayout`) rather than by whoever wrote last.
|
|
239
|
+
function syncOwnedListener(owner, name, wired) {
|
|
240
|
+
if (name === 'contentSizeChange')
|
|
241
|
+
syncContentSizeWiring(owner, wired);
|
|
242
|
+
else if (name === 'layout')
|
|
243
|
+
syncOwnerLayout(owner);
|
|
244
|
+
}
|
|
245
|
+
function scrollBehavior(contentIntrinsic, base, rowStyle, platform) {
|
|
246
|
+
// The row style is the horizontal tag's constant and nothing else carries it, so it IS the axis —
|
|
247
|
+
// deriving keeps the two from ever disagreeing about which behavior this is.
|
|
248
|
+
const horizontal = rowStyle !== undefined;
|
|
249
|
+
return {
|
|
250
|
+
// `scroll` and `layout` are owned for the collision reason rather than because the behavior
|
|
251
|
+
// consumes them: RN's ScrollView installs `_handleScroll` and `_handleLayout` on the native
|
|
252
|
+
// view unconditionally and calls the app's own handler from inside them, and `node.listeners`
|
|
253
|
+
// is single-slot — so a behavior that installed either without owning it would silently evict
|
|
254
|
+
// the app's.
|
|
255
|
+
ownedListeners: ['contentSizeChange', 'scroll', 'layout'],
|
|
256
|
+
slotProps: SLOT_PROPS,
|
|
257
|
+
slotDerived: [...SLOT_DERIVED, ...(platform.slotDerived ?? [])],
|
|
258
|
+
claimedChildren: { [REFRESH_CONTROL]: platform.claimMode },
|
|
259
|
+
onWrapChange: platform.onWrapChange?.(base, horizontal),
|
|
260
|
+
buildStructure: buildContent(contentIntrinsic, rowStyle),
|
|
261
|
+
foldPayload: ownerFold(base, horizontal),
|
|
262
|
+
// The scroll dispatcher is installed here and never conditionally: it is what drives the
|
|
263
|
+
// sticky AnimatedValue, and a header can register long after this node was created. It costs a
|
|
264
|
+
// forward per scroll event on a ScrollView with no sticky child, which is what RN pays too.
|
|
265
|
+
// Nothing else is taken — no timer, and the two conditional listeners are wired on a flip.
|
|
266
|
+
attach(node) {
|
|
267
|
+
markScrollOwner(node);
|
|
268
|
+
setBehaviorListener(node, 'scroll', event => handleOwnerScroll(node, event));
|
|
269
|
+
},
|
|
270
|
+
onOwnedListenerChange: syncOwnedListener,
|
|
271
|
+
// The one beat at which the app's children are all present — `stickyHeaderIndices` addresses
|
|
272
|
+
// them positionally, and no hook reports a children CHANGE. Costs a Set iteration per commit
|
|
273
|
+
// over the ScrollViews alone, and `reconcileStickyIndices` returns on a WeakSet miss for any
|
|
274
|
+
// that never used the prop.
|
|
275
|
+
afterCommit: reconcileStickyIndices,
|
|
276
|
+
detach(node) {
|
|
277
|
+
lastContentSize.delete(node);
|
|
278
|
+
releaseStickyOwner(node);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Both axes, given the platform's answer to the RefreshControl question. The platform files call
|
|
283
|
+
// this; nothing else should.
|
|
284
|
+
export function registerScrollViewBehaviors(platform) {
|
|
285
|
+
registerHostBehavior(SCROLL_VIEW_TAG, scrollBehavior('scroll-content', SCROLL_VIEW_BASE_VERTICAL, undefined, platform));
|
|
286
|
+
registerHostBehavior(HORIZONTAL_SCROLL_VIEW_TAG, scrollBehavior('horizontal-scroll-content', SCROLL_VIEW_BASE_HORIZONTAL, { flexDirection: 'row' }, platform));
|
|
287
|
+
// With the scroll views, never on its own: a sticky header is meaningless without an owner to
|
|
288
|
+
// find, and registering the pair together is what makes "did the registration run" one question
|
|
289
|
+
// rather than two.
|
|
290
|
+
registerHostBehavior(STICKY_HEADER_TAG, stickyHeaderBehavior);
|
|
291
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type IHostBehavior, type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
|
|
2
|
+
export declare const STICKY_HEADER_TAG = "sticky-header";
|
|
3
|
+
export declare function markScrollOwner(node: ISymbioteNode): void;
|
|
4
|
+
export declare function hasStickyHeaders(owner: ISymbioteNode): boolean;
|
|
5
|
+
export declare function syncOwnerLayout(owner: ISymbioteNode): void;
|
|
6
|
+
export declare function handleOwnerScroll(owner: ISymbioteNode, event: ISymbioteEvent): void;
|
|
7
|
+
export declare function releaseStickyOwner(owner: ISymbioteNode): void;
|
|
8
|
+
/**
|
|
9
|
+
* Bring the synthesized wrappers in line with `stickyHeaderIndices`. Called from the ScrollView
|
|
10
|
+
* behavior's `afterCommit`, the one beat at which the app's children are all present.
|
|
11
|
+
*
|
|
12
|
+
* O(slot children) per commit, once — never per mutation. Angular's controller coalesces to one
|
|
13
|
+
* pass per change detection for exactly this reason: its per-mutation walk was O(M²) and died at
|
|
14
|
+
* 801 children.
|
|
15
|
+
*/
|
|
16
|
+
export declare function reconcileStickyIndices(owner: ISymbioteNode): void;
|
|
17
|
+
export declare const stickyHeaderBehavior: IHostBehavior;
|