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

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.
Files changed (41) hide show
  1. package/README.md +68 -26
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/build.gradle +15 -0
  4. package/android/src/main/cpp/SmoothClipAndroid.h +12 -8
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +39 -15
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +288 -285
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +28 -4
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +180 -42
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +1 -0
  12. package/cpp/SmoothClipAnimationCurve.h +560 -0
  13. package/cpp/SmoothClipAnimationId.h +32 -0
  14. package/cpp/SmoothClipRegistry.h +11 -1
  15. package/cpp/SmoothClipVelocityTracker.h +30 -10
  16. package/ios/SmoothClipRegistry.mm +398 -150
  17. package/ios/SmoothClipTurboModule.cpp +4 -2
  18. package/ios/SmoothClipTurboModule.h +2 -1
  19. package/ios/SmoothClipView.mm +85 -140
  20. package/ios/SmoothClipViewRegistry.h +4 -1
  21. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  22. package/lib/module/driverState.js +8 -0
  23. package/lib/module/driverState.js.map +1 -1
  24. package/lib/module/drivers.native.js +122 -32
  25. package/lib/module/drivers.native.js.map +1 -1
  26. package/lib/module/reactRequests.js +9 -3
  27. package/lib/module/reactRequests.js.map +1 -1
  28. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  29. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  30. package/lib/typescript/src/driverState.d.ts.map +1 -1
  31. package/lib/typescript/src/driverTypes.d.ts +3 -2
  32. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  34. package/lib/typescript/src/reactRequests.d.ts +3 -1
  35. package/lib/typescript/src/reactRequests.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/NativeSmoothClipModule.ts +2 -1
  38. package/src/driverState.ts +8 -0
  39. package/src/driverTypes.ts +3 -2
  40. package/src/drivers.native.ts +160 -29
  41. package/src/reactRequests.ts +14 -2
@@ -46,9 +46,25 @@ const NATIVE = 1;
46
46
 
47
47
  const setPresentationHostFunction = NativeSmoothClipModule.setClipPresentation;
48
48
  const beginInteractionHostFunction = NativeSmoothClipModule.beginInteraction;
49
- const animateTimingHostFunction = NativeSmoothClipModule.animateTiming;
50
- const animateSpringHostFunction = NativeSmoothClipModule.animateSpring;
51
- const animateKeyframesHostFunction = NativeSmoothClipModule.animateKeyframes;
49
+
50
+ // The animate* host functions take one argument beyond the TurboModule spec:
51
+ // the Reanimated-rule start stamp in milliseconds (see animateOnUI). Android's
52
+ // JSI bindings read it as an optional trailing argument; the iOS TurboModule
53
+ // reads its declared parameters positionally and ignores extras, so the
54
+ // codegen spec stays untouched and both platforms take the same call. A
55
+ // narrower function is assignable to the widened type, so no cast is needed.
56
+ type WithStartStamp<F> = F extends (...args: infer A) => infer R
57
+ ? (...args: [...A, startedAtMs: number]) => R
58
+ : never;
59
+ const animateTimingHostFunction: WithStartStamp<
60
+ typeof NativeSmoothClipModule.animateTiming
61
+ > = NativeSmoothClipModule.animateTiming;
62
+ const animateSpringHostFunction: WithStartStamp<
63
+ typeof NativeSmoothClipModule.animateSpring
64
+ > = NativeSmoothClipModule.animateSpring;
65
+ const animateKeyframesHostFunction: WithStartStamp<
66
+ typeof NativeSmoothClipModule.animateKeyframes
67
+ > = NativeSmoothClipModule.animateKeyframes;
52
68
  const rejectAnimationHostFunction = NativeSmoothClipModule.rejectAnimation;
53
69
  const cancelAnimationHostFunction = NativeSmoothClipModule.cancelAnimation;
54
70
  const destroyDriverHostFunction = NativeSmoothClipModule.destroyDriver;
@@ -91,9 +107,19 @@ function animationIsFinite(
91
107
  return false;
92
108
  }
93
109
  if (animation.type === 'timing') {
110
+ const [x1, , x2] = animation.controlPoints;
94
111
  return (
95
112
  Number.isFinite(animation.duration) &&
96
- animation.controlPoints.every(Number.isFinite)
113
+ animation.controlPoints.every(Number.isFinite) &&
114
+ // A cubic-bezier easing is only defined for x within [0,1] (the CSS /
115
+ // CoreAnimation / Reanimated Easing.bezier contract). Out-of-range x
116
+ // makes the Android parameter solve meaningless while CoreAnimation
117
+ // silently clamps — reject like any other invalid input instead of
118
+ // diverging per platform.
119
+ x1 >= 0 &&
120
+ x1 <= 1 &&
121
+ x2 >= 0 &&
122
+ x2 <= 1
97
123
  );
98
124
  }
99
125
  if (animation.type === 'spring') {
@@ -195,6 +221,14 @@ export function useSmoothClipDriver(
195
221
  // so animateTo must start from the native registry's latest value instead
196
222
  // of snapping back to the stale SharedValue.
197
223
  const scalarsStale = useSharedValue(0);
224
+ // Non-zero between the effect cleanup's native teardown and the next effect
225
+ // run. Native cannot tell "this driver was never seeded" from "this driver
226
+ // was destroyed and erased" — both are a missing registry entry — so the
227
+ // animation entry points would happily create a fresh state for a call that
228
+ // arrives after the hook is gone, leaving a latch that no view starts and no
229
+ // destroy cancels. Only the UI side knows the difference, so it is decided
230
+ // here.
231
+ const disposed = useSharedValue(0);
198
232
  const callbackRef = useRef(options.onAnimationComplete);
199
233
  const driverRef = useRef<SmoothClipDriver | null>(null);
200
234
  callbackRef.current = options.onAnimationComplete;
@@ -205,14 +239,21 @@ export function useSmoothClipDriver(
205
239
 
206
240
  const seedPresentation = (next: SmoothClipPresentation) => {
207
241
  'worklet';
208
- if (!clipPresentationEquals(presentation.value, next)) {
209
- suppressDeliveries.value += 1;
210
- presentation.value = next;
211
- }
242
+ if (clipPresentationEquals(presentation.value, next)) return;
243
+ suppressDeliveries.value += 1;
244
+ presentation.value = next;
245
+ // The listener runs synchronously inside that assignment, so the credit
246
+ // is either spent by now or was never spendable. deliver() returns before
247
+ // the decrement whenever the value dedupes against `last` — which happens
248
+ // exactly when native already holds it, e.g. freezing a latch back to the
249
+ // value it started from. An unspent credit would then swallow the next
250
+ // real declarative write and leave native a geometry behind for good.
251
+ if (suppressDeliveries.value > 0) suppressDeliveries.value -= 1;
212
252
  };
213
253
 
214
254
  const beginOnUI = (): SmoothClipPresentation => {
215
255
  'worklet';
256
+ if (disposed.value !== 0) return presentation.value;
216
257
  const current = presentationFromNative(
217
258
  beginInteractionHostFunction(driverId),
218
259
  presentation.value
@@ -226,7 +267,7 @@ export function useSmoothClipDriver(
226
267
 
227
268
  const setOnUI = (next: SmoothClipPresentation): void => {
228
269
  'worklet';
229
- if (!isFiniteClipPresentation(next)) return;
270
+ if (disposed.value !== 0 || !isFiniteClipPresentation(next)) return;
230
271
  activeAnimationId.value = 0;
231
272
  ownership.value = INTERACTIVE;
232
273
  scalarsStale.value = 0;
@@ -240,7 +281,8 @@ export function useSmoothClipDriver(
240
281
  clip.radius,
241
282
  contentTranslateX,
242
283
  contentTranslateY,
243
- true
284
+ true,
285
+ false
244
286
  );
245
287
  seedPresentation(next);
246
288
  };
@@ -252,10 +294,12 @@ export function useSmoothClipDriver(
252
294
  height: number,
253
295
  radius: number,
254
296
  contentTranslateX: number,
255
- contentTranslateY: number
297
+ contentTranslateY: number,
298
+ overridePendingAnimation = false
256
299
  ): void => {
257
300
  'worklet';
258
301
  if (
302
+ disposed.value !== 0 ||
259
303
  !Number.isFinite(x) ||
260
304
  !Number.isFinite(y) ||
261
305
  !Number.isFinite(width) ||
@@ -278,7 +322,8 @@ export function useSmoothClipDriver(
278
322
  radius,
279
323
  contentTranslateX,
280
324
  contentTranslateY,
281
- true
325
+ true,
326
+ overridePendingAnimation
282
327
  );
283
328
  };
284
329
 
@@ -287,13 +332,22 @@ export function useSmoothClipDriver(
287
332
  animation: SmoothClipAnimation
288
333
  ): number => {
289
334
  'worklet';
335
+ // Cleanup owns the terminal state. Check it before validation so even an
336
+ // invalid stale call cannot mint a native rejection id/completion against
337
+ // a driver whose listener and registry entry are already gone.
338
+ if (disposed.value !== 0) return 0;
290
339
  if (
291
340
  !isFiniteClipPresentation(target) ||
292
341
  !animationIsFinite(target, animation)
293
342
  ) {
294
343
  return rejectAnimationHostFunction(driverId);
295
344
  }
296
-
345
+ // Past the hook's cleanup the native entry is gone for good, and an
346
+ // interactive start here would recreate it as a latch nothing can ever
347
+ // start or cancel. Reject before any side effect — not via
348
+ // rejectAnimation, which would mint an id and a completion for a driver
349
+ // whose JS state is already detached. This is the "unsupported dispatch"
350
+ // arm of the documented 0 contract.
297
351
  const from = animation.from;
298
352
  if (from !== undefined) {
299
353
  // Fused hot write: exactly setScalars(from…) issued immediately
@@ -308,7 +362,8 @@ export function useSmoothClipDriver(
308
362
  from.clip.height,
309
363
  from.clip.radius,
310
364
  from.contentTranslateX,
311
- from.contentTranslateY
365
+ from.contentTranslateY,
366
+ true
312
367
  );
313
368
  }
314
369
 
@@ -325,6 +380,26 @@ export function useSmoothClipDriver(
325
380
  scalarsStale.value = 0;
326
381
  const startClip = start.clip;
327
382
  const targetClip = target.clip;
383
+ // Reanimated stamps a parallel animation's t0 on this same runtime as
384
+ // `__frameTimestamp || _getAnimationTimestamp()` (valueSetter.ts).
385
+ // Capturing the identical value here and handing it to the integrator
386
+ // keeps clip and content phase-locked in every branch — including a
387
+ // start issued from CALLBACK_INPUT (batched gesture moves), where the
388
+ // dispatching frame's stamp is EARLIER than the call: the native min()
389
+ // anchor alone would adopt that frame stamp while Reanimated, outside
390
+ // its rAF flush where __frameTimestamp is cleared, keeps the wall
391
+ // clock — a lead lasting the whole animation. NaN when the globals are
392
+ // absent (tests, non-worklets runtimes): native then falls back to its
393
+ // own clock plus the min() anchor, the pre-stamp behavior.
394
+ const workletGlobal = globalThis as {
395
+ __frameTimestamp?: number;
396
+ _getAnimationTimestamp?: () => number;
397
+ };
398
+ const startedAtMs =
399
+ workletGlobal.__frameTimestamp ||
400
+ (typeof workletGlobal._getAnimationTimestamp === 'function'
401
+ ? workletGlobal._getAnimationTimestamp()
402
+ : Number.NaN);
328
403
  let animationId: number;
329
404
 
330
405
  if (animation.type === 'timing') {
@@ -351,7 +426,8 @@ export function useSmoothClipDriver(
351
426
  y1,
352
427
  x2,
353
428
  y2,
354
- reduceMotion
429
+ reduceMotion,
430
+ startedAtMs
355
431
  );
356
432
  } else if (animation.type === 'spring') {
357
433
  const inheritVelocity =
@@ -379,7 +455,8 @@ export function useSmoothClipDriver(
379
455
  Math.max(0, animation.damping ?? 10),
380
456
  inheritVelocity ? 0 : (animation.initialVelocity as number),
381
457
  inheritVelocity,
382
- reduceMotion
458
+ reduceMotion,
459
+ startedAtMs
383
460
  );
384
461
  } else {
385
462
  animationId = animateKeyframesHostFunction(
@@ -401,16 +478,19 @@ export function useSmoothClipDriver(
401
478
  target.contentTranslateY,
402
479
  Math.max(0, animation.duration),
403
480
  flattenedKeyframes(animation),
404
- reduceMotion
481
+ reduceMotion,
482
+ startedAtMs
405
483
  );
406
484
  }
407
485
  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.
486
+ // The 0 sentinel: off-main, invalid-id, or otherwise unsupported
487
+ // dispatch. A missing driver is accepted when this call carries the
488
+ // authoritative interactive start above; a start-less missing-state
489
+ // request remains unsupported rather than inventing zero geometry.
490
+ // Validation failures never get here: they reject before any side
491
+ // effect, with a fresh id and one finished:false completion once the
492
+ // native entry exists — in the pre-seed window rejectAnimation has no
493
+ // entry and returns the bare 0 sentinel with no completion instead.
414
494
  ownership.value = INTERACTIVE;
415
495
  presentation.value = start;
416
496
  if (scalarsWereStale) scalarsStale.value = 1;
@@ -426,6 +506,10 @@ export function useSmoothClipDriver(
426
506
  behavior: 'current' | 'target' = 'current'
427
507
  ): SmoothClipPresentation => {
428
508
  'worklet';
509
+ // Past the hook's cleanup the native entry is gone; the call would fail
510
+ // defined (handled=false) but it is the last driver.ui path that still
511
+ // crosses into native after the tombstone. Guard it like the rest.
512
+ if (disposed.value !== 0) return presentation.value;
429
513
  const values = cancelAnimationHostFunction(
430
514
  driverId,
431
515
  animationId,
@@ -498,7 +582,14 @@ export function useSmoothClipDriver(
498
582
  return promise;
499
583
  },
500
584
  set(next) {
501
- const { requestId, promise } = createReactRequest<void>(driverId);
585
+ // Teardown resolves (undefined) rather than rejects: `set` is a
586
+ // documented fire-and-forget call, and a promise discarded with
587
+ // `void` must not become an unhandled rejection at unmount.
588
+ const { requestId, promise } = createReactRequest<void>(
589
+ driverId,
590
+ false,
591
+ { value: undefined }
592
+ );
502
593
  scheduleOnUI(() => {
503
594
  'worklet';
504
595
  setOnUI(next);
@@ -507,9 +598,12 @@ export function useSmoothClipDriver(
507
598
  return promise;
508
599
  },
509
600
  animateTo(target, animation) {
601
+ // Teardown resolves with the documented 0 rejection sentinel (see
602
+ // `set` above): the README's own example voids this promise.
510
603
  const { requestId, promise } = createReactRequest<number>(
511
604
  driverId,
512
- true
605
+ true,
606
+ { value: 0 }
513
607
  );
514
608
  scheduleOnUI(() => {
515
609
  'worklet';
@@ -591,9 +685,14 @@ export function useSmoothClipDriver(
591
685
  suppressed: SharedValue<number>,
592
686
  active: SharedValue<number>,
593
687
  stale: SharedValue<number>,
688
+ gone: SharedValue<number>,
594
689
  setter: typeof setPresentationHostFunction
595
690
  ) => {
596
691
  'worklet';
692
+ // First, before the listener or the seed: this effect run owns the
693
+ // driver again, so a StrictMode/<Activity> replay must clear the
694
+ // previous cleanup's tombstone or every later write would be rejected.
695
+ gone.value = 0;
597
696
  let last: SmoothClipPresentation | null = null;
598
697
  const deliver = (next: SmoothClipPresentation) => {
599
698
  if (
@@ -623,15 +722,19 @@ export function useSmoothClipDriver(
623
722
  clip.radius,
624
723
  contentTranslateX,
625
724
  contentTranslateY,
725
+ false,
626
726
  false
627
727
  );
628
728
  };
629
729
  source.addListener(listenerId, deliver);
630
730
  // 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.
731
+ // any view mounts on the ordinary path, and revives a driver destroyed
732
+ // by an effect-replay cleanup (StrictMode/<Activity>) on later runs.
733
+ // An animation worklet can win the scheduling race and create a latch
734
+ // first; Native ownership then marks that intent as newer, so this
735
+ // passive seed must not clear its active id or replay the target value.
633
736
  const current = source.value;
634
- if (isFiniteClipPresentation(current)) {
737
+ if (owner.value === INTERACTIVE && isFiniteClipPresentation(current)) {
635
738
  active.value = 0;
636
739
  owner.value = INTERACTIVE;
637
740
  stale.value = 0;
@@ -645,7 +748,8 @@ export function useSmoothClipDriver(
645
748
  current.clip.radius,
646
749
  current.contentTranslateX,
647
750
  current.contentTranslateY,
648
- true
751
+ true,
752
+ false
649
753
  );
650
754
  }
651
755
  },
@@ -656,6 +760,7 @@ export function useSmoothClipDriver(
656
760
  suppressDeliveries,
657
761
  activeAnimationId,
658
762
  scalarsStale,
763
+ disposed,
659
764
  setPresentationHostFunction
660
765
  );
661
766
 
@@ -668,20 +773,46 @@ export function useSmoothClipDriver(
668
773
  source: SharedValue<SmoothClipPresentation>,
669
774
  listenerId: number,
670
775
  nativeDriverId: number,
776
+ owner: SharedValue<number>,
777
+ active: SharedValue<number>,
778
+ stale: SharedValue<number>,
779
+ suppressed: SharedValue<number>,
780
+ gone: SharedValue<number>,
671
781
  destroy: typeof destroyDriverHostFunction
672
782
  ) => {
673
783
  'worklet';
674
784
  source.removeListener(listenerId);
675
785
  destroy(nativeDriverId);
786
+ // StrictMode replays the effect with these same SharedValues. Reset
787
+ // the UI-side tombstone only after native teardown so the next setup
788
+ // can seed or animate without inheriting stale Native ownership.
789
+ active.value = 0;
790
+ owner.value = INTERACTIVE;
791
+ stale.value = 0;
792
+ // Defensive: seedPresentation now reclaims its own unspent credit at
793
+ // the point of issue, so this should already be 0. Kept because the
794
+ // failure mode — one declarative write silently dropped for the rest
795
+ // of the next hook's life — is invisible until someone reports a
796
+ // stale clip rect.
797
+ suppressed.value = 0;
798
+ // Last: the native entry is now erased, so until the next effect run
799
+ // clears this, any call that would recreate it is rejected instead.
800
+ gone.value = 1;
676
801
  },
677
802
  presentation,
678
803
  driverId,
679
804
  driverId,
805
+ ownership,
806
+ activeAnimationId,
807
+ scalarsStale,
808
+ suppressDeliveries,
809
+ disposed,
680
810
  destroyDriverHostFunction
681
811
  );
682
812
  };
683
813
  }, [
684
814
  activeAnimationId,
815
+ disposed,
685
816
  ownership,
686
817
  presentation,
687
818
  scalarsStale,
@@ -6,6 +6,11 @@ import {
6
6
  type PendingRequest = {
7
7
  resolve(value: unknown): void;
8
8
  reject(error: Error): void;
9
+ // Set for calls whose documented usage is fire-and-forget (`void
10
+ // driver.react.animateTo(...)`): teardown resolves them with this benign
11
+ // sentinel instead of rejecting, so a discarded promise cannot surface as
12
+ // an unhandled rejection during ordinary unmount.
13
+ teardownResolution?: { value: unknown };
9
14
  };
10
15
 
11
16
  const requests = new Map<number, PendingRequest>();
@@ -14,7 +19,8 @@ let nextRequestId = 0;
14
19
 
15
20
  export function createReactRequest<T>(
16
21
  driverId: number,
17
- deferCompletions = false
22
+ deferCompletions = false,
23
+ teardownResolution?: { value: unknown }
18
24
  ): { requestId: number; promise: Promise<T> } {
19
25
  nextRequestId = (nextRequestId % 0x7ffffffe) + 1;
20
26
  const requestId = nextRequestId;
@@ -27,6 +33,7 @@ export function createReactRequest<T>(
27
33
  requests.set(requestId, {
28
34
  resolve: resolveRequest as (value: unknown) => void,
29
35
  reject: rejectRequest,
36
+ teardownResolution,
30
37
  });
31
38
  const driverRequests = requestsByDriver.get(driverId) ?? new Set<number>();
32
39
  driverRequests.add(requestId);
@@ -58,7 +65,12 @@ export function rejectDriverRequests(driverId: number): void {
58
65
  for (const requestId of driverRequests) {
59
66
  const request = requests.get(requestId);
60
67
  requests.delete(requestId);
61
- request?.reject(new Error('[SmoothClipView] Driver was destroyed.'));
68
+ if (!request) continue;
69
+ if (request.teardownResolution) {
70
+ request.resolve(request.teardownResolution.value);
71
+ } else {
72
+ request.reject(new Error('[SmoothClipView] Driver was destroyed.'));
73
+ }
62
74
  }
63
75
  requestsByDriver.delete(driverId);
64
76
  }