react-native-smooth-clip-view 0.2.4 → 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 (40) hide show
  1. package/README.md +62 -25
  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 +198 -189
  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 +32 -15
  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.android.js +3 -1
  20. package/lib/module/drivers.android.js.map +1 -1
  21. package/lib/module/drivers.ios.js +3 -391
  22. package/lib/module/drivers.ios.js.map +1 -1
  23. package/lib/module/drivers.native.js +470 -0
  24. package/lib/module/drivers.native.js.map +1 -0
  25. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  26. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  27. package/lib/typescript/src/driverTypes.d.ts +6 -3
  28. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  29. package/lib/typescript/src/drivers.android.d.ts +2 -2
  30. package/lib/typescript/src/drivers.android.d.ts.map +1 -1
  31. package/lib/typescript/src/drivers.ios.d.ts +2 -4
  32. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts +5 -0
  34. package/lib/typescript/src/drivers.native.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/NativeSmoothClipModule.ts +2 -1
  37. package/src/driverTypes.ts +6 -3
  38. package/src/drivers.android.ts +4 -2
  39. package/src/drivers.ios.ts +4 -687
  40. package/src/drivers.native.ts +805 -0
@@ -19,17 +19,22 @@
19
19
  #include <utility>
20
20
  #include <vector>
21
21
 
22
+ #include "SmoothClipAnimationId.h"
23
+ #include "SmoothClipAnimationCurve.h"
24
+
22
25
  @interface SmoothClipView (Registry)
23
26
  - (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation;
27
+ - (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation
28
+ recordVelocitySample:(BOOL)recordVelocitySample;
24
29
  - (smoothclip::Presentation)smoothClipFreezePresentation;
25
30
  - (smoothclip::Presentation)smoothClipCurrentPresentation;
26
- - (void)smoothClipAnimateTiming:(smoothclip::Presentation)presentation
31
+ - (BOOL)smoothClipAnimateTiming:(smoothclip::Presentation)presentation
27
32
  animation:(smoothclip::TimingAnimation)animation
28
33
  animationId:(int32_t)animationId;
29
- - (void)smoothClipAnimateSpring:(smoothclip::Presentation)presentation
34
+ - (BOOL)smoothClipAnimateSpring:(smoothclip::Presentation)presentation
30
35
  animation:(smoothclip::SpringAnimation)animation
31
36
  animationId:(int32_t)animationId;
32
- - (void)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
37
+ - (BOOL)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
33
38
  keyframes:(const std::vector<smoothclip::Keyframe> &)keyframes
34
39
  durationMs:(double)durationMs
35
40
  animationId:(int32_t)animationId;
@@ -37,6 +42,7 @@
37
42
  - (BOOL)smoothClipIsJoinable;
38
43
  - (BOOL)smoothClipCanDisplay;
39
44
  - (BOOL)smoothClipHasPendingInstall;
45
+ - (double)smoothClipSpringContinuationVelocity;
40
46
  @end
41
47
 
42
48
  namespace smoothclip {
@@ -60,9 +66,13 @@ struct ActiveAnimation {
60
66
  std::vector<Keyframe> keyframes;
61
67
  double durationMs = 0;
62
68
  CFTimeInterval startedAt = 0;
63
- // False while the animation is latched: built before any host view
64
- // registered, held un-rendered until the first registerView rebases
65
- // startedAt and starts it. startedAt is only meaningful once started.
69
+ // False while the animation is latched. Two ways in: built before any host
70
+ // view could display (pre-registration, or a host in a detached subtree), or
71
+ // re-latched by unregisterView when the last DISPLAYABLE host left
72
+ // mid-flight. Started by whichever of registerView, viewBecameDisplayable
73
+ // (window attach or first layout) or joinActiveAnimation first finds a host
74
+ // that can produce a frame; each rebases startedAt through
75
+ // startLatchedAnimation. startedAt is only meaningful once started.
66
76
  bool started = false;
67
77
  };
68
78
 
@@ -72,7 +82,6 @@ struct DriverState {
72
82
  Ownership ownership = Ownership::Interactive;
73
83
  std::vector<ViewKey> views;
74
84
  std::optional<ActiveAnimation> animation;
75
- int32_t nextAnimationId = 0;
76
85
  // Set by destroyDriver while views are still registered (StrictMode effect
77
86
  // replay, hosts mounted in another subtree). The entry is erased when the
78
87
  // last view leaves and revived by a take-ownership setPresentation.
@@ -167,14 +176,6 @@ void emitStandaloneCompletion(
167
176
  #endif
168
177
  }
169
178
 
170
- int32_t allocateAnimationId(DriverState &state) {
171
- state.nextAnimationId =
172
- state.nextAnimationId == std::numeric_limits<int32_t>::max()
173
- ? 1
174
- : state.nextAnimationId + 1;
175
- return state.nextAnimationId;
176
- }
177
-
178
179
  Presentation canonicalFrozenPresentation(DriverState &state) {
179
180
  // A latched animation never rendered, so state.latest already holds its
180
181
  // target; freezing there would jump the clip. Freeze at the start instead.
@@ -203,12 +204,33 @@ void cancelActive(
203
204
  bool useTarget) {
204
205
  if (!state.animation.has_value()) return;
205
206
  const int32_t animationId = state.animation->animationId;
206
- for (const ViewKey key : state.animation->participants) {
207
+ // Deferred views are deliberately not completion participants, but they do
208
+ // hold a pending install and requested target. Cancellation must still reach
209
+ // every registered view so a later attach cannot resurrect the old id.
210
+ for (const ViewKey key : state.views) {
207
211
  [viewForKey(key) smoothClipCancelAnimationUsingTarget:useTarget];
208
212
  }
209
213
  emitCompletion(driverId, state, animationId, false);
210
214
  }
211
215
 
216
+ void finishIfNoInstalledParticipants(uint64_t driverId, DriverState &state) {
217
+ if (!state.animation.has_value() || !state.animation->started ||
218
+ !state.animation->participants.empty()) {
219
+ return;
220
+ }
221
+ const int32_t animationId = state.animation->animationId;
222
+ const bool finished = state.animation->finished;
223
+ // A detached/unlaid-out view can have a requested animation without any CA
224
+ // delegate capable of completing it. Once every installed animation ends,
225
+ // make those deferred views statically correct and invalidate their pending
226
+ // ids before releasing the driver-level completion.
227
+ for (const ViewKey key : state.views) {
228
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
229
+ }
230
+ state.ownership = Ownership::Interactive;
231
+ emitCompletion(driverId, state, animationId, finished);
232
+ }
233
+
212
234
  void applyPresentation(DriverState &state, Presentation presentation) {
213
235
  state.latest = presentation;
214
236
  state.hasLatest = true;
@@ -258,21 +280,33 @@ void dispatchActiveAnimationJoin(
258
280
  DriverState &state,
259
281
  SmoothClipView *view,
260
282
  ViewKey key,
261
- const Presentation &visible) {
283
+ const Presentation &visible,
284
+ SmoothClipView *reference = nil) {
262
285
  auto &active = *state.animation;
263
- active.participants.insert(key);
264
286
  const double elapsedMs = (CACurrentMediaTime() - active.startedAt) * 1000.0;
265
287
  const double remainingMs = MAX(0, active.durationMs - elapsedMs);
288
+ BOOL installed = NO;
266
289
  if (active.kind == AnimationKind::Timing) {
267
- TimingAnimation timing = active.timing;
268
- timing.durationMs = remainingMs;
269
- [view smoothClipAnimateTiming:state.latest
270
- animation:timing
271
- animationId:active.animationId];
290
+ const double progress = active.durationMs <= 0
291
+ ? 1
292
+ : MIN(1, MAX(0, elapsedMs / active.durationMs));
293
+ TimingAnimation timing = timingRemainder(active.timing, progress).animation;
294
+ installed = [view smoothClipAnimateTiming:state.latest
295
+ animation:timing
296
+ animationId:active.animationId];
272
297
  } else if (active.kind == AnimationKind::Spring) {
273
- [view smoothClipAnimateSpring:state.latest
274
- animation:active.spring
275
- animationId:active.animationId];
298
+ SpringAnimation spring = active.spring;
299
+ if (reference != nil) {
300
+ // A late host must join the spring's CURRENT physical state. Reusing the
301
+ // original launch velocity (or resolving `inherit` from the joining
302
+ // host's unrelated samples) creates a different spring at the seam.
303
+ spring.inheritVelocity = false;
304
+ spring.initialVelocity =
305
+ [reference smoothClipSpringContinuationVelocity];
306
+ }
307
+ installed = [view smoothClipAnimateSpring:state.latest
308
+ animation:spring
309
+ animationId:active.animationId];
276
310
  } else {
277
311
  const double progress = active.durationMs <= 0
278
312
  ? 1
@@ -285,11 +319,12 @@ void dispatchActiveAnimationJoin(
285
319
  remaining.push_back({offset, frame.presentation});
286
320
  }
287
321
  if (remaining.size() == 1) remaining.push_back({1, state.latest});
288
- [view smoothClipAnimateKeyframes:state.latest
289
- keyframes:remaining
290
- durationMs:remainingMs
291
- animationId:active.animationId];
322
+ installed = [view smoothClipAnimateKeyframes:state.latest
323
+ keyframes:remaining
324
+ durationMs:remainingMs
325
+ animationId:active.animationId];
292
326
  }
327
+ if (installed) active.participants.insert(key);
293
328
  }
294
329
 
295
330
  // An animation may only start once a registered view can actually produce a
@@ -311,6 +346,9 @@ bool anyDisplayableView(const DriverState &state) {
311
346
  void startLatchedAnimation(DriverState &state) {
312
347
  state.animation->startedAt = CACurrentMediaTime();
313
348
  state.animation->started = true;
349
+ // Participants describe live CA delegates, not registered views. Anything
350
+ // left from the frozen run can only produce a stale callback for that run.
351
+ state.animation->participants.clear();
314
352
  const Presentation start = state.animation->start;
315
353
  for (const ViewKey key : state.views) {
316
354
  SmoothClipView *participant = viewForKey(key);
@@ -362,12 +400,12 @@ void registerView(
362
400
  const bool shouldReplay =
363
401
  hasAnimation && state.animation->started && !state.views.empty();
364
402
  Presentation visible = state.latest;
403
+ SmoothClipView *reference = nil;
365
404
  if (shouldReplay) {
366
405
  // A registered peer that never laid out reports zero geometry; joining
367
406
  // from it would visibly collapse the clip. Prefer the first laid-out
368
407
  // peer and fall back to the animation's start.
369
- SmoothClipView *reference = nil;
370
- for (const ViewKey candidate : state.views) {
408
+ for (const ViewKey candidate : state.animation->participants) {
371
409
  SmoothClipView *candidateView = viewForKey(candidate);
372
410
  if ([candidateView smoothClipIsJoinable]) {
373
411
  reference = candidateView;
@@ -389,14 +427,13 @@ void registerView(
389
427
  }
390
428
  [view smoothClipApplyPresentation:visible];
391
429
  if (startsLatch) {
392
- // Rebases the join clock and dispatches to every registered view (the
393
- // participant inserts inside the joins also keep unregisterView
394
- // orphan-safe for a mount-then-unmount before first layout).
430
+ // Rebase the join clock and dispatch to every registered view. Only views
431
+ // that actually install their CA groups become completion participants.
395
432
  startLatchedAnimation(state);
396
433
  return;
397
434
  }
398
435
  if (!shouldReplay) return;
399
- dispatchActiveAnimationJoin(state, view, key, visible);
436
+ dispatchActiveAnimationJoin(state, view, key, visible, reference);
400
437
  }
401
438
 
402
439
  void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
@@ -433,33 +470,41 @@ bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
433
470
  if (!state.animation.has_value() || state.views.empty()) {
434
471
  return false;
435
472
  }
436
- // Unreachable in the normal flow (a pending install implies the animation
437
- // was dispatched, which implies started), but a join must never compute
438
- // elapsed time against an un-rebased latch clock.
439
- if (!state.animation->started) {
440
- return false;
441
- }
442
473
  const ViewKey key = keyForView(view);
443
474
  if (std::find(state.views.begin(), state.views.end(), key) ==
444
475
  state.views.end()) {
445
476
  return false;
446
477
  }
478
+ // A pending install used to imply the animation was dispatched, which
479
+ // implied started. Re-latching on displayability broke that: when the last
480
+ // displayable host leaves, a peer that deferred its install keeps
481
+ // `_pendingAnimationInstall` while the animation goes back to un-started, and
482
+ // its first layout lands here. Start the latch — returning false would make
483
+ // the caller clear `_activeAnimationId` and apply the requested geometry,
484
+ // snapping this view straight to the target. A *join* must still never
485
+ // compute elapsed time against an un-rebased latch clock, so this returns
486
+ // instead of falling through.
487
+ if (!state.animation->started) {
488
+ if (![view smoothClipCanDisplay]) return false;
489
+ startLatchedAnimation(state);
490
+ return true;
491
+ }
447
492
  // Join from a laid-out peer's live presentation, exactly as a view that had
448
493
  // registered at this moment would. When the joining view is the only
449
494
  // registered host, its own layer never received geometry (the install was
450
495
  // deferred for lack of layout), so fall back to the animation's start.
451
- ViewKey canonicalKey = 0;
452
- for (const ViewKey candidate : state.views) {
496
+ SmoothClipView *reference = nil;
497
+ for (const ViewKey candidate : state.animation->participants) {
453
498
  if (candidate != key && [viewForKey(candidate) smoothClipIsJoinable]) {
454
- canonicalKey = candidate;
499
+ reference = viewForKey(candidate);
455
500
  break;
456
501
  }
457
502
  }
458
- const Presentation visible = canonicalKey != 0
459
- ? [viewForKey(canonicalKey) smoothClipCurrentPresentation]
503
+ const Presentation visible = reference != nil
504
+ ? [reference smoothClipCurrentPresentation]
460
505
  : state.animation->start;
461
506
  [view smoothClipApplyPresentation:visible];
462
- dispatchActiveAnimationJoin(state, view, key, visible);
507
+ dispatchActiveAnimationJoin(state, view, key, visible, reference);
463
508
  return true;
464
509
  }
465
510
 
@@ -470,11 +515,49 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
470
515
 
471
516
  auto &state = iterator->second;
472
517
  const ViewKey key = keyForView(view);
518
+ state.views.erase(
519
+ std::remove(state.views.begin(), state.views.end(), key),
520
+ state.views.end());
473
521
  if (state.animation.has_value() &&
474
522
  state.animation->participants.erase(key) > 0) {
475
523
  state.animation->finished = false;
476
- if (state.animation->participants.empty()) {
524
+ if (!state.animation->started) {
525
+ // A held latch remains pending for the next displayable registration.
526
+ // Removing this participant also makes any late callback a no-op.
527
+ } else if (!anyDisplayableView(state)) {
477
528
  auto &active = *state.animation;
529
+ // Freeze before removing/reusing the layer. stopLayerAnimation invalidates
530
+ // its delegate, and erasing the participant above independently rejects
531
+ // any callback already queued by CoreAnimation.
532
+ //
533
+ // The freeze is a teardown side effect that must reach the departing
534
+ // view either way, but only a laid-out view may *define* the frozen
535
+ // geometry — smoothClipCurrentPresentation reports {0,0,0,0} for one
536
+ // that never laid out, and re-latching there would resume the animation
537
+ // out of a collapsed clip. Same filter canonicalFrozenPresentation uses:
538
+ // this view if joinable, else a laid-out survivor, else the start.
539
+ double springContinuationVelocity = 0;
540
+ if (active.kind == AnimationKind::Spring &&
541
+ [view smoothClipIsJoinable]) {
542
+ springContinuationVelocity =
543
+ [view smoothClipSpringContinuationVelocity];
544
+ }
545
+ const Presentation frozen = [view smoothClipFreezePresentation];
546
+ Presentation visible = active.start;
547
+ if ([view smoothClipIsJoinable]) {
548
+ visible = frozen;
549
+ } else {
550
+ for (const ViewKey candidate : state.views) {
551
+ SmoothClipView *candidateView = viewForKey(candidate);
552
+ if (![candidateView smoothClipIsJoinable]) continue;
553
+ visible = [candidateView smoothClipCurrentPresentation];
554
+ if (active.kind == AnimationKind::Spring) {
555
+ springContinuationVelocity =
556
+ [candidateView smoothClipSpringContinuationVelocity];
557
+ }
558
+ break;
559
+ }
560
+ }
478
561
  const double elapsedMs =
479
562
  (CACurrentMediaTime() - active.startedAt) * 1000.0;
480
563
  const double remainingMs = MAX(0, active.durationMs - elapsedMs);
@@ -486,20 +569,21 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
486
569
  state.ownership = Ownership::Interactive;
487
570
  emitCompletion(driverId, state, active.animationId, false);
488
571
  } else {
489
- // The last rendering host left mid-flight (remount, screen swap).
572
+ // The last displayable host left mid-flight. A registered detached or
573
+ // unlaid-out peer must not let the animation finish invisibly.
490
574
  // Completing here would leave `latest` at the target and statically
491
575
  // snap any re-registering host straight to it. Re-latch instead:
492
- // freeze the remaining animation at the departing view's visible
493
- // geometry so the next displayable host resumes it with the true
576
+ // freeze the remaining animation at the visible geometry resolved
577
+ // above so the next displayable host resumes it with the true
494
578
  // remaining time. Ownership stays Native — the pending animation
495
579
  // still owns rendering intent; destroyDriver cancels a latch that
496
580
  // never finds a host, so the completion cannot hang.
497
- const Presentation visible = [view smoothClipIsJoinable]
498
- ? [view smoothClipCurrentPresentation]
499
- : active.start;
500
581
  if (active.kind == AnimationKind::Timing) {
501
- active.durationMs = remainingMs;
502
- active.timing.durationMs = remainingMs;
582
+ const double progress = active.durationMs <= 0
583
+ ? 1
584
+ : MIN(1, MAX(0, elapsedMs / active.durationMs));
585
+ active.timing = timingRemainder(active.timing, progress).animation;
586
+ active.durationMs = active.timing.durationMs;
503
587
  } else if (active.kind == AnimationKind::Keyframes) {
504
588
  const double progress = active.durationMs <= 0
505
589
  ? 1
@@ -514,15 +598,37 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
514
598
  if (remaining.size() == 1) remaining.push_back({1, state.latest});
515
599
  active.keyframes = std::move(remaining);
516
600
  active.durationMs = remainingMs;
601
+ } else if (active.kind == AnimationKind::Spring) {
602
+ // The departing view resolved `inherit` before installing its CA
603
+ // spring. Re-express that spring's analytic velocity against the
604
+ // frozen remaining distance, so the resumed spring has the same
605
+ // physical derivative instead of restarting at rest.
606
+ active.spring.inheritVelocity = false;
607
+ active.spring.initialVelocity = springContinuationVelocity;
517
608
  }
518
609
  active.start = visible;
519
610
  active.started = false;
520
611
  }
612
+ } else if (state.animation->participants.empty()) {
613
+ // Every participant has either reported its stop or unregistered, yet a
614
+ // registered host can still display. viewAnimationDidStop is the only
615
+ // other exhaustion path and it can never run again for this id, so
616
+ // nothing is left to complete the animation: end it here.
617
+ //
618
+ // Reachable only with several hosts on one driver, where per-view spring
619
+ // settling durations let one host finish while another is still running
620
+ // (smoothClipAnimateSpring resolves 'inherit' per view, and
621
+ // springSettlingDurationWithVelocity derives the group duration from it).
622
+ //
623
+ // This branch MUST stay below the displayability test. A sole host that
624
+ // unregisters mid-flight also empties `participants`, and that case must
625
+ // RE-LATCH rather than complete — the next displayable host resuming the
626
+ // remainder is the entire point of the latch. Testing displayability
627
+ // first keeps the one-view-per-driver path on the branch above, which
628
+ // makes this one unreachable for a single host by construction.
629
+ finishIfNoInstalledParticipants(driverId, state);
521
630
  }
522
631
  }
523
- state.views.erase(
524
- std::remove(state.views.begin(), state.views.end(), key),
525
- state.views.end());
526
632
  if (state.destroyed && state.views.empty()) {
527
633
  registry().erase(iterator);
528
634
  }
@@ -531,7 +637,8 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
531
637
  void setPresentation(
532
638
  uint64_t driverId,
533
639
  Presentation presentation,
534
- bool takeOwnership) {
640
+ bool takeOwnership,
641
+ bool overridePendingAnimation) {
535
642
  // CALayer writes require the main thread (not main-queue drain context).
536
643
  if (!NSThread.isMainThread) {
537
644
  // Never block an off-main caller: a synchronous hop can deadlock against
@@ -539,7 +646,8 @@ void setPresentation(
539
646
  // thread while holding it, and the main thread routinely waits for the
540
647
  // same mutex when draining scheduled worklets).
541
648
  RCTExecuteOnMainQueue(^{
542
- setPresentation(driverId, presentation, takeOwnership);
649
+ setPresentation(
650
+ driverId, presentation, takeOwnership, overridePendingAnimation);
543
651
  });
544
652
  return;
545
653
  }
@@ -574,13 +682,10 @@ void setPresentation(
574
682
  }
575
683
  if (takeOwnership) {
576
684
  state.destroyed = false;
577
- // A held latch is strictly newer intent than this write: the latch was
578
- // created after whatever value the caller read (the hook's seed replays
579
- // a SharedValue that an earlier animateTo already advanced to its
580
- // target). Cancelling it here would seed the target and turn the pending
581
- // animation into a static jump. Callers that want to override a latch
582
- // cancel it explicitly first.
583
- if (state.animation.has_value() && !state.animation->started) {
685
+ // Passive seeds and public setters must not displace newer pending intent.
686
+ // The fused animation.from write is authoritative and opts in explicitly.
687
+ if (state.animation.has_value() && !state.animation->started &&
688
+ !overridePendingAnimation) {
584
689
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
585
690
  if (signpostsEnabled) {
586
691
  os_signpost_interval_end(
@@ -633,13 +738,17 @@ int32_t animateTiming(
633
738
  // See setPresentation: blocking off-main risks a deadlock. 0 is the
634
739
  // documented rejection sentinel.
635
740
  return 0;
636
- } auto iterator = registry().find(driverId);
741
+ }
742
+ auto iterator = registry().find(driverId);
637
743
  if (iterator == registry().end()) {
638
- return 0;
744
+ if (!start.hasInteractiveStart) return 0;
745
+ iterator = registry().try_emplace(driverId).first;
746
+ iterator->second.latest = start.interactiveStart;
747
+ iterator->second.hasLatest = true;
639
748
  }
640
749
  auto &state = iterator->second;
641
750
  state.destroyed = false;
642
- const int32_t animationId = allocateAnimationId(state);
751
+ const int32_t animationId = allocateAnimationId();
643
752
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
644
753
  prepareAnimation(driverId, state, start, presentation);
645
754
 
@@ -655,7 +764,6 @@ int32_t animateTiming(
655
764
  active.timing = animation;
656
765
  active.durationMs = animation.durationMs;
657
766
  active.startedAt = CACurrentMediaTime();
658
- active.participants.insert(state.views.begin(), state.views.end());
659
767
  active.started = anyDisplayableView(state);
660
768
  state.animation = std::move(active);
661
769
  if (!state.animation->started) {
@@ -675,9 +783,11 @@ int32_t animateTiming(
675
783
  static_cast<unsigned long>(state.views.size()));
676
784
  #endif
677
785
  for (const ViewKey key : state.views) {
678
- [viewForKey(key) smoothClipAnimateTiming:presentation
679
- animation:animation
680
- animationId:animationId];
786
+ if ([viewForKey(key) smoothClipAnimateTiming:presentation
787
+ animation:animation
788
+ animationId:animationId]) {
789
+ state.animation->participants.insert(key);
790
+ }
681
791
  }
682
792
  return animationId;
683
793
  }
@@ -690,11 +800,17 @@ int32_t animateSpring(
690
800
  if (!NSThread.isMainThread) {
691
801
  // See setPresentation: blocking off-main risks a deadlock.
692
802
  return 0;
693
- } auto iterator = registry().find(driverId);
694
- if (iterator == registry().end()) return 0;
803
+ }
804
+ auto iterator = registry().find(driverId);
805
+ if (iterator == registry().end()) {
806
+ if (!start.hasInteractiveStart) return 0;
807
+ iterator = registry().try_emplace(driverId).first;
808
+ iterator->second.latest = start.interactiveStart;
809
+ iterator->second.hasLatest = true;
810
+ }
695
811
  auto &state = iterator->second;
696
812
  state.destroyed = false;
697
- const int32_t animationId = allocateAnimationId(state);
813
+ const int32_t animationId = allocateAnimationId();
698
814
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
699
815
  prepareAnimation(driverId, state, start, presentation);
700
816
 
@@ -709,7 +825,6 @@ int32_t animateSpring(
709
825
  active.start = resolvedStart;
710
826
  active.spring = animation;
711
827
  active.startedAt = CACurrentMediaTime();
712
- active.participants.insert(state.views.begin(), state.views.end());
713
828
  active.started = anyDisplayableView(state);
714
829
  state.animation = std::move(active);
715
830
  if (!state.animation->started) {
@@ -724,9 +839,11 @@ int32_t animateSpring(
724
839
  static_cast<unsigned long>(state.views.size()));
725
840
  #endif
726
841
  for (const ViewKey key : state.views) {
727
- [viewForKey(key) smoothClipAnimateSpring:presentation
728
- animation:animation
729
- animationId:animationId];
842
+ if ([viewForKey(key) smoothClipAnimateSpring:presentation
843
+ animation:animation
844
+ animationId:animationId]) {
845
+ state.animation->participants.insert(key);
846
+ }
730
847
  }
731
848
  return animationId;
732
849
  }
@@ -741,11 +858,17 @@ int32_t animateKeyframes(
741
858
  if (!NSThread.isMainThread) {
742
859
  // See setPresentation: blocking off-main risks a deadlock.
743
860
  return 0;
744
- } auto iterator = registry().find(driverId);
745
- if (iterator == registry().end()) return 0;
861
+ }
862
+ auto iterator = registry().find(driverId);
863
+ if (iterator == registry().end()) {
864
+ if (!start.hasInteractiveStart) return 0;
865
+ iterator = registry().try_emplace(driverId).first;
866
+ iterator->second.latest = start.interactiveStart;
867
+ iterator->second.hasLatest = true;
868
+ }
746
869
  auto &state = iterator->second;
747
870
  state.destroyed = false;
748
- const int32_t animationId = allocateAnimationId(state);
871
+ const int32_t animationId = allocateAnimationId();
749
872
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
750
873
  prepareAnimation(driverId, state, start, presentation);
751
874
  if (shouldReduceMotion(reduceMotion) || durationMs <= 0) {
@@ -760,7 +883,6 @@ int32_t animateKeyframes(
760
883
  active.keyframes = keyframes;
761
884
  active.durationMs = durationMs;
762
885
  active.startedAt = CACurrentMediaTime();
763
- active.participants.insert(state.views.begin(), state.views.end());
764
886
  active.started = anyDisplayableView(state);
765
887
  state.animation = std::move(active);
766
888
  if (!state.animation->started) {
@@ -768,10 +890,12 @@ int32_t animateKeyframes(
768
890
  return animationId;
769
891
  }
770
892
  for (const ViewKey key : state.views) {
771
- [viewForKey(key) smoothClipAnimateKeyframes:presentation
772
- keyframes:keyframes
773
- durationMs:durationMs
774
- animationId:animationId];
893
+ if ([viewForKey(key) smoothClipAnimateKeyframes:presentation
894
+ keyframes:keyframes
895
+ durationMs:durationMs
896
+ animationId:animationId]) {
897
+ state.animation->participants.insert(key);
898
+ }
775
899
  }
776
900
  return animationId;
777
901
  }
@@ -782,7 +906,7 @@ int32_t rejectAnimation(uint64_t driverId) {
782
906
  return 0;
783
907
  } auto iterator = registry().find(driverId);
784
908
  if (iterator == registry().end()) return 0;
785
- const int32_t animationId = allocateAnimationId(iterator->second);
909
+ const int32_t animationId = allocateAnimationId();
786
910
  emitStandaloneCompletion(driverId, animationId, false);
787
911
  return animationId;
788
912
  }
@@ -812,7 +936,30 @@ CancelResult cancelAnimation(
812
936
  #endif
813
937
  if (useTarget) {
814
938
  const Presentation target = state.latest;
939
+ // A latch never dispatched to its views, so every participant still has
940
+ // _activeAnimationId == 0 and smoothClipCancelAnimationUsingTarget
941
+ // early-returns without writing anything: state.latest would report the
942
+ // target while every layer still showed the pre-animation geometry, and
943
+ // nothing else re-applies it. Android's cancelAnimation fans the result out
944
+ // unconditionally — match that. Read `started` before cancelActive, which
945
+ // resets state.animation.
946
+ //
947
+ // Gated on the latch case so a started animation is not written twice
948
+ // (smoothClipCancelAnimationUsingTarget already applied the target there),
949
+ // and applied WITHOUT a velocity sample: a jump to the target is not
950
+ // interactive motion, and the cancel-to-target path deliberately keeps it
951
+ // out of the 'inherit' history on both platforms — Android's cancel
952
+ // fan-out records nothing either.
953
+ const bool wasLatched = !state.animation->started;
815
954
  cancelActive(driverId, state, true);
955
+ if (wasLatched) {
956
+ // state.latest already holds the target (set at animate time); only the
957
+ // view fan-out is missing.
958
+ for (const ViewKey key : state.views) {
959
+ [viewForKey(key) smoothClipApplyPresentation:target
960
+ recordVelocitySample:NO];
961
+ }
962
+ }
816
963
  state.ownership = Ownership::Interactive;
817
964
  return {true, target};
818
965
  }
@@ -853,15 +1000,17 @@ void viewAnimationDidStop(
853
1000
  auto &state = iterator->second;
854
1001
  if (!state.animation.has_value() ||
855
1002
  state.animation->animationId != animationId) return;
1003
+ // A delegate from the frozen run can still report a stop for this id after
1004
+ // the animation has been re-latched. Draining participants there could
1005
+ // complete an animation that is waiting to resume. Only a running animation
1006
+ // may be completed by its installed delegates; a latch is completed by
1007
+ // replacement, cancel or destroy.
1008
+ if (!state.animation->started) return;
856
1009
 
857
1010
  const ViewKey key = keyForView(view);
858
1011
  if (state.animation->participants.erase(key) == 0) return;
859
1012
  state.animation->finished = state.animation->finished && finished;
860
- if (state.animation->participants.empty()) {
861
- state.ownership = Ownership::Interactive;
862
- emitCompletion(
863
- driverId, state, animationId, state.animation->finished);
864
- }
1013
+ finishIfNoInstalledParticipants(driverId, state);
865
1014
  }
866
1015
 
867
1016
  size_t registeredViewCount(uint64_t driverId) {
@@ -75,12 +75,14 @@ void SmoothClipTurboModule::setClipPresentation(
75
75
  double radius,
76
76
  double contentTranslateX,
77
77
  double contentTranslateY,
78
- bool takeOwnership) {
78
+ bool takeOwnership,
79
+ bool overridePendingAnimation) {
79
80
  const smoothclip::Presentation presentation{
80
81
  {x, y, width, height, radius}, contentTranslateX, contentTranslateY};
81
82
  if (validDriverId(driverId) && finitePresentation(presentation)) {
82
83
  smoothclip::setPresentation(
83
- static_cast<uint64_t>(driverId), presentation, takeOwnership);
84
+ static_cast<uint64_t>(driverId), presentation, takeOwnership,
85
+ overridePendingAnimation);
84
86
  }
85
87
  }
86
88
 
@@ -49,7 +49,8 @@ class SmoothClipTurboModule final
49
49
  double radius,
50
50
  double contentTranslateX,
51
51
  double contentTranslateY,
52
- bool takeOwnership);
52
+ bool takeOwnership,
53
+ bool overridePendingAnimation);
53
54
  jsi::Array beginInteraction(jsi::Runtime &runtime, double driverId);
54
55
  int32_t animateTiming(
55
56
  jsi::Runtime &runtime,