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