@sigx/lynx-gestures 0.4.1 → 0.4.2
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 +42 -0
- package/dist/components/Swiper.d.ts +65 -0
- package/dist/components/Swiper.d.ts.map +1 -0
- package/dist/components/Swiper.js +124 -0
- package/dist/components/Swiper.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/use-swiper-dot-progress.d.ts +129 -0
- package/dist/use-swiper-dot-progress.d.ts.map +1 -0
- package/dist/use-swiper-dot-progress.js +141 -0
- package/dist/use-swiper-dot-progress.js.map +1 -0
- package/package.json +6 -6
- package/src/components/Swiper.tsx +204 -0
- package/src/index.ts +14 -0
- package/src/use-swiper-dot-progress.ts +231 -0
package/README.md
CHANGED
|
@@ -209,6 +209,48 @@ The component handles the inline `'main thread'` worklet, the SharedValue writes
|
|
|
209
209
|
|
|
210
210
|
---
|
|
211
211
|
|
|
212
|
+
### `<Swiper>` and headless dot hooks
|
|
213
|
+
|
|
214
|
+
`<Swiper>` is a paged horizontal carousel that re-uses the platform's native `<scroll-view paging-enabled>` for snap-to-page (deceleration, overscroll, fling — all free), and writes the live pixel offset into a `SharedValue<number>` on every MT frame. Pair it with the headless `useSwiperDot*` hooks to build any indicator visual.
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import { useSharedValue } from '@sigx/lynx';
|
|
218
|
+
import { signal } from '@sigx/lynx';
|
|
219
|
+
import { Swiper, useSwiperDotProgress } from '@sigx/lynx-gestures';
|
|
220
|
+
|
|
221
|
+
const offset = useSharedValue(0);
|
|
222
|
+
const pageIdx = signal({ value: 0 });
|
|
223
|
+
|
|
224
|
+
<Swiper offset={offset} index={pageIdx} width={pageWidth}>
|
|
225
|
+
<view style={{ width: pageWidth + 'px' }}>…page 1…</view>
|
|
226
|
+
<view style={{ width: pageWidth + 'px' }}>…page 2…</view>
|
|
227
|
+
<view style={{ width: pageWidth + 'px' }}>…page 3…</view>
|
|
228
|
+
</Swiper>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
#### Headless indicator hooks
|
|
232
|
+
|
|
233
|
+
Every indicator hook returns a `MainThreadRef<MainThread.Element | null>` that you spread onto whatever view you want animated. The hook owns the `useAnimatedStyle` call-site so you don't redo the triangular-window math. Pick one based on which CSS channel you want to drive:
|
|
234
|
+
|
|
235
|
+
| Hook | Channel(s) | Use case |
|
|
236
|
+
| -------------------------- | ---------------------- | ------------------------------------------- |
|
|
237
|
+
| `useSwiperDotProgress` | `opacity` | Crossfade between two colour layers. |
|
|
238
|
+
| `useSwiperDotScale` | `scale` (uniform) | Pulse / grow the active dot symmetrically. |
|
|
239
|
+
| `useSwiperDotGrowX` | `scaleX` (transform) | Pill / bar that stretches horizontally. |
|
|
240
|
+
| `useSwiperDotWidth` | `width` (layout px) | Same look as `GrowX` but reflows neighbours.|
|
|
241
|
+
| `useSwiperDotTranslate` | `translateX` (track) | Single thumb that slides across the whole strip. |
|
|
242
|
+
|
|
243
|
+
Example — a minimal opacity-crossfade dot:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
const ref = useSwiperDotProgress({ offset, pageWidth, index: i });
|
|
247
|
+
<view main-thread:ref={ref} style={{ opacity: '0' }} />
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
For a fully themed indicator (5 ready-made variants — dots, bar, pill, numbered, scale-pulse), use `<SwiperIndicator>` from [`@sigx/lynx-daisyui`](../lynx-daisyui).
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
212
254
|
## Animation primitives
|
|
213
255
|
|
|
214
256
|
> The cross-thread primitive — `useSharedValue`, `SharedValue`, `useAnimatedStyle` — lives in [`@sigx/lynx`](../lynx#sharedvalue--the-cross-thread-primitive) since 0.3.0. Import from `@sigx/lynx` directly:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type SharedValue, type Define } from '@sigx/lynx';
|
|
2
|
+
import type { PrimitiveSignal } from '@sigx/reactivity';
|
|
3
|
+
export type SwiperProps<T = unknown> =
|
|
4
|
+
/**
|
|
5
|
+
* The items to render — one page per item. Switched from slot-based
|
|
6
|
+
* children because horizontal `<scroll-view>` children need explicit
|
|
7
|
+
* pixel widths (Lynx doesn't resolve `width: 100%` against the viewport
|
|
8
|
+
* in a horizontal scroller), so the Swiper has to own the page wrapper.
|
|
9
|
+
*/
|
|
10
|
+
Define.Prop<'items', readonly T[], true>
|
|
11
|
+
/** Per-item renderer. Output is wrapped in a page-width sized `<view>`. */
|
|
12
|
+
& Define.Prop<'renderItem', (item: T, index: number) => unknown, true>
|
|
13
|
+
/** Optional key extractor — defaults to the item's array index. */
|
|
14
|
+
& Define.Prop<'keyExtractor', (item: T, index: number) => string | number, false>
|
|
15
|
+
/**
|
|
16
|
+
* Page width in CSS pixels. Defaults to the Swiper's own measured
|
|
17
|
+
* container width via `useElementLayout`, falling back to
|
|
18
|
+
* `lynx.SystemInfo.pixelWidth / pixelRatio` before the first layout
|
|
19
|
+
* pass.
|
|
20
|
+
*/
|
|
21
|
+
& Define.Prop<'width', number, false>
|
|
22
|
+
/** Page height in CSS pixels — applied to each page wrapper. */
|
|
23
|
+
& Define.Prop<'height', number | string, false>
|
|
24
|
+
/**
|
|
25
|
+
* Externally-observable current page (whole units). Updated from
|
|
26
|
+
* `bindscroll` as the user pans. Writes from outside (e.g.
|
|
27
|
+
* `idx.value = 2`) glide the swiper to that page via the native
|
|
28
|
+
* `<scroll-view>.scrollTo` UI method.
|
|
29
|
+
*/
|
|
30
|
+
& Define.Prop<'index', PrimitiveSignal<number>, false>
|
|
31
|
+
/** Page to render first (uncontrolled-initial). */
|
|
32
|
+
& Define.Prop<'initialIndex', number, false>
|
|
33
|
+
/**
|
|
34
|
+
* MT-thread live pixel offset, updated every scroll frame from the
|
|
35
|
+
* native scroll-view's `scrollLeft`.
|
|
36
|
+
*/
|
|
37
|
+
& Define.Prop<'offset', SharedValue<number>, false> & Define.Prop<'class', string, false> & Define.Prop<'style', Record<string, string | number>, false>
|
|
38
|
+
/** Emitted (BG) when the page-rounded `scrollLeft / width` changes. */
|
|
39
|
+
& Define.Event<'pageChange', {
|
|
40
|
+
index: number;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* Paged horizontal carousel built on Lynx's native `<scroll-view
|
|
44
|
+
* paging-enabled>` — native snap, no MTS pan handling required for the
|
|
45
|
+
* happy path. Items are rendered into page-sized `<view>` wrappers (the
|
|
46
|
+
* Swiper owns the sizing so Lynx's horizontal scroller has explicit
|
|
47
|
+
* widths to lay out against; `width: 100%` does NOT resolve to the
|
|
48
|
+
* viewport for `<scroll-view scroll-orientation="horizontal">` children).
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* const idx = signal(0);
|
|
53
|
+
* const offset = useSharedValue(0);
|
|
54
|
+
* <Swiper
|
|
55
|
+
* items={photos}
|
|
56
|
+
* index={idx}
|
|
57
|
+
* offset={offset}
|
|
58
|
+
* renderItem={(src) => (
|
|
59
|
+
* <image src={src} mode="aspectFit" style={{ width: '100%', height: '100%' }} />
|
|
60
|
+
* )}
|
|
61
|
+
* />
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare const Swiper: <T>(props: SwiperProps<T>) => unknown;
|
|
65
|
+
//# sourceMappingURL=Swiper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Swiper.d.ts","sourceRoot":"","sources":["../../src/components/Swiper.tsx"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,MAAM,EAEZ,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAkCxD,MAAM,MAAM,WAAW,CAAC,CAAC,GAAG,OAAO;AACjC;;;;;GAKG;AACD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,EAAE,IAAI,CAAC;AAC1C,2EAA2E;GACzE,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,EAAE,IAAI,CAAC;AACtE,mEAAmE;GACjE,MAAM,CAAC,IAAI,CAAC,cAAc,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,MAAM,EAAE,KAAK,CAAC;AACjF;;;;;GAKG;GACD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC;AACrC,gEAAgE;GAC9D,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,CAAC;AAC/C;;;;;GAKG;GACD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,eAAe,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC;AACtD,mDAAmD;GACjD,MAAM,CAAC,IAAI,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,CAAC;AAC5C;;;GAGG;GACD,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,GACjD,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,GACnC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,EAAE,KAAK,CAAC;AAC9D,uEAAuE;GACrE,MAAM,CAAC,KAAK,CAAC,YAAY,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,MAAM,EA+Fb,CAAC,CAAC,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@sigx/lynx/jsx-runtime";
|
|
2
|
+
import { component, effect, runOnMainThread, signal, useElementLayout, useMainThreadRef, useSharedValue, } from '@sigx/lynx';
|
|
3
|
+
const SCREEN_WIDTH_FALLBACK = (() => {
|
|
4
|
+
try {
|
|
5
|
+
const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
|
|
6
|
+
const px = info?.pixelWidth;
|
|
7
|
+
const pr = info?.pixelRatio || 1;
|
|
8
|
+
if (typeof px === 'number' && px > 0)
|
|
9
|
+
return Math.round(px / pr);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* ignore */
|
|
13
|
+
}
|
|
14
|
+
return 400;
|
|
15
|
+
})();
|
|
16
|
+
const SCREEN_HEIGHT_FALLBACK = (() => {
|
|
17
|
+
try {
|
|
18
|
+
const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
|
|
19
|
+
const px = info?.pixelHeight;
|
|
20
|
+
const pr = info?.pixelRatio || 1;
|
|
21
|
+
if (typeof px === 'number' && px > 0)
|
|
22
|
+
return Math.round(px / pr);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
return 800;
|
|
28
|
+
})();
|
|
29
|
+
/**
|
|
30
|
+
* Paged horizontal carousel built on Lynx's native `<scroll-view
|
|
31
|
+
* paging-enabled>` — native snap, no MTS pan handling required for the
|
|
32
|
+
* happy path. Items are rendered into page-sized `<view>` wrappers (the
|
|
33
|
+
* Swiper owns the sizing so Lynx's horizontal scroller has explicit
|
|
34
|
+
* widths to lay out against; `width: 100%` does NOT resolve to the
|
|
35
|
+
* viewport for `<scroll-view scroll-orientation="horizontal">` children).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const idx = signal(0);
|
|
40
|
+
* const offset = useSharedValue(0);
|
|
41
|
+
* <Swiper
|
|
42
|
+
* items={photos}
|
|
43
|
+
* index={idx}
|
|
44
|
+
* offset={offset}
|
|
45
|
+
* renderItem={(src) => (
|
|
46
|
+
* <image src={src} mode="aspectFit" style={{ width: '100%', height: '100%' }} />
|
|
47
|
+
* )}
|
|
48
|
+
* />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const Swiper = component(({ props, emit }) => {
|
|
52
|
+
const ownOffset = useSharedValue(0);
|
|
53
|
+
const ownIndex = signal(props.initialIndex ?? 0);
|
|
54
|
+
const { layout, onLayoutChange } = useElementLayout();
|
|
55
|
+
const scrollRef = useMainThreadRef(null);
|
|
56
|
+
// Resolve the controlled-vs-uncontrolled signal/offset once at setup so the
|
|
57
|
+
// BG→MT reactive bridge below and the render closure share the same instance.
|
|
58
|
+
const offset = props.offset ?? ownOffset;
|
|
59
|
+
const idx = props.index ?? ownIndex;
|
|
60
|
+
// BG→MT bridge: when external code (or a dot tap) writes `idx.value = N`,
|
|
61
|
+
// we invoke the native `<scroll-view>.scrollTo` UI method on MT so the
|
|
62
|
+
// animation runs with platform physics (the same easing the user gets when
|
|
63
|
+
// they fling-snap). The dedup runs MT-side against the live scroll offset
|
|
64
|
+
// so we don't re-invoke for writes that just mirror a finished snap.
|
|
65
|
+
const scrollOnMT = runOnMainThread((index, pageW) => {
|
|
66
|
+
'main thread';
|
|
67
|
+
const el = scrollRef.current;
|
|
68
|
+
if (!el || pageW <= 0)
|
|
69
|
+
return;
|
|
70
|
+
const target = index * pageW;
|
|
71
|
+
const current = offset.current.value;
|
|
72
|
+
if (Math.abs(current - target) < 0.5)
|
|
73
|
+
return;
|
|
74
|
+
el.invoke('scrollTo', { index, smooth: true });
|
|
75
|
+
});
|
|
76
|
+
effect(() => {
|
|
77
|
+
const v = idx.value;
|
|
78
|
+
if (typeof v !== 'number')
|
|
79
|
+
return;
|
|
80
|
+
const pw = props.width
|
|
81
|
+
?? (layout.value && layout.value.width > 0 ? layout.value.width : undefined)
|
|
82
|
+
?? SCREEN_WIDTH_FALLBACK;
|
|
83
|
+
scrollOnMT(v, pw);
|
|
84
|
+
});
|
|
85
|
+
return () => {
|
|
86
|
+
const pageWidth = props.width
|
|
87
|
+
?? (layout.value && layout.value.width > 0 ? layout.value.width : undefined)
|
|
88
|
+
?? SCREEN_WIDTH_FALLBACK;
|
|
89
|
+
// Lynx horizontal `<scroll-view>` doesn't resolve `height: 100%` on
|
|
90
|
+
// children (same constraint as width), so the page wrapper needs a
|
|
91
|
+
// pixel value. Use the measured layout height when available; the
|
|
92
|
+
// screen-height fallback covers the first paint. Guard against the
|
|
93
|
+
// `0 ?? fallback` gotcha — a zero-sized layout report (e.g. before
|
|
94
|
+
// first paint) must fall through to the fallback.
|
|
95
|
+
const measuredHeight = layout.value && layout.value.height > 0 ? layout.value.height : undefined;
|
|
96
|
+
const pageHeight = props.height
|
|
97
|
+
?? measuredHeight
|
|
98
|
+
?? SCREEN_HEIGHT_FALLBACK;
|
|
99
|
+
const initialScrollLeft = (props.initialIndex ?? 0) * pageWidth;
|
|
100
|
+
const items = props.items;
|
|
101
|
+
const keyOf = props.keyExtractor;
|
|
102
|
+
return (_jsx("scroll-view", { "main-thread:ref": scrollRef, "scroll-orientation": "horizontal", "paging-enabled": true, "show-scrollbar": false, bounces: true, "scroll-left": initialScrollLeft, class: props.class, style: { width: '100%', ...(props.style || {}) }, bindlayoutchange: onLayoutChange, "main-thread-bindscroll": (e) => {
|
|
103
|
+
'main thread';
|
|
104
|
+
offset.current.value = e.detail.scrollLeft;
|
|
105
|
+
const __flush = globalThis['__FlushElementTree'];
|
|
106
|
+
if (__flush)
|
|
107
|
+
__flush();
|
|
108
|
+
}, bindscroll: (e) => {
|
|
109
|
+
if (pageWidth <= 0)
|
|
110
|
+
return;
|
|
111
|
+
const next = Math.round(e.detail.scrollLeft / pageWidth);
|
|
112
|
+
if (next !== idx.value) {
|
|
113
|
+
idx.value = next;
|
|
114
|
+
emit('pageChange', { index: next });
|
|
115
|
+
}
|
|
116
|
+
}, children: items.map((item, i) => (_jsx("view", { style: {
|
|
117
|
+
width: pageWidth + 'px',
|
|
118
|
+
height: typeof pageHeight === 'number' ? pageHeight + 'px' : pageHeight,
|
|
119
|
+
flexShrink: 0,
|
|
120
|
+
flexGrow: 0,
|
|
121
|
+
}, children: props.renderItem(item, i) }, keyOf ? String(keyOf(item, i)) : String(i)))) }));
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
//# sourceMappingURL=Swiper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Swiper.js","sourceRoot":"","sources":["../../src/components/Swiper.tsx"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export { Swipeable } from './components/Swipeable.js';
|
|
|
10
10
|
export type { SwipeableProps, SwipeSide } from './components/Swipeable.js';
|
|
11
11
|
export { ScrollView } from './components/ScrollView.js';
|
|
12
12
|
export type { ScrollViewProps } from './components/ScrollView.js';
|
|
13
|
+
export { Swiper } from './components/Swiper.js';
|
|
14
|
+
export type { SwiperProps } from './components/Swiper.js';
|
|
15
|
+
export { useSwiperDotProgress, useSwiperDotScale, useSwiperDotGrowX, useSwiperDotWidth, useSwiperDotTranslate, } from './use-swiper-dot-progress.js';
|
|
16
|
+
export type { SwiperDotHookInputs, UseSwiperDotProgressOptions, UseSwiperDotTranslateOptions, } from './use-swiper-dot-progress.js';
|
|
13
17
|
export { useScrollContext } from './scroll-context.js';
|
|
14
18
|
export type { ScrollContext } from './scroll-context.js';
|
|
15
19
|
export type { TouchPoint, TouchEvent, GesturePhase, GestureHandlers, PinchState, UsePinchOptions, UsePinchReturn, RotationState, UseRotationOptions, UseRotationReturn, } from './types.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAOhD,OAAO,EACL,cAAc,EACd,WAAW,EAEX,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,4BAA4B,GAC7B,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,gBAAgB,EAEhB,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAOhD,OAAO,EACL,cAAc,EACd,WAAW,EAEX,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,4BAA4B,GAC7B,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,gBAAgB,EAEhB,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,YAAY,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAChD,YAAY,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,8BAA8B,CAAC;AACtC,YAAY,EACV,mBAAmB,EACnB,2BAA2B,EAC3B,4BAA4B,GAC7B,MAAM,8BAA8B,CAAC;AAKtC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGzD,YAAY,EACV,UAAU,EACV,UAAU,EACV,YAAY,EACZ,eAAe,EACf,UAAU,EACV,eAAe,EACf,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,8 @@ export { Pressable } from './components/Pressable.js';
|
|
|
20
20
|
export { Draggable } from './components/Draggable.js';
|
|
21
21
|
export { Swipeable } from './components/Swipeable.js';
|
|
22
22
|
export { ScrollView } from './components/ScrollView.js';
|
|
23
|
+
export { Swiper } from './components/Swiper.js';
|
|
24
|
+
export { useSwiperDotProgress, useSwiperDotScale, useSwiperDotGrowX, useSwiperDotWidth, useSwiperDotTranslate, } from './use-swiper-dot-progress.js';
|
|
23
25
|
// ScrollView ↔ child-gesture coordination (Phase 2.12.3). Public mostly so
|
|
24
26
|
// custom gesture components can opt in to the same auto-yield behavior that
|
|
25
27
|
// `<Draggable>` and `<Swipeable>` get for free.
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,8EAA8E;AAC9E,yEAAyE;AACzE,0EAA0E;AAC1E,uEAAuE;AACvE,2EAA2E;AAC3E,iEAAiE;AACjE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,8EAA8E;AAC9E,uEAAuE;AACvE,4EAA4E;AAC5E,sEAAsE;AACtE,0DAA0D;AAC1D,OAAO,EACL,cAAc,EACd,WAAW;AACX,4EAA4E;AAC5E,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,4BAA4B,GAC7B,MAAM,YAAY,CAAC;AASpB,gFAAgF;AAChF,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEtD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,8EAA8E;AAC9E,yEAAyE;AACzE,0EAA0E;AAC1E,uEAAuE;AACvE,2EAA2E;AAC3E,iEAAiE;AACjE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,8EAA8E;AAC9E,uEAAuE;AACvE,4EAA4E;AAC5E,sEAAsE;AACtE,0DAA0D;AAC1D,OAAO,EACL,cAAc,EACd,WAAW;AACX,4EAA4E;AAC5E,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,4BAA4B,GAC7B,MAAM,YAAY,CAAC;AASpB,gFAAgF;AAChF,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEtD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAExD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAEhD,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,8BAA8B,CAAC;AAOtC,2EAA2E;AAC3E,4EAA4E;AAC5E,gDAAgD;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless `<Swiper>` indicator hooks.
|
|
3
|
+
*
|
|
4
|
+
* `<Swiper>` writes the live scroll offset to a `SharedValue<number>` on
|
|
5
|
+
* the MT thread every frame. To render an indicator the consumer needs
|
|
6
|
+
* one binding per element so `useAnimatedStyle` has a stable call-site —
|
|
7
|
+
* doing that inside `.map()` is fine (per-iteration call-sites are
|
|
8
|
+
* stable across renders), but the bookkeeping (range math, ref alloc)
|
|
9
|
+
* is fiddly and easy to get wrong.
|
|
10
|
+
*
|
|
11
|
+
* These hooks own the bookkeeping and return a `MainThreadRef` the
|
|
12
|
+
* caller spreads onto any element they want animated. That keeps the
|
|
13
|
+
* presentation in user-land (and in `@sigx/lynx-daisyui`'s themed
|
|
14
|
+
* `SwiperIndicator`) while logic lives here.
|
|
15
|
+
*
|
|
16
|
+
* Layering pattern mirrors the daisyui split:
|
|
17
|
+
* - `@sigx/lynx-gestures` owns headless logic (this file + the
|
|
18
|
+
* `<Swiper>` component itself).
|
|
19
|
+
* - `@sigx/lynx-daisyui` ships themed `<SwiperIndicator>` variants
|
|
20
|
+
* that consume these hooks and pick colours from `ThemeProvider`.
|
|
21
|
+
*
|
|
22
|
+
* @example Custom dot using the opacity hook
|
|
23
|
+
* ```tsx
|
|
24
|
+
* function MyDot({ offset, pageWidth, index }) {
|
|
25
|
+
* const ref = useSwiperDotProgress({ offset, pageWidth, index });
|
|
26
|
+
* return (
|
|
27
|
+
* <view
|
|
28
|
+
* main-thread:ref={ref}
|
|
29
|
+
* style={{ width: '8px', height: '8px', borderRadius: '4px',
|
|
30
|
+
* backgroundColor: 'tomato', opacity: '0' }}
|
|
31
|
+
* />
|
|
32
|
+
* );
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
import { type MainThread, type MainThreadRef, type SharedValue } from '@sigx/lynx';
|
|
37
|
+
/** Common per-dot inputs — offset is page-pixel space, index is the dot's page. */
|
|
38
|
+
export interface SwiperDotHookInputs {
|
|
39
|
+
/** Live MT-thread pixel offset from the Swiper's `offset` prop. */
|
|
40
|
+
offset: SharedValue<number>;
|
|
41
|
+
/** Page width in CSS pixels. Must match the Swiper's effective page width. */
|
|
42
|
+
pageWidth: number;
|
|
43
|
+
/** Zero-based page index this dot represents. */
|
|
44
|
+
index: number;
|
|
45
|
+
}
|
|
46
|
+
export interface UseSwiperDotProgressOptions extends SwiperDotHookInputs {
|
|
47
|
+
/**
|
|
48
|
+
* Half-width of the input window in `pageWidth` units. The dot's
|
|
49
|
+
* animation runs from `(index − window) * pageWidth` to
|
|
50
|
+
* `(index + window) * pageWidth`. Default `1` — adjacent dots
|
|
51
|
+
* crossfade because their windows overlap.
|
|
52
|
+
*/
|
|
53
|
+
window?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Output values at `[centre − window·pageWidth, centre, centre +
|
|
56
|
+
* window·pageWidth]`. Default `[0, 1, 0]` (triangular). For "always
|
|
57
|
+
* active" decoration use `[0, 1, 0]` with opacity; for "scale
|
|
58
|
+
* pulse" pass e.g. `[1, 1.4, 1]` with channel `'scale'`.
|
|
59
|
+
*/
|
|
60
|
+
outputRange?: readonly [number, number, number];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a triangular range-map for the given dot index, defaulting to
|
|
64
|
+
* the opacity crossfade `<SwiperDots>` shipped with previously. Returns
|
|
65
|
+
* a `MainThreadRef` — spread it onto whatever element you want
|
|
66
|
+
* animated.
|
|
67
|
+
*/
|
|
68
|
+
export declare function useSwiperDotProgress(opts: UseSwiperDotProgressOptions): MainThreadRef<MainThread.Element | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Scale-pulse variant — active dot scales up, neighbours scale down to
|
|
71
|
+
* the inactive baseline. Defaults: inactive `1`, active `1.4`.
|
|
72
|
+
*
|
|
73
|
+
* Uniform scale (both axes). For width-axis only growth (pill effect
|
|
74
|
+
* that keeps the dot's height stable) use `useSwiperDotGrowX` instead.
|
|
75
|
+
*/
|
|
76
|
+
export declare function useSwiperDotScale(opts: SwiperDotHookInputs & {
|
|
77
|
+
inactive?: number;
|
|
78
|
+
active?: number;
|
|
79
|
+
window?: number;
|
|
80
|
+
}): MainThreadRef<MainThread.Element | null>;
|
|
81
|
+
/**
|
|
82
|
+
* Width-axis growth — the active dot stretches into a pill, neighbours
|
|
83
|
+
* shrink to a circle. Uses the `scaleX` channel, so the element's
|
|
84
|
+
* intrinsic size in the layout stays put; only the visual width
|
|
85
|
+
* changes. If you want surrounding siblings to physically shove apart
|
|
86
|
+
* use `useSwiperDotWidth` instead (it animates the `width` style
|
|
87
|
+
* property, which costs a layout pass each frame).
|
|
88
|
+
*/
|
|
89
|
+
export declare function useSwiperDotGrowX(opts: SwiperDotHookInputs & {
|
|
90
|
+
/** Width multiplier when inactive. Default `1` (the dot's base size). */
|
|
91
|
+
inactive?: number;
|
|
92
|
+
/** Width multiplier when active. Default `3` (a pill ~3× as wide as tall). */
|
|
93
|
+
active?: number;
|
|
94
|
+
window?: number;
|
|
95
|
+
}): MainThreadRef<MainThread.Element | null>;
|
|
96
|
+
/**
|
|
97
|
+
* Layout-aware width growth — animates the element's `width` style in
|
|
98
|
+
* px. Use this when sibling layout must respond (siblings flex away as
|
|
99
|
+
* the pill grows). Slower than `useSwiperDotGrowX` because every frame
|
|
100
|
+
* re-runs layout.
|
|
101
|
+
*
|
|
102
|
+
* Defaults shape an 8px → 24px pill.
|
|
103
|
+
*/
|
|
104
|
+
export declare function useSwiperDotWidth(opts: SwiperDotHookInputs & {
|
|
105
|
+
/** Width in CSS pixels when inactive. Default `8`. */
|
|
106
|
+
inactive?: number;
|
|
107
|
+
/** Width in CSS pixels when active. Default `24`. */
|
|
108
|
+
active?: number;
|
|
109
|
+
window?: number;
|
|
110
|
+
}): MainThreadRef<MainThread.Element | null>;
|
|
111
|
+
/** Inputs for the track-wide translate hook used by the "bar" indicator variant. */
|
|
112
|
+
export interface UseSwiperDotTranslateOptions {
|
|
113
|
+
offset: SharedValue<number>;
|
|
114
|
+
/** Page width in CSS pixels. */
|
|
115
|
+
pageWidth: number;
|
|
116
|
+
/**
|
|
117
|
+
* Distance in CSS pixels that one full page of scroll should move
|
|
118
|
+
* the thumb by — typically `dotWidth + spacing` (the thumb steps to
|
|
119
|
+
* the next dot's centre when the swiper advances by one page).
|
|
120
|
+
*/
|
|
121
|
+
step: number;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Translate a single "thumb" element across the indicator track,
|
|
125
|
+
* proportional to the swiper's scroll progress. Use for the `bar`
|
|
126
|
+
* variant where a single pill slides between fixed dots.
|
|
127
|
+
*/
|
|
128
|
+
export declare function useSwiperDotTranslate(opts: UseSwiperDotTranslateOptions): MainThreadRef<MainThread.Element | null>;
|
|
129
|
+
//# sourceMappingURL=use-swiper-dot-progress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-swiper-dot-progress.d.ts","sourceRoot":"","sources":["../src/use-swiper-dot-progress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAGL,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,WAAW,EAEjB,MAAM,YAAY,CAAC;AAEpB,mFAAmF;AACnF,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC5B,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,2BAA4B,SAAQ,mBAAmB;IACtE;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;CACjD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,2BAA2B,GAChC,aAAa,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC,CAM1C;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,mBAAmB,GAAG;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC,CAW3C;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,mBAAmB,GAAG;IAC5D,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC,CAW3C;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,mBAAmB,GAAG;IAC5D,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GAAG,aAAa,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC,CAW3C;AAED,oFAAoF;AACpF,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC5B,gCAAgC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,4BAA4B,GACjC,aAAa,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC,CAQ1C"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless `<Swiper>` indicator hooks.
|
|
3
|
+
*
|
|
4
|
+
* `<Swiper>` writes the live scroll offset to a `SharedValue<number>` on
|
|
5
|
+
* the MT thread every frame. To render an indicator the consumer needs
|
|
6
|
+
* one binding per element so `useAnimatedStyle` has a stable call-site —
|
|
7
|
+
* doing that inside `.map()` is fine (per-iteration call-sites are
|
|
8
|
+
* stable across renders), but the bookkeeping (range math, ref alloc)
|
|
9
|
+
* is fiddly and easy to get wrong.
|
|
10
|
+
*
|
|
11
|
+
* These hooks own the bookkeeping and return a `MainThreadRef` the
|
|
12
|
+
* caller spreads onto any element they want animated. That keeps the
|
|
13
|
+
* presentation in user-land (and in `@sigx/lynx-daisyui`'s themed
|
|
14
|
+
* `SwiperIndicator`) while logic lives here.
|
|
15
|
+
*
|
|
16
|
+
* Layering pattern mirrors the daisyui split:
|
|
17
|
+
* - `@sigx/lynx-gestures` owns headless logic (this file + the
|
|
18
|
+
* `<Swiper>` component itself).
|
|
19
|
+
* - `@sigx/lynx-daisyui` ships themed `<SwiperIndicator>` variants
|
|
20
|
+
* that consume these hooks and pick colours from `ThemeProvider`.
|
|
21
|
+
*
|
|
22
|
+
* @example Custom dot using the opacity hook
|
|
23
|
+
* ```tsx
|
|
24
|
+
* function MyDot({ offset, pageWidth, index }) {
|
|
25
|
+
* const ref = useSwiperDotProgress({ offset, pageWidth, index });
|
|
26
|
+
* return (
|
|
27
|
+
* <view
|
|
28
|
+
* main-thread:ref={ref}
|
|
29
|
+
* style={{ width: '8px', height: '8px', borderRadius: '4px',
|
|
30
|
+
* backgroundColor: 'tomato', opacity: '0' }}
|
|
31
|
+
* />
|
|
32
|
+
* );
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
import { useAnimatedStyle, useMainThreadRef, } from '@sigx/lynx';
|
|
37
|
+
/**
|
|
38
|
+
* Build a triangular range-map for the given dot index, defaulting to
|
|
39
|
+
* the opacity crossfade `<SwiperDots>` shipped with previously. Returns
|
|
40
|
+
* a `MainThreadRef` — spread it onto whatever element you want
|
|
41
|
+
* animated.
|
|
42
|
+
*/
|
|
43
|
+
export function useSwiperDotProgress(opts) {
|
|
44
|
+
return useSwiperDotChannel({
|
|
45
|
+
...opts,
|
|
46
|
+
channel: 'opacity',
|
|
47
|
+
outputRange: opts.outputRange ?? [0, 1, 0],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Scale-pulse variant — active dot scales up, neighbours scale down to
|
|
52
|
+
* the inactive baseline. Defaults: inactive `1`, active `1.4`.
|
|
53
|
+
*
|
|
54
|
+
* Uniform scale (both axes). For width-axis only growth (pill effect
|
|
55
|
+
* that keeps the dot's height stable) use `useSwiperDotGrowX` instead.
|
|
56
|
+
*/
|
|
57
|
+
export function useSwiperDotScale(opts) {
|
|
58
|
+
const inactive = opts.inactive ?? 1;
|
|
59
|
+
const active = opts.active ?? 1.4;
|
|
60
|
+
return useSwiperDotChannel({
|
|
61
|
+
offset: opts.offset,
|
|
62
|
+
pageWidth: opts.pageWidth,
|
|
63
|
+
index: opts.index,
|
|
64
|
+
window: opts.window,
|
|
65
|
+
channel: 'scale',
|
|
66
|
+
outputRange: [inactive, active, inactive],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Width-axis growth — the active dot stretches into a pill, neighbours
|
|
71
|
+
* shrink to a circle. Uses the `scaleX` channel, so the element's
|
|
72
|
+
* intrinsic size in the layout stays put; only the visual width
|
|
73
|
+
* changes. If you want surrounding siblings to physically shove apart
|
|
74
|
+
* use `useSwiperDotWidth` instead (it animates the `width` style
|
|
75
|
+
* property, which costs a layout pass each frame).
|
|
76
|
+
*/
|
|
77
|
+
export function useSwiperDotGrowX(opts) {
|
|
78
|
+
const inactive = opts.inactive ?? 1;
|
|
79
|
+
const active = opts.active ?? 3;
|
|
80
|
+
return useSwiperDotChannel({
|
|
81
|
+
offset: opts.offset,
|
|
82
|
+
pageWidth: opts.pageWidth,
|
|
83
|
+
index: opts.index,
|
|
84
|
+
window: opts.window,
|
|
85
|
+
channel: 'scaleX',
|
|
86
|
+
outputRange: [inactive, active, inactive],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Layout-aware width growth — animates the element's `width` style in
|
|
91
|
+
* px. Use this when sibling layout must respond (siblings flex away as
|
|
92
|
+
* the pill grows). Slower than `useSwiperDotGrowX` because every frame
|
|
93
|
+
* re-runs layout.
|
|
94
|
+
*
|
|
95
|
+
* Defaults shape an 8px → 24px pill.
|
|
96
|
+
*/
|
|
97
|
+
export function useSwiperDotWidth(opts) {
|
|
98
|
+
const inactive = opts.inactive ?? 8;
|
|
99
|
+
const active = opts.active ?? 24;
|
|
100
|
+
return useSwiperDotChannel({
|
|
101
|
+
offset: opts.offset,
|
|
102
|
+
pageWidth: opts.pageWidth,
|
|
103
|
+
index: opts.index,
|
|
104
|
+
window: opts.window,
|
|
105
|
+
channel: 'width',
|
|
106
|
+
outputRange: [inactive, active, inactive],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Translate a single "thumb" element across the indicator track,
|
|
111
|
+
* proportional to the swiper's scroll progress. Use for the `bar`
|
|
112
|
+
* variant where a single pill slides between fixed dots.
|
|
113
|
+
*/
|
|
114
|
+
export function useSwiperDotTranslate(opts) {
|
|
115
|
+
const ref = useMainThreadRef(null);
|
|
116
|
+
// factor = step px per pageWidth px of offset. Guard against the
|
|
117
|
+
// divide-by-zero before first layout — a factor of 0 just parks the
|
|
118
|
+
// thumb at translateX(0), which is the correct initial position.
|
|
119
|
+
const factor = opts.pageWidth > 0 ? opts.step / opts.pageWidth : 0;
|
|
120
|
+
useAnimatedStyle(ref, opts.offset, 'translateX', { factor });
|
|
121
|
+
return ref;
|
|
122
|
+
}
|
|
123
|
+
function useSwiperDotChannel(opts) {
|
|
124
|
+
const ref = useMainThreadRef(null);
|
|
125
|
+
// Guard against pre-layout pageWidth=0: collapsing the inputRange to
|
|
126
|
+
// [0, 0, 0] would produce divide-by-zero / NaN in interpolateLinear.
|
|
127
|
+
// Fall back to a non-degenerate window so the binding stays valid; once
|
|
128
|
+
// layout settles and the parent re-renders with a real pageWidth, the
|
|
129
|
+
// values flow through normally.
|
|
130
|
+
const safePageWidth = opts.pageWidth > 0 ? opts.pageWidth : 1;
|
|
131
|
+
const center = opts.index * safePageWidth;
|
|
132
|
+
const w = (opts.window ?? 1) * safePageWidth;
|
|
133
|
+
const params = {
|
|
134
|
+
inputRange: [center - w, center, center + w],
|
|
135
|
+
outputRange: [opts.outputRange[0], opts.outputRange[1], opts.outputRange[2]],
|
|
136
|
+
extrapolate: 'clamp',
|
|
137
|
+
};
|
|
138
|
+
useAnimatedStyle(ref, opts.offset, opts.channel, params);
|
|
139
|
+
return ref;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=use-swiper-dot-progress.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-swiper-dot-progress.js","sourceRoot":"","sources":["../src/use-swiper-dot-progress.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigx/lynx-gestures",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Gesture system for sigx-lynx - declarative composables for tap, pan, pinch, swipe, long press",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -28,15 +28,15 @@
|
|
|
28
28
|
"author": "Andreas Ekdahl",
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@sigx/lynx": "^0.4.
|
|
31
|
+
"@sigx/lynx": "^0.4.2"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@lynx-js/react": "^0.121.0",
|
|
35
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
35
|
+
"@typescript/native-preview": "7.0.0-dev.20260521.1",
|
|
36
36
|
"typescript": "^6.0.3",
|
|
37
|
-
"@sigx/lynx-
|
|
38
|
-
"@sigx/lynx-
|
|
39
|
-
"@sigx/lynx-testing": "^0.4.
|
|
37
|
+
"@sigx/lynx-plugin": "^0.4.2",
|
|
38
|
+
"@sigx/lynx-runtime-main": "^0.4.2",
|
|
39
|
+
"@sigx/lynx-testing": "^0.4.2"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
component,
|
|
3
|
+
effect,
|
|
4
|
+
runOnMainThread,
|
|
5
|
+
signal,
|
|
6
|
+
useElementLayout,
|
|
7
|
+
useMainThreadRef,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
type SharedValue,
|
|
10
|
+
type Define,
|
|
11
|
+
type MainThread,
|
|
12
|
+
} from '@sigx/lynx';
|
|
13
|
+
import type { PrimitiveSignal } from '@sigx/reactivity';
|
|
14
|
+
|
|
15
|
+
// Read the logical screen width once at module load — used as the page
|
|
16
|
+
// width fallback before the Swiper's own layout box has been measured.
|
|
17
|
+
// Matches the same `lynx.SystemInfo` reads used by lynx-navigation, so
|
|
18
|
+
// fullscreen / edge-to-edge layouts line up.
|
|
19
|
+
declare const lynx:
|
|
20
|
+
| { SystemInfo?: { pixelWidth?: number; pixelHeight?: number; pixelRatio?: number } }
|
|
21
|
+
| undefined;
|
|
22
|
+
|
|
23
|
+
const SCREEN_WIDTH_FALLBACK = (() => {
|
|
24
|
+
try {
|
|
25
|
+
const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
|
|
26
|
+
const px = info?.pixelWidth;
|
|
27
|
+
const pr = info?.pixelRatio || 1;
|
|
28
|
+
if (typeof px === 'number' && px > 0) return Math.round(px / pr);
|
|
29
|
+
} catch {
|
|
30
|
+
/* ignore */
|
|
31
|
+
}
|
|
32
|
+
return 400;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const SCREEN_HEIGHT_FALLBACK = (() => {
|
|
36
|
+
try {
|
|
37
|
+
const info = typeof lynx !== 'undefined' ? lynx?.SystemInfo : undefined;
|
|
38
|
+
const px = info?.pixelHeight;
|
|
39
|
+
const pr = info?.pixelRatio || 1;
|
|
40
|
+
if (typeof px === 'number' && px > 0) return Math.round(px / pr);
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
return 800;
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
export type SwiperProps<T = unknown> =
|
|
48
|
+
/**
|
|
49
|
+
* The items to render — one page per item. Switched from slot-based
|
|
50
|
+
* children because horizontal `<scroll-view>` children need explicit
|
|
51
|
+
* pixel widths (Lynx doesn't resolve `width: 100%` against the viewport
|
|
52
|
+
* in a horizontal scroller), so the Swiper has to own the page wrapper.
|
|
53
|
+
*/
|
|
54
|
+
& Define.Prop<'items', readonly T[], true>
|
|
55
|
+
/** Per-item renderer. Output is wrapped in a page-width sized `<view>`. */
|
|
56
|
+
& Define.Prop<'renderItem', (item: T, index: number) => unknown, true>
|
|
57
|
+
/** Optional key extractor — defaults to the item's array index. */
|
|
58
|
+
& Define.Prop<'keyExtractor', (item: T, index: number) => string | number, false>
|
|
59
|
+
/**
|
|
60
|
+
* Page width in CSS pixels. Defaults to the Swiper's own measured
|
|
61
|
+
* container width via `useElementLayout`, falling back to
|
|
62
|
+
* `lynx.SystemInfo.pixelWidth / pixelRatio` before the first layout
|
|
63
|
+
* pass.
|
|
64
|
+
*/
|
|
65
|
+
& Define.Prop<'width', number, false>
|
|
66
|
+
/** Page height in CSS pixels — applied to each page wrapper. */
|
|
67
|
+
& Define.Prop<'height', number | string, false>
|
|
68
|
+
/**
|
|
69
|
+
* Externally-observable current page (whole units). Updated from
|
|
70
|
+
* `bindscroll` as the user pans. Writes from outside (e.g.
|
|
71
|
+
* `idx.value = 2`) glide the swiper to that page via the native
|
|
72
|
+
* `<scroll-view>.scrollTo` UI method.
|
|
73
|
+
*/
|
|
74
|
+
& Define.Prop<'index', PrimitiveSignal<number>, false>
|
|
75
|
+
/** Page to render first (uncontrolled-initial). */
|
|
76
|
+
& Define.Prop<'initialIndex', number, false>
|
|
77
|
+
/**
|
|
78
|
+
* MT-thread live pixel offset, updated every scroll frame from the
|
|
79
|
+
* native scroll-view's `scrollLeft`.
|
|
80
|
+
*/
|
|
81
|
+
& Define.Prop<'offset', SharedValue<number>, false>
|
|
82
|
+
& Define.Prop<'class', string, false>
|
|
83
|
+
& Define.Prop<'style', Record<string, string | number>, false>
|
|
84
|
+
/** Emitted (BG) when the page-rounded `scrollLeft / width` changes. */
|
|
85
|
+
& Define.Event<'pageChange', { index: number }>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Paged horizontal carousel built on Lynx's native `<scroll-view
|
|
89
|
+
* paging-enabled>` — native snap, no MTS pan handling required for the
|
|
90
|
+
* happy path. Items are rendered into page-sized `<view>` wrappers (the
|
|
91
|
+
* Swiper owns the sizing so Lynx's horizontal scroller has explicit
|
|
92
|
+
* widths to lay out against; `width: 100%` does NOT resolve to the
|
|
93
|
+
* viewport for `<scroll-view scroll-orientation="horizontal">` children).
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* const idx = signal(0);
|
|
98
|
+
* const offset = useSharedValue(0);
|
|
99
|
+
* <Swiper
|
|
100
|
+
* items={photos}
|
|
101
|
+
* index={idx}
|
|
102
|
+
* offset={offset}
|
|
103
|
+
* renderItem={(src) => (
|
|
104
|
+
* <image src={src} mode="aspectFit" style={{ width: '100%', height: '100%' }} />
|
|
105
|
+
* )}
|
|
106
|
+
* />
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export const Swiper = component<SwiperProps>(({ props, emit }) => {
|
|
110
|
+
const ownOffset = useSharedValue(0);
|
|
111
|
+
const ownIndex = signal(props.initialIndex ?? 0);
|
|
112
|
+
|
|
113
|
+
const { layout, onLayoutChange } = useElementLayout();
|
|
114
|
+
const scrollRef = useMainThreadRef<MainThread.Element | null>(null);
|
|
115
|
+
|
|
116
|
+
// Resolve the controlled-vs-uncontrolled signal/offset once at setup so the
|
|
117
|
+
// BG→MT reactive bridge below and the render closure share the same instance.
|
|
118
|
+
const offset = props.offset ?? ownOffset;
|
|
119
|
+
const idx = props.index ?? ownIndex;
|
|
120
|
+
|
|
121
|
+
// BG→MT bridge: when external code (or a dot tap) writes `idx.value = N`,
|
|
122
|
+
// we invoke the native `<scroll-view>.scrollTo` UI method on MT so the
|
|
123
|
+
// animation runs with platform physics (the same easing the user gets when
|
|
124
|
+
// they fling-snap). The dedup runs MT-side against the live scroll offset
|
|
125
|
+
// so we don't re-invoke for writes that just mirror a finished snap.
|
|
126
|
+
const scrollOnMT = runOnMainThread((index: number, pageW: number) => {
|
|
127
|
+
'main thread';
|
|
128
|
+
const el = scrollRef.current;
|
|
129
|
+
if (!el || pageW <= 0) return;
|
|
130
|
+
const target = index * pageW;
|
|
131
|
+
const current = offset.current.value;
|
|
132
|
+
if (Math.abs(current - target) < 0.5) return;
|
|
133
|
+
el.invoke('scrollTo', { index, smooth: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
effect(() => {
|
|
137
|
+
const v = idx.value;
|
|
138
|
+
if (typeof v !== 'number') return;
|
|
139
|
+
const pw = props.width
|
|
140
|
+
?? (layout.value && layout.value.width > 0 ? layout.value.width : undefined)
|
|
141
|
+
?? SCREEN_WIDTH_FALLBACK;
|
|
142
|
+
scrollOnMT(v, pw);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
const pageWidth = props.width
|
|
147
|
+
?? (layout.value && layout.value.width > 0 ? layout.value.width : undefined)
|
|
148
|
+
?? SCREEN_WIDTH_FALLBACK;
|
|
149
|
+
// Lynx horizontal `<scroll-view>` doesn't resolve `height: 100%` on
|
|
150
|
+
// children (same constraint as width), so the page wrapper needs a
|
|
151
|
+
// pixel value. Use the measured layout height when available; the
|
|
152
|
+
// screen-height fallback covers the first paint. Guard against the
|
|
153
|
+
// `0 ?? fallback` gotcha — a zero-sized layout report (e.g. before
|
|
154
|
+
// first paint) must fall through to the fallback.
|
|
155
|
+
const measuredHeight = layout.value && layout.value.height > 0 ? layout.value.height : undefined;
|
|
156
|
+
const pageHeight: number | string = props.height
|
|
157
|
+
?? measuredHeight
|
|
158
|
+
?? SCREEN_HEIGHT_FALLBACK;
|
|
159
|
+
const initialScrollLeft = (props.initialIndex ?? 0) * pageWidth;
|
|
160
|
+
const items = props.items;
|
|
161
|
+
const keyOf = props.keyExtractor;
|
|
162
|
+
return (
|
|
163
|
+
<scroll-view
|
|
164
|
+
main-thread:ref={scrollRef}
|
|
165
|
+
scroll-orientation="horizontal"
|
|
166
|
+
paging-enabled
|
|
167
|
+
show-scrollbar={false}
|
|
168
|
+
bounces={true}
|
|
169
|
+
scroll-left={initialScrollLeft}
|
|
170
|
+
class={props.class}
|
|
171
|
+
style={{ width: '100%', ...(props.style || {}) }}
|
|
172
|
+
bindlayoutchange={onLayoutChange}
|
|
173
|
+
main-thread-bindscroll={(e: { detail: { scrollLeft: number } }) => {
|
|
174
|
+
'main thread';
|
|
175
|
+
offset.current.value = e.detail.scrollLeft;
|
|
176
|
+
const __flush = (globalThis as Record<string, unknown>)['__FlushElementTree'] as (() => void) | undefined;
|
|
177
|
+
if (__flush) __flush();
|
|
178
|
+
}}
|
|
179
|
+
bindscroll={(e: { detail: { scrollLeft: number } }) => {
|
|
180
|
+
if (pageWidth <= 0) return;
|
|
181
|
+
const next = Math.round(e.detail.scrollLeft / pageWidth);
|
|
182
|
+
if (next !== idx.value) {
|
|
183
|
+
idx.value = next;
|
|
184
|
+
emit('pageChange', { index: next });
|
|
185
|
+
}
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{items.map((item, i) => (
|
|
189
|
+
<view
|
|
190
|
+
key={keyOf ? String(keyOf(item, i)) : String(i)}
|
|
191
|
+
style={{
|
|
192
|
+
width: pageWidth + 'px',
|
|
193
|
+
height: typeof pageHeight === 'number' ? pageHeight + 'px' : pageHeight,
|
|
194
|
+
flexShrink: 0,
|
|
195
|
+
flexGrow: 0,
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
{props.renderItem(item, i)}
|
|
199
|
+
</view>
|
|
200
|
+
))}
|
|
201
|
+
</scroll-view>
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
}) as <T>(props: SwiperProps<T>) => unknown;
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,20 @@ export { Swipeable } from './components/Swipeable.js';
|
|
|
39
39
|
export type { SwipeableProps, SwipeSide } from './components/Swipeable.js';
|
|
40
40
|
export { ScrollView } from './components/ScrollView.js';
|
|
41
41
|
export type { ScrollViewProps } from './components/ScrollView.js';
|
|
42
|
+
export { Swiper } from './components/Swiper.js';
|
|
43
|
+
export type { SwiperProps } from './components/Swiper.js';
|
|
44
|
+
export {
|
|
45
|
+
useSwiperDotProgress,
|
|
46
|
+
useSwiperDotScale,
|
|
47
|
+
useSwiperDotGrowX,
|
|
48
|
+
useSwiperDotWidth,
|
|
49
|
+
useSwiperDotTranslate,
|
|
50
|
+
} from './use-swiper-dot-progress.js';
|
|
51
|
+
export type {
|
|
52
|
+
SwiperDotHookInputs,
|
|
53
|
+
UseSwiperDotProgressOptions,
|
|
54
|
+
UseSwiperDotTranslateOptions,
|
|
55
|
+
} from './use-swiper-dot-progress.js';
|
|
42
56
|
|
|
43
57
|
// ScrollView ↔ child-gesture coordination (Phase 2.12.3). Public mostly so
|
|
44
58
|
// custom gesture components can opt in to the same auto-yield behavior that
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless `<Swiper>` indicator hooks.
|
|
3
|
+
*
|
|
4
|
+
* `<Swiper>` writes the live scroll offset to a `SharedValue<number>` on
|
|
5
|
+
* the MT thread every frame. To render an indicator the consumer needs
|
|
6
|
+
* one binding per element so `useAnimatedStyle` has a stable call-site —
|
|
7
|
+
* doing that inside `.map()` is fine (per-iteration call-sites are
|
|
8
|
+
* stable across renders), but the bookkeeping (range math, ref alloc)
|
|
9
|
+
* is fiddly and easy to get wrong.
|
|
10
|
+
*
|
|
11
|
+
* These hooks own the bookkeeping and return a `MainThreadRef` the
|
|
12
|
+
* caller spreads onto any element they want animated. That keeps the
|
|
13
|
+
* presentation in user-land (and in `@sigx/lynx-daisyui`'s themed
|
|
14
|
+
* `SwiperIndicator`) while logic lives here.
|
|
15
|
+
*
|
|
16
|
+
* Layering pattern mirrors the daisyui split:
|
|
17
|
+
* - `@sigx/lynx-gestures` owns headless logic (this file + the
|
|
18
|
+
* `<Swiper>` component itself).
|
|
19
|
+
* - `@sigx/lynx-daisyui` ships themed `<SwiperIndicator>` variants
|
|
20
|
+
* that consume these hooks and pick colours from `ThemeProvider`.
|
|
21
|
+
*
|
|
22
|
+
* @example Custom dot using the opacity hook
|
|
23
|
+
* ```tsx
|
|
24
|
+
* function MyDot({ offset, pageWidth, index }) {
|
|
25
|
+
* const ref = useSwiperDotProgress({ offset, pageWidth, index });
|
|
26
|
+
* return (
|
|
27
|
+
* <view
|
|
28
|
+
* main-thread:ref={ref}
|
|
29
|
+
* style={{ width: '8px', height: '8px', borderRadius: '4px',
|
|
30
|
+
* backgroundColor: 'tomato', opacity: '0' }}
|
|
31
|
+
* />
|
|
32
|
+
* );
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
import {
|
|
37
|
+
useAnimatedStyle,
|
|
38
|
+
useMainThreadRef,
|
|
39
|
+
type MainThread,
|
|
40
|
+
type MainThreadRef,
|
|
41
|
+
type SharedValue,
|
|
42
|
+
type MapperParams,
|
|
43
|
+
} from '@sigx/lynx';
|
|
44
|
+
|
|
45
|
+
/** Common per-dot inputs — offset is page-pixel space, index is the dot's page. */
|
|
46
|
+
export interface SwiperDotHookInputs {
|
|
47
|
+
/** Live MT-thread pixel offset from the Swiper's `offset` prop. */
|
|
48
|
+
offset: SharedValue<number>;
|
|
49
|
+
/** Page width in CSS pixels. Must match the Swiper's effective page width. */
|
|
50
|
+
pageWidth: number;
|
|
51
|
+
/** Zero-based page index this dot represents. */
|
|
52
|
+
index: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UseSwiperDotProgressOptions extends SwiperDotHookInputs {
|
|
56
|
+
/**
|
|
57
|
+
* Half-width of the input window in `pageWidth` units. The dot's
|
|
58
|
+
* animation runs from `(index − window) * pageWidth` to
|
|
59
|
+
* `(index + window) * pageWidth`. Default `1` — adjacent dots
|
|
60
|
+
* crossfade because their windows overlap.
|
|
61
|
+
*/
|
|
62
|
+
window?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Output values at `[centre − window·pageWidth, centre, centre +
|
|
65
|
+
* window·pageWidth]`. Default `[0, 1, 0]` (triangular). For "always
|
|
66
|
+
* active" decoration use `[0, 1, 0]` with opacity; for "scale
|
|
67
|
+
* pulse" pass e.g. `[1, 1.4, 1]` with channel `'scale'`.
|
|
68
|
+
*/
|
|
69
|
+
outputRange?: readonly [number, number, number];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a triangular range-map for the given dot index, defaulting to
|
|
74
|
+
* the opacity crossfade `<SwiperDots>` shipped with previously. Returns
|
|
75
|
+
* a `MainThreadRef` — spread it onto whatever element you want
|
|
76
|
+
* animated.
|
|
77
|
+
*/
|
|
78
|
+
export function useSwiperDotProgress(
|
|
79
|
+
opts: UseSwiperDotProgressOptions,
|
|
80
|
+
): MainThreadRef<MainThread.Element | null> {
|
|
81
|
+
return useSwiperDotChannel({
|
|
82
|
+
...opts,
|
|
83
|
+
channel: 'opacity',
|
|
84
|
+
outputRange: opts.outputRange ?? [0, 1, 0],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Scale-pulse variant — active dot scales up, neighbours scale down to
|
|
90
|
+
* the inactive baseline. Defaults: inactive `1`, active `1.4`.
|
|
91
|
+
*
|
|
92
|
+
* Uniform scale (both axes). For width-axis only growth (pill effect
|
|
93
|
+
* that keeps the dot's height stable) use `useSwiperDotGrowX` instead.
|
|
94
|
+
*/
|
|
95
|
+
export function useSwiperDotScale(opts: SwiperDotHookInputs & {
|
|
96
|
+
inactive?: number;
|
|
97
|
+
active?: number;
|
|
98
|
+
window?: number;
|
|
99
|
+
}): MainThreadRef<MainThread.Element | null> {
|
|
100
|
+
const inactive = opts.inactive ?? 1;
|
|
101
|
+
const active = opts.active ?? 1.4;
|
|
102
|
+
return useSwiperDotChannel({
|
|
103
|
+
offset: opts.offset,
|
|
104
|
+
pageWidth: opts.pageWidth,
|
|
105
|
+
index: opts.index,
|
|
106
|
+
window: opts.window,
|
|
107
|
+
channel: 'scale',
|
|
108
|
+
outputRange: [inactive, active, inactive],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Width-axis growth — the active dot stretches into a pill, neighbours
|
|
114
|
+
* shrink to a circle. Uses the `scaleX` channel, so the element's
|
|
115
|
+
* intrinsic size in the layout stays put; only the visual width
|
|
116
|
+
* changes. If you want surrounding siblings to physically shove apart
|
|
117
|
+
* use `useSwiperDotWidth` instead (it animates the `width` style
|
|
118
|
+
* property, which costs a layout pass each frame).
|
|
119
|
+
*/
|
|
120
|
+
export function useSwiperDotGrowX(opts: SwiperDotHookInputs & {
|
|
121
|
+
/** Width multiplier when inactive. Default `1` (the dot's base size). */
|
|
122
|
+
inactive?: number;
|
|
123
|
+
/** Width multiplier when active. Default `3` (a pill ~3× as wide as tall). */
|
|
124
|
+
active?: number;
|
|
125
|
+
window?: number;
|
|
126
|
+
}): MainThreadRef<MainThread.Element | null> {
|
|
127
|
+
const inactive = opts.inactive ?? 1;
|
|
128
|
+
const active = opts.active ?? 3;
|
|
129
|
+
return useSwiperDotChannel({
|
|
130
|
+
offset: opts.offset,
|
|
131
|
+
pageWidth: opts.pageWidth,
|
|
132
|
+
index: opts.index,
|
|
133
|
+
window: opts.window,
|
|
134
|
+
channel: 'scaleX',
|
|
135
|
+
outputRange: [inactive, active, inactive],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Layout-aware width growth — animates the element's `width` style in
|
|
141
|
+
* px. Use this when sibling layout must respond (siblings flex away as
|
|
142
|
+
* the pill grows). Slower than `useSwiperDotGrowX` because every frame
|
|
143
|
+
* re-runs layout.
|
|
144
|
+
*
|
|
145
|
+
* Defaults shape an 8px → 24px pill.
|
|
146
|
+
*/
|
|
147
|
+
export function useSwiperDotWidth(opts: SwiperDotHookInputs & {
|
|
148
|
+
/** Width in CSS pixels when inactive. Default `8`. */
|
|
149
|
+
inactive?: number;
|
|
150
|
+
/** Width in CSS pixels when active. Default `24`. */
|
|
151
|
+
active?: number;
|
|
152
|
+
window?: number;
|
|
153
|
+
}): MainThreadRef<MainThread.Element | null> {
|
|
154
|
+
const inactive = opts.inactive ?? 8;
|
|
155
|
+
const active = opts.active ?? 24;
|
|
156
|
+
return useSwiperDotChannel({
|
|
157
|
+
offset: opts.offset,
|
|
158
|
+
pageWidth: opts.pageWidth,
|
|
159
|
+
index: opts.index,
|
|
160
|
+
window: opts.window,
|
|
161
|
+
channel: 'width',
|
|
162
|
+
outputRange: [inactive, active, inactive],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Inputs for the track-wide translate hook used by the "bar" indicator variant. */
|
|
167
|
+
export interface UseSwiperDotTranslateOptions {
|
|
168
|
+
offset: SharedValue<number>;
|
|
169
|
+
/** Page width in CSS pixels. */
|
|
170
|
+
pageWidth: number;
|
|
171
|
+
/**
|
|
172
|
+
* Distance in CSS pixels that one full page of scroll should move
|
|
173
|
+
* the thumb by — typically `dotWidth + spacing` (the thumb steps to
|
|
174
|
+
* the next dot's centre when the swiper advances by one page).
|
|
175
|
+
*/
|
|
176
|
+
step: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Translate a single "thumb" element across the indicator track,
|
|
181
|
+
* proportional to the swiper's scroll progress. Use for the `bar`
|
|
182
|
+
* variant where a single pill slides between fixed dots.
|
|
183
|
+
*/
|
|
184
|
+
export function useSwiperDotTranslate(
|
|
185
|
+
opts: UseSwiperDotTranslateOptions,
|
|
186
|
+
): MainThreadRef<MainThread.Element | null> {
|
|
187
|
+
const ref = useMainThreadRef<MainThread.Element | null>(null);
|
|
188
|
+
// factor = step px per pageWidth px of offset. Guard against the
|
|
189
|
+
// divide-by-zero before first layout — a factor of 0 just parks the
|
|
190
|
+
// thumb at translateX(0), which is the correct initial position.
|
|
191
|
+
const factor = opts.pageWidth > 0 ? opts.step / opts.pageWidth : 0;
|
|
192
|
+
useAnimatedStyle(ref, opts.offset, 'translateX', { factor });
|
|
193
|
+
return ref;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
197
|
+
// Internals
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Channels with `RangeParams` support that the indicator hooks use.
|
|
201
|
+
* `width` / `height` run via the new layout-axis mappers; `scaleX` /
|
|
202
|
+
* `scale` / `opacity` are transform/opacity-only.
|
|
203
|
+
*/
|
|
204
|
+
type RangeChannel = 'opacity' | 'scale' | 'scaleX' | 'scaleY' | 'translateX' | 'translateY' | 'width' | 'height';
|
|
205
|
+
|
|
206
|
+
interface ChannelOptions<N extends RangeChannel> extends SwiperDotHookInputs {
|
|
207
|
+
channel: N;
|
|
208
|
+
outputRange: readonly [number, number, number];
|
|
209
|
+
window?: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function useSwiperDotChannel<N extends RangeChannel>(
|
|
213
|
+
opts: ChannelOptions<N>,
|
|
214
|
+
): MainThreadRef<MainThread.Element | null> {
|
|
215
|
+
const ref = useMainThreadRef<MainThread.Element | null>(null);
|
|
216
|
+
// Guard against pre-layout pageWidth=0: collapsing the inputRange to
|
|
217
|
+
// [0, 0, 0] would produce divide-by-zero / NaN in interpolateLinear.
|
|
218
|
+
// Fall back to a non-degenerate window so the binding stays valid; once
|
|
219
|
+
// layout settles and the parent re-renders with a real pageWidth, the
|
|
220
|
+
// values flow through normally.
|
|
221
|
+
const safePageWidth = opts.pageWidth > 0 ? opts.pageWidth : 1;
|
|
222
|
+
const center = opts.index * safePageWidth;
|
|
223
|
+
const w = (opts.window ?? 1) * safePageWidth;
|
|
224
|
+
const params: MapperParams[N] = {
|
|
225
|
+
inputRange: [center - w, center, center + w],
|
|
226
|
+
outputRange: [opts.outputRange[0], opts.outputRange[1], opts.outputRange[2]],
|
|
227
|
+
extrapolate: 'clamp',
|
|
228
|
+
} as MapperParams[N];
|
|
229
|
+
useAnimatedStyle(ref, opts.offset, opts.channel, params);
|
|
230
|
+
return ref;
|
|
231
|
+
}
|