@sigx/lynx-gestures 0.1.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.
@@ -0,0 +1,175 @@
1
+ import {
2
+ component,
3
+ useMainThreadRef,
4
+ runOnBackground,
5
+ Gesture,
6
+ useGestureDetector,
7
+ type Define,
8
+ type MainThread,
9
+ } from '@sigx/lynx';
10
+
11
+ export type PressableProps =
12
+ & Define.Prop<'pressedOpacity', number, false>
13
+ & Define.Prop<'pressedScale', number, false>
14
+ & Define.Prop<'longPressDuration', number, false>
15
+ & Define.Prop<'maxDistance', number, false>
16
+ & Define.Prop<'disabled', boolean, false>
17
+ & Define.Prop<'class', string, false>
18
+ & Define.Prop<'style', Record<string, string | number>, false>
19
+ & Define.Slot<'default'>
20
+ & Define.Event<'press', void>
21
+ & Define.Event<'longPress', void>;
22
+
23
+ interface PressableMTState {
24
+ longPressFired: boolean;
25
+ pressEmitted: boolean;
26
+ startPageX: number;
27
+ startPageY: number;
28
+ }
29
+
30
+ /**
31
+ * MT-thread tap + long-press recognizer with built-in pressed-state visual
32
+ * feedback (opacity + scale). Press and long-press callbacks are dispatched
33
+ * to BG via `runOnBackground` (low-frequency cross-thread is fine).
34
+ *
35
+ * Cross-platform gesture-arena quirks (Phase 2.12.1, observed on iOS Lynx
36
+ * 3.5 sim and Android Lynx 3.6 / Pixel 9 Pro XL) make this component a
37
+ * hybrid: it composes `Gesture.Tap()` + `Gesture.LongPress()` via
38
+ * `Simultaneous` AND adds an onEnd-fallback path inside LongPress, so press
39
+ * emission works on both platforms via different routes:
40
+ *
41
+ * - **Android**: `Tap.onStart` fires on touch-up (as documented). Press
42
+ * emits there; the LongPress fallback sees `pressEmitted=true` and
43
+ * skips. `Tap.onEnd` fires on the same touch-up — but iOS's premature
44
+ * onEnd (next bullet) means we can't safely reset styles here, so style
45
+ * reset lives in LongPress.onEnd.
46
+ * - **iOS**: `Tap.onEnd` fires ~6ms after touchstart (an arena
47
+ * fail/reset path that doesn't trigger on Android). `Tap.onStart`
48
+ * never fires for our composition. We rely on `LongPress.onEnd` to
49
+ * detect "lift before duration with no movement" and emit press from
50
+ * the fallback. `Gesture.Race` would be simpler in theory, but its
51
+ * `waitFor` deadlocks Tap on iOS — the arena dispatches Tap before
52
+ * LongPress reaches Fail state.
53
+ *
54
+ * State tracks `longPressFired` and `pressEmitted` so neither event
55
+ * double-fires regardless of which platform path resolves first.
56
+ * Movement past `maxDistance` is tracked from `e.params.pageX/pageY`;
57
+ * `LongPress.onEnd` skips press emission when the touch drifted past
58
+ * the threshold (matching Tap's success criteria).
59
+ *
60
+ * Disabled is captured at setup; runtime toggling won't update an active
61
+ * gesture's behavior. Wrap the parent in conditional rendering for now if
62
+ * dynamic disable is needed.
63
+ */
64
+ export const Pressable = component<PressableProps>(({ props, slots, emit }) => {
65
+ const elRef = useMainThreadRef<MainThread.Element | null>(null);
66
+
67
+ const opacity = props.pressedOpacity ?? 0.6;
68
+ const scale = props.pressedScale ?? 1;
69
+ // longPressDuration === 0 disables long-press: we set minDuration to a
70
+ // huge value so the platform timer never fires; the iOS press fallback
71
+ // path still works because it's gated on `!longPressFired` (which stays
72
+ // false), and on Android the Tap.onStart path is unaffected.
73
+ const longPressDuration = props.longPressDuration ?? 500;
74
+ const minDuration = longPressDuration > 0 ? longPressDuration : 1_000_000;
75
+ const maxDistance = props.maxDistance ?? 10;
76
+ const maxDistanceSq = maxDistance * maxDistance;
77
+ const disabled = props.disabled ?? false;
78
+
79
+ const state = useMainThreadRef<PressableMTState>({
80
+ longPressFired: false,
81
+ pressEmitted: false,
82
+ startPageX: 0,
83
+ startPageY: 0,
84
+ });
85
+
86
+ const tap = Gesture.Tap()
87
+ .maxDistance(maxDistance)
88
+ .onBegin((e: any) => {
89
+ 'main thread';
90
+ if (disabled) return;
91
+ // Reset the cross-platform state on every fresh touch-down. Both
92
+ // Tap.onBegin and LongPress.onBegin fire — first one wins, second
93
+ // is a no-op because pressEmitted/longPressFired are already false.
94
+ state.current.longPressFired = false;
95
+ state.current.pressEmitted = false;
96
+ const p = e && e.params;
97
+ state.current.startPageX = (p && p.pageX) || 0;
98
+ state.current.startPageY = (p && p.pageY) || 0;
99
+ elRef.current?.setStyleProperties({
100
+ opacity: opacity,
101
+ transform: 'scale(' + scale + ')',
102
+ });
103
+ })
104
+ .onStart(() => {
105
+ 'main thread';
106
+ if (disabled) return;
107
+ // Android path: Tap.onStart fires on touchend within maxDuration;
108
+ // emit press here. The LongPress.onEnd fallback below is gated on
109
+ // !pressEmitted so it won't double-fire on Android.
110
+ if (!state.current.pressEmitted) {
111
+ state.current.pressEmitted = true;
112
+ runOnBackground(() => { emit('press'); })();
113
+ }
114
+ });
115
+ // No Tap.onEnd: iOS fires it ~6ms after touchstart (arena fail/reset
116
+ // path), which would prematurely reset our press-state styles. Style
117
+ // reset lives in LongPress.onEnd, which fires only on real touch-up.
118
+
119
+ const longPress = Gesture.LongPress()
120
+ .minDuration(minDuration)
121
+ .maxDistance(maxDistance)
122
+ .onBegin(() => {
123
+ 'main thread';
124
+ if (disabled) return;
125
+ // Idempotent with Tap.onBegin — both fire on touch-down. State has
126
+ // already been initialised by Tap.onBegin (whichever fires first).
127
+ elRef.current?.setStyleProperties({
128
+ opacity: opacity,
129
+ transform: 'scale(' + scale + ')',
130
+ });
131
+ })
132
+ .onStart(() => {
133
+ 'main thread';
134
+ if (disabled) return;
135
+ state.current.longPressFired = true;
136
+ runOnBackground(() => { emit('longPress'); })();
137
+ })
138
+ .onEnd((e: any) => {
139
+ 'main thread';
140
+ // Reset visual feedback regardless of how this terminal state was
141
+ // reached (success / fail / cancel / lift-before-duration).
142
+ elRef.current?.setStyleProperties({
143
+ opacity: 1,
144
+ transform: 'scale(1)',
145
+ });
146
+ if (disabled) return;
147
+ // iOS fallback path. On iOS Tap.onStart never fires, so press would
148
+ // never emit without this. On Android this is a no-op because
149
+ // pressEmitted is already true (or longPressFired is true).
150
+ if (state.current.longPressFired || state.current.pressEmitted) return;
151
+ const p = e && e.params;
152
+ if (!p) return;
153
+ const dx = (p.pageX || 0) - state.current.startPageX;
154
+ const dy = (p.pageY || 0) - state.current.startPageY;
155
+ if (dx * dx + dy * dy > maxDistanceSq) return; // movement-cancel
156
+ state.current.pressEmitted = true;
157
+ runOnBackground(() => { emit('press'); })();
158
+ });
159
+
160
+ const gesture = longPressDuration > 0
161
+ ? Gesture.Simultaneous(tap, longPress)
162
+ : tap;
163
+
164
+ useGestureDetector(elRef, gesture);
165
+
166
+ return () => (
167
+ <view
168
+ class={props.class}
169
+ style={props.style}
170
+ main-thread:ref={elRef}
171
+ >
172
+ {slots.default?.()}
173
+ </view>
174
+ );
175
+ });
@@ -0,0 +1,123 @@
1
+ import {
2
+ component,
3
+ signal,
4
+ useSharedValue,
5
+ useMainThreadRef,
6
+ defineProvide,
7
+ type SharedValue,
8
+ type Define,
9
+ type MainThread,
10
+ } from '@sigx/lynx';
11
+ import { useScrollContext } from '../scroll-context.js';
12
+
13
+ export type ScrollViewProps =
14
+ & Define.Prop<'offsetX', SharedValue<number>, false>
15
+ & Define.Prop<'offsetY', SharedValue<number>, false>
16
+ & Define.Prop<'scroll-orientation', 'vertical' | 'horizontal', false>
17
+ /**
18
+ * Toggle native scroll responsiveness at runtime — set false to lock the
19
+ * scroll-view (e.g. while a child `<Draggable>` is mid-drag, so Lynx's
20
+ * native pan gesture doesn't steal the touch). Maps to Lynx's
21
+ * `enable-scroll` attribute.
22
+ */
23
+ & Define.Prop<'enable-scroll', boolean, false>
24
+ & Define.Prop<'class', string, false>
25
+ & Define.Prop<'style', Record<string, string | number>, false>
26
+ & Define.Slot<'default'>;
27
+
28
+ /**
29
+ * MT-thread `<scroll-view>` wrapper that mirrors scroll position into a
30
+ * `SharedValue`. Pair with `useAnimatedStyle` for parallax / fade / scale
31
+ * effects driven by scroll, all running on MT with zero per-frame thread
32
+ * crossings.
33
+ *
34
+ * The component is the API; the inline `'main thread'` worklet, the
35
+ * `__FlushElementTree()` trigger, and the runtime registration are all
36
+ * internal. Users just pass a `SharedValue<number>` for the axis they care
37
+ * about — same shape as `<Draggable translateX={tx}>`.
38
+ *
39
+ * @example Parallax header
40
+ * ```tsx
41
+ * const scrollY = useSharedValue(0);
42
+ * const headerRef = useMainThreadRef<MainThread.Element | null>(null);
43
+ *
44
+ * useAnimatedStyle(headerRef, scrollY, 'translateY', {
45
+ * inputRange: [0, 300], outputRange: [0, -150], extrapolate: 'clamp',
46
+ * });
47
+ *
48
+ * <ScrollView offsetY={scrollY}>
49
+ * <view main-thread:ref={headerRef}><image src={hero} /></view>
50
+ * <text>Body…</text>
51
+ * </ScrollView>
52
+ * ```
53
+ *
54
+ * @example BG-reactive scroll readout
55
+ * ```tsx
56
+ * const scrollY = useSharedValue(0);
57
+ * <ScrollView offsetY={scrollY}>...</ScrollView>
58
+ * <text>Scrolled: {scrollY.value.toFixed(0)}px</text>
59
+ * ```
60
+ */
61
+ export const ScrollView = component<ScrollViewProps>(({ props, slots }) => {
62
+ // Always allocate fallback SharedValues — hooks must run unconditionally.
63
+ // The render closure picks between own/external; the worklet always sees
64
+ // a defined SharedValue in its `_c` capture.
65
+ const ownX = useSharedValue(0);
66
+ const ownY = useSharedValue(0);
67
+
68
+ // Phase 2.12 ScrollView ↔ child-gesture coordination. Descendant
69
+ // `<Draggable>` / `<Swipeable>` flip this signal during their drag so the
70
+ // UIKit `panGestureRecognizer` (which doesn't participate in the new
71
+ // gesture arena) yields the touch. See `scroll-context.ts` for the why.
72
+ const dragging = signal(false);
73
+
74
+ // Phase 2.13: publish the scroll-view's element ref through the context so
75
+ // descendants can drive scroll directly from worklets (e.g. <Draggable
76
+ // edgeScroll>). Captured at setup so the worklet `_c` map sees a stable
77
+ // ref identity.
78
+ const scrollViewRef = useMainThreadRef<MainThread.Element | null>(null);
79
+
80
+ // Pick the axis SVs once; the same identity is shared with descendants via
81
+ // the context (so they can read live scroll position) and used at render
82
+ // time for the bindscroll worklet's `_c` capture.
83
+ const x: SharedValue<number> = props.offsetX ?? ownX;
84
+ const y: SharedValue<number> = props.offsetY ?? ownY;
85
+ const scrollOrientation = props['scroll-orientation'] ?? 'vertical';
86
+
87
+ defineProvide(useScrollContext, () => ({
88
+ dragging,
89
+ scrollViewRef,
90
+ offsetX: x,
91
+ offsetY: y,
92
+ scrollOrientation,
93
+ }));
94
+
95
+ return () => {
96
+ // Compose user-passed enable-scroll with the descendant-driven flag:
97
+ // both must be true. User can still force-lock by passing `false`.
98
+ const userEnableScroll = props['enable-scroll'] ?? true;
99
+ const enableScroll = userEnableScroll && !dragging.value;
100
+ return (
101
+ <scroll-view
102
+ main-thread:ref={scrollViewRef}
103
+ scroll-orientation={scrollOrientation}
104
+ enable-scroll={enableScroll}
105
+ class={props.class}
106
+ style={props.style}
107
+ main-thread-bindscroll={(e: any) => {
108
+ 'main thread';
109
+ y.current.value = e.detail.scrollTop;
110
+ x.current.value = e.detail.scrollLeft;
111
+ // Apply useAnimatedStyle bindings on the same frame. Inlined
112
+ // (rather than calling a helper) because plain function imports
113
+ // don't survive worklet `_c` capture across the MT bundle —
114
+ // same constraint @sigx/lynx-motion's `animate()` documents.
115
+ const __flush = (globalThis as Record<string, unknown>)['__FlushElementTree'] as (() => void) | undefined;
116
+ if (__flush) __flush();
117
+ }}
118
+ >
119
+ {slots.default?.()}
120
+ </scroll-view>
121
+ );
122
+ };
123
+ });
@@ -0,0 +1,220 @@
1
+ import {
2
+ component,
3
+ useMainThreadRef,
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ runOnBackground,
7
+ Gesture,
8
+ useGestureDetector,
9
+ type Define,
10
+ type MainThread,
11
+ } from '@sigx/lynx';
12
+ import { useScrollContext } from '../scroll-context.js';
13
+
14
+ export type SwipeSide = 'left' | 'right';
15
+
16
+ export type SwipeableProps =
17
+ & Define.Prop<'leftActionsWidth', number, false>
18
+ & Define.Prop<'rightActionsWidth', number, false>
19
+ & Define.Prop<'snapThreshold', number, false>
20
+ & Define.Prop<'snapDuration', number, false>
21
+ & Define.Prop<'leftActions', () => unknown, false>
22
+ & Define.Prop<'rightActions', () => unknown, false>
23
+ & Define.Prop<'class', string, false>
24
+ & Define.Prop<'style', Record<string, string | number>, false>
25
+ & Define.Prop<'foregroundStyle', Record<string, string | number>, false>
26
+ & Define.Slot<'default'>
27
+ & Define.Event<'swipeOpen', { side: SwipeSide }>
28
+ & Define.Event<'swipeClose', void>;
29
+
30
+ interface SwipeMTState {
31
+ startPageX: number;
32
+ offsetX: number;
33
+ /** Snapped resting position: 0, +leftWidth, or -rightWidth. */
34
+ currentX: number;
35
+ }
36
+
37
+ /**
38
+ * Horizontal swipe-to-reveal container, built on the native gesture arena
39
+ * via `Gesture.Pan().axis('x')`. The foreground is dragged horizontally on
40
+ * the MT thread; on release it snaps to one of three resting positions
41
+ * (closed / open-left / open-right) using `MTElementWrapper.animate()`.
42
+ * Open and close events are dispatched to BG via `runOnBackground`.
43
+ *
44
+ * Migrated from a 4-`bindtouch*`-worklet implementation to a single
45
+ * `Gesture.Pan()` (Phase 2.12). Carries the same Phase 2.11 quirks:
46
+ * - `.onBegin(() => {})` no-op is load-bearing on iOS Pan to gate
47
+ * `_isInvokedBegin` open so onStart/onEnd fire.
48
+ * - `e.params.pageX` (not `e.pageX`) — Lynx pan event nests the touch
49
+ * payload under `params`.
50
+ *
51
+ * Supply `leftActions` and/or `rightActions` as render-prop functions:
52
+ *
53
+ * ```tsx
54
+ * <Swipeable
55
+ * rightActions={() => <view><text>Delete</text></view>}
56
+ * onSwipeOpen={(e) => console.log('opened', e.side)}
57
+ * >
58
+ * <view><text>Row content</text></view>
59
+ * </Swipeable>
60
+ * ```
61
+ *
62
+ * **Scroll composition** (Phase 2.12.3): nesting `<Swipeable>` inside
63
+ * `<ScrollView>` is automatic — `useScrollContext` is read at setup and
64
+ * the BG-side onStart/onEnd handlers flip `scrollCtx.dragging` so the
65
+ * parent yields its UIKit pan for the duration of the swipe. No consumer
66
+ * wiring required.
67
+ */
68
+ export const Swipeable = component<SwipeableProps>(({ props, slots, emit }) => {
69
+ const fgRef = useMainThreadRef<MainThread.Element | null>(null);
70
+
71
+ // Drive the foreground transform via a SharedValue so external animations
72
+ // could compose if we ever wanted spring snaps. For now we still call
73
+ // `.animate()` on the element directly for the snap; the SV is only the
74
+ // intermediate write target during the drag.
75
+ const tx = useSharedValue(0);
76
+ useAnimatedStyle(fgRef, tx, 'translateX');
77
+
78
+ const drag = useMainThreadRef<SwipeMTState>({
79
+ startPageX: 0,
80
+ offsetX: 0,
81
+ currentX: 0,
82
+ });
83
+
84
+ // Coordinate with the parent <ScrollView> (Phase 2.12.3) — see Draggable
85
+ // for the why. Null when no ancestor ScrollView.
86
+ const scrollCtx = useScrollContext();
87
+
88
+ const leftWidth = props.leftActionsWidth ?? 100;
89
+ const rightWidth = props.rightActionsWidth ?? 100;
90
+ const snapThreshold = props.snapThreshold ?? 60;
91
+ const snapDuration = props.snapDuration ?? 200;
92
+ const hasLeft = !!props.leftActions;
93
+ const hasRight = !!props.rightActions;
94
+ const upper = hasLeft ? leftWidth : 0;
95
+ const lower = hasRight ? -rightWidth : 0;
96
+
97
+ const pan = Gesture.Pan()
98
+ .axis('x')
99
+ // Empty onBegin gates `_isInvokedBegin` open on iOS so onStart/onEnd fire.
100
+ .onBegin(() => {
101
+ 'main thread';
102
+ })
103
+ .onStart((e: any) => {
104
+ 'main thread';
105
+ const p = e && e.params;
106
+ drag.current.startPageX = (p && p.pageX) || 0;
107
+ drag.current.offsetX = drag.current.currentX;
108
+ // Tell the parent ScrollView (if any) we own the touch.
109
+ runOnBackground(() => {
110
+ if (scrollCtx) scrollCtx.dragging.value = true;
111
+ })();
112
+ })
113
+ .onUpdate((e: any) => {
114
+ 'main thread';
115
+ const p = e && e.params;
116
+ const pageX = (p && p.pageX) || 0;
117
+ let x = drag.current.offsetX + (pageX - drag.current.startPageX);
118
+ if (x > upper) x = upper;
119
+ if (x < lower) x = lower;
120
+ tx.current.value = x;
121
+ // Bridge the binding on the same frame so the foreground tracks the
122
+ // finger without a vsync delay (same trick as Draggable).
123
+ const __flush = (globalThis as Record<string, unknown>)['__FlushElementTree'] as (() => void) | undefined;
124
+ if (__flush) __flush();
125
+ })
126
+ .onEnd(() => {
127
+ 'main thread';
128
+ const x = tx.current.value;
129
+ // Snap to closest resting position.
130
+ let target = 0;
131
+ if (hasLeft && x > snapThreshold) target = leftWidth;
132
+ else if (hasRight && x < -snapThreshold) target = -rightWidth;
133
+ fgRef.current?.animate(
134
+ [
135
+ { transform: 'translateX(' + x + 'px)' },
136
+ { transform: 'translateX(' + target + 'px)' },
137
+ ],
138
+ { duration: snapDuration, fill: 'forwards', easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
139
+ )?.play();
140
+ // Keep the SV in sync with the snap target so subsequent drags don't
141
+ // jump back to the pre-animate position.
142
+ tx.current.value = target;
143
+ const wasOpen = drag.current.currentX !== 0;
144
+ const nowOpen = target !== 0;
145
+ drag.current.currentX = target;
146
+ // Always release the parent ScrollView's claim, regardless of snap.
147
+ // Bundled into the same runOnBackground call as the emit so we only
148
+ // pay one cross-thread hop.
149
+ if (nowOpen) {
150
+ const side: SwipeSide = target > 0 ? 'left' : 'right';
151
+ runOnBackground((s: SwipeSide) => {
152
+ if (scrollCtx) scrollCtx.dragging.value = false;
153
+ emit('swipeOpen', { side: s });
154
+ })(side);
155
+ } else if (wasOpen) {
156
+ runOnBackground(() => {
157
+ if (scrollCtx) scrollCtx.dragging.value = false;
158
+ emit('swipeClose');
159
+ })();
160
+ } else {
161
+ // Closed→closed: still need to release the ScrollView claim.
162
+ runOnBackground(() => {
163
+ if (scrollCtx) scrollCtx.dragging.value = false;
164
+ })();
165
+ }
166
+ });
167
+
168
+ useGestureDetector(fgRef, pan);
169
+
170
+ return () => (
171
+ <view
172
+ class={props.class}
173
+ style={{
174
+ position: 'relative',
175
+ overflow: 'hidden',
176
+ ...(props.style || {}),
177
+ }}
178
+ >
179
+ {hasLeft ? (
180
+ <view style={{
181
+ position: 'absolute',
182
+ left: '0',
183
+ top: '0',
184
+ bottom: '0',
185
+ width: leftWidth + 'px',
186
+ display: 'flex',
187
+ alignItems: 'center',
188
+ justifyContent: 'center',
189
+ }}>
190
+ {props.leftActions!()}
191
+ </view>
192
+ ) : null}
193
+
194
+ {hasRight ? (
195
+ <view style={{
196
+ position: 'absolute',
197
+ right: '0',
198
+ top: '0',
199
+ bottom: '0',
200
+ width: rightWidth + 'px',
201
+ display: 'flex',
202
+ alignItems: 'center',
203
+ justifyContent: 'center',
204
+ }}>
205
+ {props.rightActions!()}
206
+ </view>
207
+ ) : null}
208
+
209
+ <view
210
+ main-thread:ref={fgRef}
211
+ style={{
212
+ position: 'relative',
213
+ ...(props.foregroundStyle || {}),
214
+ }}
215
+ >
216
+ {slots.default?.()}
217
+ </view>
218
+ </view>
219
+ );
220
+ });
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Multi-touch JS-only fallback hooks. Lynx's native gesture arena ships a
2
+ // `Gesture.Pinch()` / `Gesture.Rotation()` builder pair (`GestureType.PINCH`,
3
+ // `GestureType.ROTATION`), but the platform-side handlers are unfinished
4
+ // in Lynx 3.5 — these hooks parse `bindtouch*` events directly until that
5
+ // changes. The other legacy hooks (`useTap`, `useLongPress`, `usePan`,
6
+ // `useFling`, `useSwipe`, `useGesture`, `usePanResponder`) were deleted in
7
+ // Phase 2.12.4 — use `Gesture.*` + `useGestureDetector` instead.
8
+ export { usePinch } from './use-pinch.js';
9
+ export { useRotation } from './use-rotation.js';
10
+
11
+ // Cross-thread value primitive — re-exported from @sigx/lynx for back-compat.
12
+ // @deprecated since 0.3.0 — import directly from '@sigx/lynx' instead.
13
+ // The primitives moved out of @sigx/lynx-gestures in Phase 2.6 because they
14
+ // have no gesture coupling (SharedValue extends MainThreadRef and the
15
+ // bridge plumbing already lives in the runtime packages).
16
+ export {
17
+ useSharedValue,
18
+ SharedValue,
19
+ // @deprecated — kept for back-compat. Use `useSharedValue` / `SharedValue`.
20
+ useAnimatedValue,
21
+ AnimatedValue,
22
+ useAnimatedStyle,
23
+ resetAnimatedStyleBindingIds,
24
+ } from '@sigx/lynx';
25
+ export type {
26
+ SharedValueState,
27
+ // @deprecated — use `SharedValueState`.
28
+ AnimatedValueState,
29
+ BuiltinMapperName,
30
+ MapperParams,
31
+ } from '@sigx/lynx';
32
+
33
+ // Built-in MT components (arena-driven via `Gesture.*` + `useGestureDetector`).
34
+ export { Pressable } from './components/Pressable.js';
35
+ export type { PressableProps } from './components/Pressable.js';
36
+ export { Draggable } from './components/Draggable.js';
37
+ export type { DraggableProps, DragEndDetail } from './components/Draggable.js';
38
+ export { Swipeable } from './components/Swipeable.js';
39
+ export type { SwipeableProps, SwipeSide } from './components/Swipeable.js';
40
+ export { ScrollView } from './components/ScrollView.js';
41
+ export type { ScrollViewProps } from './components/ScrollView.js';
42
+
43
+ // ScrollView ↔ child-gesture coordination (Phase 2.12.3). Public mostly so
44
+ // custom gesture components can opt in to the same auto-yield behavior that
45
+ // `<Draggable>` and `<Swipeable>` get for free.
46
+ export { useScrollContext } from './scroll-context.js';
47
+ export type { ScrollContext } from './scroll-context.js';
48
+
49
+ // Types
50
+ export type {
51
+ TouchPoint,
52
+ TouchEvent,
53
+ GesturePhase,
54
+ GestureHandlers,
55
+ PinchState,
56
+ UsePinchOptions,
57
+ UsePinchReturn,
58
+ RotationState,
59
+ UseRotationOptions,
60
+ UseRotationReturn,
61
+ } from './types.js';
@@ -0,0 +1,72 @@
1
+ import {
2
+ defineInjectable,
3
+ type PrimitiveSignal,
4
+ type SharedValue,
5
+ type MainThreadRef,
6
+ type MainThread,
7
+ } from '@sigx/lynx';
8
+
9
+ /**
10
+ * Scroll-arena coordination context, provided by `<ScrollView>` and consumed
11
+ * by descendant gesture components (`<Draggable>`, `<Swipeable>`).
12
+ *
13
+ * Why this exists: Lynx's `<scroll-view>` does NOT participate in the new
14
+ * gesture arena (`LynxGestureArenaManager`) on iOS — its UIKit
15
+ * `panGestureRecognizer` runs independently of arena gestures, so a Pan
16
+ * registered on a descendant element fires concurrently with the parent
17
+ * scroll. The visible result is "drag works but the page scrolls too,
18
+ * sliding the box away from the finger".
19
+ *
20
+ * Workaround: the parent `<ScrollView>` exposes a BG-side `dragging` signal
21
+ * that gates its `enable-scroll` prop. Gesture children flip the signal
22
+ * during their lifecycle (onStart/onEnd → onDragStart/onDragEnd) so the
23
+ * UIScrollView pan recognizer is disabled while a child gesture owns the
24
+ * touch.
25
+ *
26
+ * This is a Phase 2.12 framework-level encapsulation of what consumers had
27
+ * to wire by hand in Phase 2.11. A proper fix lives on the Lynx native
28
+ * side: making `<scroll-view>`'s pan recognizer participate in the arena
29
+ * (or yielding to arena recognizers in `shouldBeRequiredToFailByGestureRecognizer:`).
30
+ * Until then, this is the cleanest the framework can be.
31
+ *
32
+ * Returns `null` when no parent `<ScrollView>` is in scope, so consumers
33
+ * branch on presence:
34
+ *
35
+ * ```ts
36
+ * const scrollCtx = useScrollContext();
37
+ * // ... inside an onStart's runOnBackground arrow:
38
+ * if (scrollCtx) scrollCtx.dragging.value = true;
39
+ * ```
40
+ *
41
+ * Phase 2.13 extends the context with the scroll-view's element ref + live
42
+ * scroll-position SVs + axis, so descendants can drive scroll directly
43
+ * (edge-scroll while dragging, etc.) without re-piping refs through props.
44
+ */
45
+ export interface ScrollContext {
46
+ /** BG-side flag the parent `<ScrollView>` reads as `enable-scroll={!dragging.value}`. */
47
+ dragging: PrimitiveSignal<boolean>;
48
+ /**
49
+ * MT element ref to the underlying `<scroll-view>`. Null until mounted.
50
+ * Descendants call `scrollViewRef.current?.invoke('scrollBy', ...)` from
51
+ * worklets to drive scroll programmatically.
52
+ */
53
+ scrollViewRef: MainThreadRef<MainThread.Element | null>;
54
+ /**
55
+ * Live horizontal scroll position. Same SV the consumer passes via
56
+ * `<ScrollView offsetX={…}>` (or an internally-allocated fallback).
57
+ */
58
+ offsetX: SharedValue<number>;
59
+ /**
60
+ * Live vertical scroll position. Same SV the consumer passes via
61
+ * `<ScrollView offsetY={…}>` (or an internally-allocated fallback).
62
+ */
63
+ offsetY: SharedValue<number>;
64
+ /**
65
+ * Scroll axis as configured on the `<scroll-view>`. Edge-scroll
66
+ * descendants pick which edges to monitor based on this:
67
+ * `'vertical'` → top/bottom; `'horizontal'` → left/right.
68
+ */
69
+ scrollOrientation: 'vertical' | 'horizontal';
70
+ }
71
+
72
+ export const useScrollContext = defineInjectable<ScrollContext | null>(() => null);