react-native-smooth-clip-view 0.2.4 → 0.2.5

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,698 @@
1
+ // The shared NATIVE driver: iOS and Android both execute this file, via the
2
+ // one-line re-exports in drivers.ios.ts / drivers.android.ts that Metro's
3
+ // platform resolution picks up from index.ts's `./drivers` import. Only the
4
+ // `./smoothClipNative` import below is platform-split. drivers.ts (no
5
+ // suffix) is the web/Reanimated fallback, not this driver's sibling.
6
+ import { useEffect, useRef } from 'react';
7
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated';
8
+ import { isRNRuntime, scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
9
+ import NativeSmoothClipModule from './smoothClipNative';
10
+ import type {
11
+ ClipReduceMotion,
12
+ KeyframedClipAnimation,
13
+ SmoothClipAnimation,
14
+ SmoothClipDriver,
15
+ SmoothClipDriverOptions,
16
+ SpringClipAnimation,
17
+ TimingClipAnimation,
18
+ } from './driverTypes';
19
+ import {
20
+ allocateDriverId,
21
+ attachDriverState,
22
+ createDriverState,
23
+ detachDriverState,
24
+ getDriverState,
25
+ setDriverState,
26
+ } from './driverState';
27
+ import {
28
+ completeNativeAnimation,
29
+ synchronizeNativeCompletion,
30
+ } from './nativeCompletion';
31
+ import {
32
+ clipPresentationEquals,
33
+ createClipPresentation,
34
+ isFiniteClipPresentation,
35
+ type ClipGeometry,
36
+ type SmoothClipPresentation,
37
+ } from './geometry';
38
+ import {
39
+ createReactRequest,
40
+ rejectDriverRequests,
41
+ resolveReactRequest,
42
+ } from './reactRequests';
43
+
44
+ const INTERACTIVE = 0;
45
+ const NATIVE = 1;
46
+
47
+ const setPresentationHostFunction = NativeSmoothClipModule.setClipPresentation;
48
+ const beginInteractionHostFunction = NativeSmoothClipModule.beginInteraction;
49
+ const animateTimingHostFunction = NativeSmoothClipModule.animateTiming;
50
+ const animateSpringHostFunction = NativeSmoothClipModule.animateSpring;
51
+ const animateKeyframesHostFunction = NativeSmoothClipModule.animateKeyframes;
52
+ const rejectAnimationHostFunction = NativeSmoothClipModule.rejectAnimation;
53
+ const cancelAnimationHostFunction = NativeSmoothClipModule.cancelAnimation;
54
+ const destroyDriverHostFunction = NativeSmoothClipModule.destroyDriver;
55
+
56
+ // Workaround for react-native-worklets 0.10: the RN-side serialization cache
57
+ // hands out one remote-function registry id per function forever, but the
58
+ // registry entry is deleted as soon as any transient UI-side proxy of that
59
+ // function is garbage collected. A later scheduleOnRN for the same function
60
+ // then reads `undefined` from the registry and aborts in Value::getObject.
61
+ // Pinning one UI-side reference for the app lifetime keeps the proxy — and
62
+ // with it the registry entry — alive. Remove once worklets decouples registry
63
+ // cleanup from proxy lifetime.
64
+ scheduleOnUI((resolver: typeof resolveReactRequest) => {
65
+ 'worklet';
66
+ (globalThis as Record<string, unknown>).__smoothClipPinnedResolver = resolver;
67
+ }, resolveReactRequest);
68
+
69
+ function reduceMotionCode(value: ClipReduceMotion): number {
70
+ switch (value) {
71
+ case 'always':
72
+ return 1;
73
+ case 'never':
74
+ return 2;
75
+ default:
76
+ return 0;
77
+ }
78
+ }
79
+
80
+ function animationIsFinite(
81
+ target: SmoothClipPresentation,
82
+ animation: SmoothClipAnimation
83
+ ): boolean {
84
+ 'worklet';
85
+ if (
86
+ animation.from !== undefined &&
87
+ !isFiniteClipPresentation(animation.from)
88
+ ) {
89
+ // Silently dropping an invalid explicit start would re-open the exact
90
+ // stale-start handoff `from` exists to close — reject the whole call.
91
+ return false;
92
+ }
93
+ if (animation.type === 'timing') {
94
+ return (
95
+ Number.isFinite(animation.duration) &&
96
+ animation.controlPoints.every(Number.isFinite)
97
+ );
98
+ }
99
+ if (animation.type === 'spring') {
100
+ return [
101
+ animation.mass ?? 1,
102
+ animation.stiffness ?? 100,
103
+ animation.damping ?? 10,
104
+ animation.initialVelocity === 'inherit'
105
+ ? 0
106
+ : (animation.initialVelocity ?? 0),
107
+ ].every(Number.isFinite);
108
+ }
109
+ if (!Number.isFinite(animation.duration) || animation.frames.length < 2) {
110
+ return false;
111
+ }
112
+ let previousOffset = -1;
113
+ for (const frame of animation.frames) {
114
+ if (
115
+ !Number.isFinite(frame.offset) ||
116
+ frame.offset < 0 ||
117
+ frame.offset > 1 ||
118
+ frame.offset <= previousOffset ||
119
+ !isFiniteClipPresentation(frame.presentation)
120
+ ) {
121
+ return false;
122
+ }
123
+ previousOffset = frame.offset;
124
+ }
125
+ return (
126
+ animation.frames[0]?.offset === 0 &&
127
+ previousOffset === 1 &&
128
+ clipPresentationEquals(
129
+ animation.frames[animation.frames.length - 1]?.presentation ?? null,
130
+ target
131
+ )
132
+ );
133
+ }
134
+
135
+ function presentationFromNative(
136
+ values: ReadonlyArray<number>,
137
+ fallback: SmoothClipPresentation,
138
+ offset = 0
139
+ ): SmoothClipPresentation {
140
+ 'worklet';
141
+ if (values.length < offset + 7) return fallback;
142
+ const presentation = {
143
+ clip: {
144
+ x: values[offset] as number,
145
+ y: values[offset + 1] as number,
146
+ width: values[offset + 2] as number,
147
+ height: values[offset + 3] as number,
148
+ radius: values[offset + 4] as number,
149
+ },
150
+ contentTranslateX: values[offset + 5] as number,
151
+ contentTranslateY: values[offset + 6] as number,
152
+ };
153
+ return isFiniteClipPresentation(presentation) ? presentation : fallback;
154
+ }
155
+
156
+ function flattenedKeyframes(animation: KeyframedClipAnimation): number[] {
157
+ 'worklet';
158
+ const values: number[] = [];
159
+ for (const frame of animation.frames) {
160
+ const { clip, contentTranslateX, contentTranslateY } = frame.presentation;
161
+ values.push(
162
+ frame.offset,
163
+ clip.x,
164
+ clip.y,
165
+ clip.width,
166
+ clip.height,
167
+ clip.radius,
168
+ contentTranslateX,
169
+ contentTranslateY
170
+ );
171
+ }
172
+ return values;
173
+ }
174
+
175
+ function uiOnly(): never {
176
+ 'worklet';
177
+ throw new Error(
178
+ '[SmoothClipView] driver.ui methods must run on the UI runtime. Use driver.react from React code.'
179
+ );
180
+ }
181
+
182
+ export function useSmoothClipDriver(
183
+ initialValue: ClipGeometry | SmoothClipPresentation,
184
+ options: SmoothClipDriverOptions = {}
185
+ ): SmoothClipDriver {
186
+ const initialPresentation =
187
+ 'clip' in initialValue
188
+ ? initialValue
189
+ : createClipPresentation(initialValue);
190
+ const presentation = useSharedValue(initialPresentation);
191
+ const ownership = useSharedValue(INTERACTIVE);
192
+ const suppressDeliveries = useSharedValue(0);
193
+ const activeAnimationId = useSharedValue(0);
194
+ // Non-zero after a setScalars hot write: driver.presentation is then stale,
195
+ // so animateTo must start from the native registry's latest value instead
196
+ // of snapping back to the stale SharedValue.
197
+ const scalarsStale = useSharedValue(0);
198
+ const callbackRef = useRef(options.onAnimationComplete);
199
+ const driverRef = useRef<SmoothClipDriver | null>(null);
200
+ callbackRef.current = options.onAnimationComplete;
201
+
202
+ if (driverRef.current === null) {
203
+ const driverId = allocateDriverId();
204
+ const reduceMotion = reduceMotionCode(options.reduceMotion ?? 'system');
205
+
206
+ const seedPresentation = (next: SmoothClipPresentation) => {
207
+ 'worklet';
208
+ if (!clipPresentationEquals(presentation.value, next)) {
209
+ suppressDeliveries.value += 1;
210
+ presentation.value = next;
211
+ }
212
+ };
213
+
214
+ const beginOnUI = (): SmoothClipPresentation => {
215
+ 'worklet';
216
+ const current = presentationFromNative(
217
+ beginInteractionHostFunction(driverId),
218
+ presentation.value
219
+ );
220
+ activeAnimationId.value = 0;
221
+ ownership.value = INTERACTIVE;
222
+ scalarsStale.value = 0;
223
+ seedPresentation(current);
224
+ return current;
225
+ };
226
+
227
+ const setOnUI = (next: SmoothClipPresentation): void => {
228
+ 'worklet';
229
+ if (!isFiniteClipPresentation(next)) return;
230
+ activeAnimationId.value = 0;
231
+ ownership.value = INTERACTIVE;
232
+ scalarsStale.value = 0;
233
+ const { clip, contentTranslateX, contentTranslateY } = next;
234
+ setPresentationHostFunction(
235
+ driverId,
236
+ clip.x,
237
+ clip.y,
238
+ clip.width,
239
+ clip.height,
240
+ clip.radius,
241
+ contentTranslateX,
242
+ contentTranslateY,
243
+ true
244
+ );
245
+ seedPresentation(next);
246
+ };
247
+
248
+ const setScalarsOnUI = (
249
+ x: number,
250
+ y: number,
251
+ width: number,
252
+ height: number,
253
+ radius: number,
254
+ contentTranslateX: number,
255
+ contentTranslateY: number
256
+ ): void => {
257
+ 'worklet';
258
+ if (
259
+ !Number.isFinite(x) ||
260
+ !Number.isFinite(y) ||
261
+ !Number.isFinite(width) ||
262
+ !Number.isFinite(height) ||
263
+ !Number.isFinite(radius) ||
264
+ !Number.isFinite(contentTranslateX) ||
265
+ !Number.isFinite(contentTranslateY)
266
+ ) {
267
+ return;
268
+ }
269
+ if (activeAnimationId.value !== 0) activeAnimationId.value = 0;
270
+ if (ownership.value !== INTERACTIVE) ownership.value = INTERACTIVE;
271
+ if (scalarsStale.value === 0) scalarsStale.value = 1;
272
+ setPresentationHostFunction(
273
+ driverId,
274
+ x,
275
+ y,
276
+ width,
277
+ height,
278
+ radius,
279
+ contentTranslateX,
280
+ contentTranslateY,
281
+ true
282
+ );
283
+ };
284
+
285
+ const animateOnUI = (
286
+ target: SmoothClipPresentation,
287
+ animation: SmoothClipAnimation
288
+ ): number => {
289
+ 'worklet';
290
+ if (
291
+ !isFiniteClipPresentation(target) ||
292
+ !animationIsFinite(target, animation)
293
+ ) {
294
+ return rejectAnimationHostFunction(driverId);
295
+ }
296
+
297
+ const from = animation.from;
298
+ if (from !== undefined) {
299
+ // Fused hot write: exactly setScalars(from…) issued immediately
300
+ // before the handoff, so native's latest value — the animation
301
+ // start below — is the caller's explicit presentation. Also
302
+ // re-grabs from a running animation, which the implicit
303
+ // interactive-start path would silently skip.
304
+ setScalarsOnUI(
305
+ from.clip.x,
306
+ from.clip.y,
307
+ from.clip.width,
308
+ from.clip.height,
309
+ from.clip.radius,
310
+ from.contentTranslateX,
311
+ from.contentTranslateY
312
+ );
313
+ }
314
+
315
+ // After a setScalars hot write the SharedValue no longer matches what
316
+ // is on screen; passing it as the start would snap the animation back.
317
+ // Without an interactive start, native resolves the start from its own
318
+ // latest value instead.
319
+ const scalarsWereStale = scalarsStale.value !== 0;
320
+ const hasInteractiveStart =
321
+ ownership.value === INTERACTIVE && !scalarsWereStale;
322
+ const start = presentation.value;
323
+ ownership.value = NATIVE;
324
+ presentation.value = target;
325
+ scalarsStale.value = 0;
326
+ const startClip = start.clip;
327
+ const targetClip = target.clip;
328
+ let animationId: number;
329
+
330
+ if (animation.type === 'timing') {
331
+ const [x1, y1, x2, y2] = animation.controlPoints;
332
+ animationId = animateTimingHostFunction(
333
+ driverId,
334
+ hasInteractiveStart,
335
+ startClip.x,
336
+ startClip.y,
337
+ startClip.width,
338
+ startClip.height,
339
+ startClip.radius,
340
+ start.contentTranslateX,
341
+ start.contentTranslateY,
342
+ targetClip.x,
343
+ targetClip.y,
344
+ targetClip.width,
345
+ targetClip.height,
346
+ targetClip.radius,
347
+ target.contentTranslateX,
348
+ target.contentTranslateY,
349
+ Math.max(0, animation.duration),
350
+ x1,
351
+ y1,
352
+ x2,
353
+ y2,
354
+ reduceMotion
355
+ );
356
+ } else if (animation.type === 'spring') {
357
+ const inheritVelocity =
358
+ animation.initialVelocity === undefined ||
359
+ animation.initialVelocity === 'inherit';
360
+ animationId = animateSpringHostFunction(
361
+ driverId,
362
+ hasInteractiveStart,
363
+ startClip.x,
364
+ startClip.y,
365
+ startClip.width,
366
+ startClip.height,
367
+ startClip.radius,
368
+ start.contentTranslateX,
369
+ start.contentTranslateY,
370
+ targetClip.x,
371
+ targetClip.y,
372
+ targetClip.width,
373
+ targetClip.height,
374
+ targetClip.radius,
375
+ target.contentTranslateX,
376
+ target.contentTranslateY,
377
+ Math.max(0.0001, animation.mass ?? 1),
378
+ Math.max(0.0001, animation.stiffness ?? 100),
379
+ Math.max(0, animation.damping ?? 10),
380
+ inheritVelocity ? 0 : (animation.initialVelocity as number),
381
+ inheritVelocity,
382
+ reduceMotion
383
+ );
384
+ } else {
385
+ animationId = animateKeyframesHostFunction(
386
+ driverId,
387
+ hasInteractiveStart,
388
+ startClip.x,
389
+ startClip.y,
390
+ startClip.width,
391
+ startClip.height,
392
+ startClip.radius,
393
+ start.contentTranslateX,
394
+ start.contentTranslateY,
395
+ targetClip.x,
396
+ targetClip.y,
397
+ targetClip.width,
398
+ targetClip.height,
399
+ targetClip.radius,
400
+ target.contentTranslateX,
401
+ target.contentTranslateY,
402
+ Math.max(0, animation.duration),
403
+ flattenedKeyframes(animation),
404
+ reduceMotion
405
+ );
406
+ }
407
+ if (animationId === 0) {
408
+ // The 0 sentinel: the driver entry no longer exists (destroyed) or
409
+ // the call ran off the main thread — validation failures never get
410
+ // here (they reject before any side effect with a fresh id and one
411
+ // finished:false completion). Restore the two-call-desugar state and
412
+ // forward the registry's rejection result, which is 0 again in both
413
+ // reachable cases, with no completion — exactly the README contract.
414
+ ownership.value = INTERACTIVE;
415
+ presentation.value = start;
416
+ if (scalarsWereStale) scalarsStale.value = 1;
417
+ activeAnimationId.value = 0;
418
+ return rejectAnimationHostFunction(driverId);
419
+ }
420
+ activeAnimationId.value = animationId;
421
+ return animationId;
422
+ };
423
+
424
+ const cancelOnUI = (
425
+ animationId = 0,
426
+ behavior: 'current' | 'target' = 'current'
427
+ ): SmoothClipPresentation => {
428
+ 'worklet';
429
+ const values = cancelAnimationHostFunction(
430
+ driverId,
431
+ animationId,
432
+ behavior === 'target' ? 1 : 0
433
+ );
434
+ if (values.length < 8 || values[0] !== 1) return presentation.value;
435
+ const current = presentationFromNative(values, presentation.value, 1);
436
+ activeAnimationId.value = 0;
437
+ ownership.value = INTERACTIVE;
438
+ scalarsStale.value = 0;
439
+ seedPresentation(current);
440
+ return current;
441
+ };
442
+
443
+ const driver: SmoothClipDriver = {
444
+ kind: 'hybrid',
445
+ presentation,
446
+ ui: {
447
+ beginInteraction() {
448
+ 'worklet';
449
+ if (isRNRuntime()) return uiOnly();
450
+ return beginOnUI();
451
+ },
452
+ set(next) {
453
+ 'worklet';
454
+ if (isRNRuntime()) return uiOnly();
455
+ setOnUI(next);
456
+ },
457
+ setScalars(
458
+ x,
459
+ y,
460
+ width,
461
+ height,
462
+ radius,
463
+ contentTranslateX,
464
+ contentTranslateY
465
+ ) {
466
+ 'worklet';
467
+ if (isRNRuntime()) return uiOnly();
468
+ setScalarsOnUI(
469
+ x,
470
+ y,
471
+ width,
472
+ height,
473
+ radius,
474
+ contentTranslateX,
475
+ contentTranslateY
476
+ );
477
+ },
478
+ animateTo(target, animation) {
479
+ 'worklet';
480
+ if (isRNRuntime()) return uiOnly();
481
+ return animateOnUI(target, animation);
482
+ },
483
+ cancel(animationId = 0, behavior = 'current') {
484
+ 'worklet';
485
+ if (isRNRuntime()) return uiOnly();
486
+ return cancelOnUI(animationId, behavior);
487
+ },
488
+ },
489
+ react: {
490
+ beginInteraction() {
491
+ const { requestId, promise } =
492
+ createReactRequest<SmoothClipPresentation>(driverId);
493
+ scheduleOnUI(() => {
494
+ 'worklet';
495
+ const result = beginOnUI();
496
+ scheduleOnRN(resolveReactRequest, driverId, requestId, result);
497
+ });
498
+ return promise;
499
+ },
500
+ set(next) {
501
+ const { requestId, promise } = createReactRequest<void>(driverId);
502
+ scheduleOnUI(() => {
503
+ 'worklet';
504
+ setOnUI(next);
505
+ scheduleOnRN(resolveReactRequest, driverId, requestId, undefined);
506
+ });
507
+ return promise;
508
+ },
509
+ animateTo(target, animation) {
510
+ const { requestId, promise } = createReactRequest<number>(
511
+ driverId,
512
+ true
513
+ );
514
+ scheduleOnUI(() => {
515
+ 'worklet';
516
+ const result = animateOnUI(target, animation);
517
+ scheduleOnRN(
518
+ resolveReactRequest,
519
+ driverId,
520
+ requestId,
521
+ result,
522
+ true
523
+ );
524
+ });
525
+ return promise;
526
+ },
527
+ cancel(animationId = 0, behavior = 'current') {
528
+ const { requestId, promise } =
529
+ createReactRequest<SmoothClipPresentation>(driverId, true);
530
+ scheduleOnUI(() => {
531
+ 'worklet';
532
+ const result = cancelOnUI(animationId, behavior);
533
+ scheduleOnRN(
534
+ resolveReactRequest,
535
+ driverId,
536
+ requestId,
537
+ result,
538
+ true
539
+ );
540
+ });
541
+ return promise;
542
+ },
543
+ },
544
+ };
545
+
546
+ setDriverState(
547
+ driver,
548
+ createDriverState(
549
+ driverId,
550
+ initialPresentation,
551
+ presentation,
552
+ callbackRef,
553
+ activeAnimationId,
554
+ ownership
555
+ )
556
+ );
557
+ driverRef.current = driver;
558
+ }
559
+
560
+ const driver = driverRef.current;
561
+
562
+ useEffect(() => {
563
+ const currentDriver = driverRef.current;
564
+ if (!currentDriver) return undefined;
565
+ const state = getDriverState(currentDriver);
566
+ const { driverId } = state;
567
+ // Re-attach on every effect run: StrictMode/<Activity> replay the effect
568
+ // after its cleanup detached this state and destroyed the native driver.
569
+ // The authoritative seed below recreates the native entry in that case.
570
+ attachDriverState(state);
571
+
572
+ const subscription = NativeSmoothClipModule.onClipAnimationComplete(
573
+ (result) => {
574
+ if (result.driverId !== driverId) return;
575
+ synchronizeNativeCompletion(
576
+ activeAnimationId,
577
+ ownership,
578
+ result.animationId,
579
+ result.finished
580
+ );
581
+ completeNativeAnimation(driverId, result.animationId, result.finished);
582
+ }
583
+ );
584
+
585
+ scheduleOnUI(
586
+ (
587
+ source: SharedValue<SmoothClipPresentation>,
588
+ listenerId: number,
589
+ nativeDriverId: number,
590
+ owner: SharedValue<number>,
591
+ suppressed: SharedValue<number>,
592
+ active: SharedValue<number>,
593
+ stale: SharedValue<number>,
594
+ setter: typeof setPresentationHostFunction
595
+ ) => {
596
+ 'worklet';
597
+ let last: SmoothClipPresentation | null = null;
598
+ const deliver = (next: SmoothClipPresentation) => {
599
+ if (
600
+ !isFiniteClipPresentation(next) ||
601
+ clipPresentationEquals(last, next)
602
+ ) {
603
+ return;
604
+ }
605
+ // Record `last` only for values native has actually observed:
606
+ // deliveries dropped during native ownership must stay eligible
607
+ // for re-delivery once interactive ownership returns.
608
+ if (owner.value !== INTERACTIVE) return;
609
+ if (suppressed.value > 0) {
610
+ suppressed.value -= 1;
611
+ last = next;
612
+ return;
613
+ }
614
+ last = next;
615
+ if (stale.value !== 0) stale.value = 0;
616
+ const { clip, contentTranslateX, contentTranslateY } = next;
617
+ setter(
618
+ nativeDriverId,
619
+ clip.x,
620
+ clip.y,
621
+ clip.width,
622
+ clip.height,
623
+ clip.radius,
624
+ contentTranslateX,
625
+ contentTranslateY,
626
+ false
627
+ );
628
+ };
629
+ source.addListener(listenerId, deliver);
630
+ // Authoritative take-ownership seed. Creates the native entry before
631
+ // any view mounts on the first run, and revives a driver destroyed by
632
+ // an effect-replay cleanup (StrictMode/<Activity>) on later runs.
633
+ const current = source.value;
634
+ if (isFiniteClipPresentation(current)) {
635
+ active.value = 0;
636
+ owner.value = INTERACTIVE;
637
+ stale.value = 0;
638
+ last = current;
639
+ setter(
640
+ nativeDriverId,
641
+ current.clip.x,
642
+ current.clip.y,
643
+ current.clip.width,
644
+ current.clip.height,
645
+ current.clip.radius,
646
+ current.contentTranslateX,
647
+ current.contentTranslateY,
648
+ true
649
+ );
650
+ }
651
+ },
652
+ presentation,
653
+ driverId,
654
+ driverId,
655
+ ownership,
656
+ suppressDeliveries,
657
+ activeAnimationId,
658
+ scalarsStale,
659
+ setPresentationHostFunction
660
+ );
661
+
662
+ return () => {
663
+ subscription.remove();
664
+ rejectDriverRequests(driverId);
665
+ detachDriverState(state);
666
+ scheduleOnUI(
667
+ (
668
+ source: SharedValue<SmoothClipPresentation>,
669
+ listenerId: number,
670
+ nativeDriverId: number,
671
+ destroy: typeof destroyDriverHostFunction
672
+ ) => {
673
+ 'worklet';
674
+ source.removeListener(listenerId);
675
+ destroy(nativeDriverId);
676
+ },
677
+ presentation,
678
+ driverId,
679
+ driverId,
680
+ destroyDriverHostFunction
681
+ );
682
+ };
683
+ }, [
684
+ activeAnimationId,
685
+ ownership,
686
+ presentation,
687
+ scalarsStale,
688
+ suppressDeliveries,
689
+ ]);
690
+
691
+ return driver;
692
+ }
693
+
694
+ export type {
695
+ KeyframedClipAnimation,
696
+ SpringClipAnimation,
697
+ TimingClipAnimation,
698
+ };