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

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 (29) hide show
  1. package/README.md +49 -21
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/src/main/cpp/SmoothClipAndroid.h +5 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +28 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +192 -191
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +20 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +105 -34
  10. package/cpp/SmoothClipAnimationCurve.h +468 -0
  11. package/cpp/SmoothClipAnimationId.h +32 -0
  12. package/cpp/SmoothClipRegistry.h +11 -1
  13. package/cpp/SmoothClipVelocityTracker.h +27 -5
  14. package/ios/SmoothClipRegistry.mm +246 -97
  15. package/ios/SmoothClipTurboModule.cpp +4 -2
  16. package/ios/SmoothClipTurboModule.h +2 -1
  17. package/ios/SmoothClipView.mm +73 -16
  18. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  19. package/lib/module/drivers.native.js +98 -29
  20. package/lib/module/drivers.native.js.map +1 -1
  21. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  22. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  23. package/lib/typescript/src/driverTypes.d.ts +3 -2
  24. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  25. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/NativeSmoothClipModule.ts +2 -1
  28. package/src/driverTypes.ts +3 -2
  29. package/src/drivers.native.ts +132 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-smooth-clip-view",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Layout-free animated rounded clipping for React Native Fabric",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",
@@ -18,7 +18,8 @@ export interface Spec extends TurboModule {
18
18
  radius: CodegenTypes.Double,
19
19
  contentTranslateX: CodegenTypes.Double,
20
20
  contentTranslateY: CodegenTypes.Double,
21
- takeOwnership: boolean
21
+ takeOwnership: boolean,
22
+ overridePendingAnimation: boolean
22
23
  ): void;
23
24
  beginInteraction(
24
25
  driverId: CodegenTypes.Double
@@ -16,8 +16,9 @@ type ClipAnimationBase = Readonly<{
16
16
  * On the native drivers `driver.presentation.value` is not written from
17
17
  * `from` (it stays stale until success sets it to the target); the web
18
18
  * fallback does write it — its presentation SharedValue is the render
19
- * channel itself. Against a held pending-animation latch the seed is
20
- * dropped by design — latch intent wins.
19
+ * channel itself. Against a held pending-animation latch, explicit `from`
20
+ * is newer intent: the fused write cancels that latch once unfinished,
21
+ * applies `from`, and starts the replacement from that native value.
21
22
  *
22
23
  * Keyframes interpolate absolutely, so pass `frames[0].presentation` —
23
24
  * the seed renders it before the first animation frame, making the
@@ -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;
@@ -195,6 +211,14 @@ export function useSmoothClipDriver(
195
211
  // so animateTo must start from the native registry's latest value instead
196
212
  // of snapping back to the stale SharedValue.
197
213
  const scalarsStale = useSharedValue(0);
214
+ // Non-zero between the effect cleanup's native teardown and the next effect
215
+ // run. Native cannot tell "this driver was never seeded" from "this driver
216
+ // was destroyed and erased" — both are a missing registry entry — so the
217
+ // animation entry points would happily create a fresh state for a call that
218
+ // arrives after the hook is gone, leaving a latch that no view starts and no
219
+ // destroy cancels. Only the UI side knows the difference, so it is decided
220
+ // here.
221
+ const disposed = useSharedValue(0);
198
222
  const callbackRef = useRef(options.onAnimationComplete);
199
223
  const driverRef = useRef<SmoothClipDriver | null>(null);
200
224
  callbackRef.current = options.onAnimationComplete;
@@ -205,14 +229,21 @@ export function useSmoothClipDriver(
205
229
 
206
230
  const seedPresentation = (next: SmoothClipPresentation) => {
207
231
  'worklet';
208
- if (!clipPresentationEquals(presentation.value, next)) {
209
- suppressDeliveries.value += 1;
210
- presentation.value = next;
211
- }
232
+ if (clipPresentationEquals(presentation.value, next)) return;
233
+ suppressDeliveries.value += 1;
234
+ presentation.value = next;
235
+ // The listener runs synchronously inside that assignment, so the credit
236
+ // is either spent by now or was never spendable. deliver() returns before
237
+ // the decrement whenever the value dedupes against `last` — which happens
238
+ // exactly when native already holds it, e.g. freezing a latch back to the
239
+ // value it started from. An unspent credit would then swallow the next
240
+ // real declarative write and leave native a geometry behind for good.
241
+ if (suppressDeliveries.value > 0) suppressDeliveries.value -= 1;
212
242
  };
213
243
 
214
244
  const beginOnUI = (): SmoothClipPresentation => {
215
245
  'worklet';
246
+ if (disposed.value !== 0) return presentation.value;
216
247
  const current = presentationFromNative(
217
248
  beginInteractionHostFunction(driverId),
218
249
  presentation.value
@@ -226,7 +257,7 @@ export function useSmoothClipDriver(
226
257
 
227
258
  const setOnUI = (next: SmoothClipPresentation): void => {
228
259
  'worklet';
229
- if (!isFiniteClipPresentation(next)) return;
260
+ if (disposed.value !== 0 || !isFiniteClipPresentation(next)) return;
230
261
  activeAnimationId.value = 0;
231
262
  ownership.value = INTERACTIVE;
232
263
  scalarsStale.value = 0;
@@ -240,7 +271,8 @@ export function useSmoothClipDriver(
240
271
  clip.radius,
241
272
  contentTranslateX,
242
273
  contentTranslateY,
243
- true
274
+ true,
275
+ false
244
276
  );
245
277
  seedPresentation(next);
246
278
  };
@@ -252,10 +284,12 @@ export function useSmoothClipDriver(
252
284
  height: number,
253
285
  radius: number,
254
286
  contentTranslateX: number,
255
- contentTranslateY: number
287
+ contentTranslateY: number,
288
+ overridePendingAnimation = false
256
289
  ): void => {
257
290
  'worklet';
258
291
  if (
292
+ disposed.value !== 0 ||
259
293
  !Number.isFinite(x) ||
260
294
  !Number.isFinite(y) ||
261
295
  !Number.isFinite(width) ||
@@ -278,7 +312,8 @@ export function useSmoothClipDriver(
278
312
  radius,
279
313
  contentTranslateX,
280
314
  contentTranslateY,
281
- true
315
+ true,
316
+ overridePendingAnimation
282
317
  );
283
318
  };
284
319
 
@@ -293,6 +328,13 @@ export function useSmoothClipDriver(
293
328
  ) {
294
329
  return rejectAnimationHostFunction(driverId);
295
330
  }
331
+ // Past the hook's cleanup the native entry is gone for good, and an
332
+ // interactive start here would recreate it as a latch nothing can ever
333
+ // start or cancel. Reject before any side effect — not via
334
+ // rejectAnimation, which would mint an id and a completion for a driver
335
+ // whose JS state is already detached. This is the "unsupported dispatch"
336
+ // arm of the documented 0 contract.
337
+ if (disposed.value !== 0) return 0;
296
338
 
297
339
  const from = animation.from;
298
340
  if (from !== undefined) {
@@ -308,7 +350,8 @@ export function useSmoothClipDriver(
308
350
  from.clip.height,
309
351
  from.clip.radius,
310
352
  from.contentTranslateX,
311
- from.contentTranslateY
353
+ from.contentTranslateY,
354
+ true
312
355
  );
313
356
  }
314
357
 
@@ -325,6 +368,26 @@ export function useSmoothClipDriver(
325
368
  scalarsStale.value = 0;
326
369
  const startClip = start.clip;
327
370
  const targetClip = target.clip;
371
+ // Reanimated stamps a parallel animation's t0 on this same runtime as
372
+ // `__frameTimestamp || _getAnimationTimestamp()` (valueSetter.ts).
373
+ // Capturing the identical value here and handing it to the integrator
374
+ // keeps clip and content phase-locked in every branch — including a
375
+ // start issued from CALLBACK_INPUT (batched gesture moves), where the
376
+ // dispatching frame's stamp is EARLIER than the call: the native min()
377
+ // anchor alone would adopt that frame stamp while Reanimated, outside
378
+ // its rAF flush where __frameTimestamp is cleared, keeps the wall
379
+ // clock — a lead lasting the whole animation. NaN when the globals are
380
+ // absent (tests, non-worklets runtimes): native then falls back to its
381
+ // own clock plus the min() anchor, the pre-stamp behavior.
382
+ const workletGlobal = globalThis as {
383
+ __frameTimestamp?: number;
384
+ _getAnimationTimestamp?: () => number;
385
+ };
386
+ const startedAtMs =
387
+ workletGlobal.__frameTimestamp ||
388
+ (typeof workletGlobal._getAnimationTimestamp === 'function'
389
+ ? workletGlobal._getAnimationTimestamp()
390
+ : Number.NaN);
328
391
  let animationId: number;
329
392
 
330
393
  if (animation.type === 'timing') {
@@ -351,7 +414,8 @@ export function useSmoothClipDriver(
351
414
  y1,
352
415
  x2,
353
416
  y2,
354
- reduceMotion
417
+ reduceMotion,
418
+ startedAtMs
355
419
  );
356
420
  } else if (animation.type === 'spring') {
357
421
  const inheritVelocity =
@@ -379,7 +443,8 @@ export function useSmoothClipDriver(
379
443
  Math.max(0, animation.damping ?? 10),
380
444
  inheritVelocity ? 0 : (animation.initialVelocity as number),
381
445
  inheritVelocity,
382
- reduceMotion
446
+ reduceMotion,
447
+ startedAtMs
383
448
  );
384
449
  } else {
385
450
  animationId = animateKeyframesHostFunction(
@@ -401,16 +466,17 @@ export function useSmoothClipDriver(
401
466
  target.contentTranslateY,
402
467
  Math.max(0, animation.duration),
403
468
  flattenedKeyframes(animation),
404
- reduceMotion
469
+ reduceMotion,
470
+ startedAtMs
405
471
  );
406
472
  }
407
473
  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.
474
+ // The 0 sentinel: off-main, invalid-id, or otherwise unsupported
475
+ // dispatch. A missing driver is accepted when this call carries the
476
+ // authoritative interactive start above; a start-less missing-state
477
+ // request remains unsupported rather than inventing zero geometry.
478
+ // Validation failures never get here (they reject before any side
479
+ // effect with a fresh id and one finished:false completion).
414
480
  ownership.value = INTERACTIVE;
415
481
  presentation.value = start;
416
482
  if (scalarsWereStale) scalarsStale.value = 1;
@@ -426,6 +492,10 @@ export function useSmoothClipDriver(
426
492
  behavior: 'current' | 'target' = 'current'
427
493
  ): SmoothClipPresentation => {
428
494
  'worklet';
495
+ // Past the hook's cleanup the native entry is gone; the call would fail
496
+ // defined (handled=false) but it is the last driver.ui path that still
497
+ // crosses into native after the tombstone. Guard it like the rest.
498
+ if (disposed.value !== 0) return presentation.value;
429
499
  const values = cancelAnimationHostFunction(
430
500
  driverId,
431
501
  animationId,
@@ -591,9 +661,14 @@ export function useSmoothClipDriver(
591
661
  suppressed: SharedValue<number>,
592
662
  active: SharedValue<number>,
593
663
  stale: SharedValue<number>,
664
+ gone: SharedValue<number>,
594
665
  setter: typeof setPresentationHostFunction
595
666
  ) => {
596
667
  'worklet';
668
+ // First, before the listener or the seed: this effect run owns the
669
+ // driver again, so a StrictMode/<Activity> replay must clear the
670
+ // previous cleanup's tombstone or every later write would be rejected.
671
+ gone.value = 0;
597
672
  let last: SmoothClipPresentation | null = null;
598
673
  const deliver = (next: SmoothClipPresentation) => {
599
674
  if (
@@ -623,15 +698,19 @@ export function useSmoothClipDriver(
623
698
  clip.radius,
624
699
  contentTranslateX,
625
700
  contentTranslateY,
701
+ false,
626
702
  false
627
703
  );
628
704
  };
629
705
  source.addListener(listenerId, deliver);
630
706
  // 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.
707
+ // any view mounts on the ordinary path, and revives a driver destroyed
708
+ // by an effect-replay cleanup (StrictMode/<Activity>) on later runs.
709
+ // An animation worklet can win the scheduling race and create a latch
710
+ // first; Native ownership then marks that intent as newer, so this
711
+ // passive seed must not clear its active id or replay the target value.
633
712
  const current = source.value;
634
- if (isFiniteClipPresentation(current)) {
713
+ if (owner.value === INTERACTIVE && isFiniteClipPresentation(current)) {
635
714
  active.value = 0;
636
715
  owner.value = INTERACTIVE;
637
716
  stale.value = 0;
@@ -645,7 +724,8 @@ export function useSmoothClipDriver(
645
724
  current.clip.radius,
646
725
  current.contentTranslateX,
647
726
  current.contentTranslateY,
648
- true
727
+ true,
728
+ false
649
729
  );
650
730
  }
651
731
  },
@@ -656,6 +736,7 @@ export function useSmoothClipDriver(
656
736
  suppressDeliveries,
657
737
  activeAnimationId,
658
738
  scalarsStale,
739
+ disposed,
659
740
  setPresentationHostFunction
660
741
  );
661
742
 
@@ -668,20 +749,46 @@ export function useSmoothClipDriver(
668
749
  source: SharedValue<SmoothClipPresentation>,
669
750
  listenerId: number,
670
751
  nativeDriverId: number,
752
+ owner: SharedValue<number>,
753
+ active: SharedValue<number>,
754
+ stale: SharedValue<number>,
755
+ suppressed: SharedValue<number>,
756
+ gone: SharedValue<number>,
671
757
  destroy: typeof destroyDriverHostFunction
672
758
  ) => {
673
759
  'worklet';
674
760
  source.removeListener(listenerId);
675
761
  destroy(nativeDriverId);
762
+ // StrictMode replays the effect with these same SharedValues. Reset
763
+ // the UI-side tombstone only after native teardown so the next setup
764
+ // can seed or animate without inheriting stale Native ownership.
765
+ active.value = 0;
766
+ owner.value = INTERACTIVE;
767
+ stale.value = 0;
768
+ // Defensive: seedPresentation now reclaims its own unspent credit at
769
+ // the point of issue, so this should already be 0. Kept because the
770
+ // failure mode — one declarative write silently dropped for the rest
771
+ // of the next hook's life — is invisible until someone reports a
772
+ // stale clip rect.
773
+ suppressed.value = 0;
774
+ // Last: the native entry is now erased, so until the next effect run
775
+ // clears this, any call that would recreate it is rejected instead.
776
+ gone.value = 1;
676
777
  },
677
778
  presentation,
678
779
  driverId,
679
780
  driverId,
781
+ ownership,
782
+ activeAnimationId,
783
+ scalarsStale,
784
+ suppressDeliveries,
785
+ disposed,
680
786
  destroyDriverHostFunction
681
787
  );
682
788
  };
683
789
  }, [
684
790
  activeAnimationId,
791
+ disposed,
685
792
  ownership,
686
793
  presentation,
687
794
  scalarsStale,