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
@@ -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,8 @@
37
42
  - (BOOL)smoothClipIsJoinable;
38
43
  - (BOOL)smoothClipCanDisplay;
39
44
  - (BOOL)smoothClipHasPendingInstall;
45
+ - (double)smoothClipSpringContinuationVelocity;
46
+ - (void)smoothClipClearVelocitySamples;
40
47
  @end
41
48
 
42
49
  namespace smoothclip {
@@ -50,6 +57,10 @@ enum class AnimationKind { Timing, Spring, Keyframes };
50
57
  struct ActiveAnimation {
51
58
  int32_t animationId;
52
59
  std::unordered_set<ViewKey> participants;
60
+ // Hosts that installed this id and temporarily lost displayability. They
61
+ // may rejoin the same remainder; completion is false if any remain
62
+ // unresolved when the run ends or unregister.
63
+ std::unordered_set<ViewKey> suspendedParticipants;
53
64
  bool finished = true;
54
65
  AnimationKind kind = AnimationKind::Timing;
55
66
  // Geometry the transition started from. Used when a view must join the
@@ -60,9 +71,13 @@ struct ActiveAnimation {
60
71
  std::vector<Keyframe> keyframes;
61
72
  double durationMs = 0;
62
73
  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.
74
+ // False while the animation is latched. Two ways in: built before any host
75
+ // view could display (pre-registration, or a host in a detached subtree), or
76
+ // re-latched by unregisterView when the last DISPLAYABLE host left
77
+ // mid-flight. Started by whichever registration/displayability update or
78
+ // joinActiveAnimation first finds a host that can produce a frame; each
79
+ // rebases startedAt through startLatchedAnimation. startedAt is only
80
+ // meaningful once started.
66
81
  bool started = false;
67
82
  };
68
83
 
@@ -72,7 +87,6 @@ struct DriverState {
72
87
  Ownership ownership = Ownership::Interactive;
73
88
  std::vector<ViewKey> views;
74
89
  std::optional<ActiveAnimation> animation;
75
- int32_t nextAnimationId = 0;
76
90
  // Set by destroyDriver while views are still registered (StrictMode effect
77
91
  // replay, hosts mounted in another subtree). The entry is erased when the
78
92
  // last view leaves and revived by a take-ownership setPresentation.
@@ -89,6 +103,41 @@ std::unordered_map<uint64_t, DriverState> &registry() {
89
103
  return value;
90
104
  }
91
105
 
106
+ // The transitions must be observed for the whole process lifetime, not per
107
+ // view: the state below is process-global, and a notification delivered while
108
+ // no SmoothClipView happens to be alive (the last host unmounted during
109
+ // inactivity, or the app backgrounded between screens) must still land.
110
+ // View-held observers die with their views and left the flag permanently
111
+ // stale in exactly those windows.
112
+ void installApplicationStateObservers() {
113
+ static dispatch_once_t once;
114
+ dispatch_once(&once, ^{
115
+ NSNotificationCenter *center = NSNotificationCenter.defaultCenter;
116
+ [center addObserverForName:UIApplicationWillResignActiveNotification
117
+ object:nil
118
+ queue:nil
119
+ usingBlock:^(NSNotification *) {
120
+ applicationWillResignActive();
121
+ }];
122
+ [center addObserverForName:UIApplicationDidBecomeActiveNotification
123
+ object:nil
124
+ queue:nil
125
+ usingBlock:^(NSNotification *) {
126
+ applicationDidBecomeActive();
127
+ }];
128
+ });
129
+ }
130
+
131
+ bool &applicationActiveState() {
132
+ installApplicationStateObservers();
133
+ // The first SmoothClipView can be created after the app already entered the
134
+ // background, too late to observe WillResignActive. Seed from UIKit rather
135
+ // than assuming foreground so that first registration cannot start offscreen.
136
+ static bool active = UIApplication.sharedApplication.applicationState ==
137
+ UIApplicationStateActive;
138
+ return active;
139
+ }
140
+
92
141
  CompletionSink &completionSink() {
93
142
  static CompletionSink sink;
94
143
  return sink;
@@ -167,15 +216,9 @@ void emitStandaloneCompletion(
167
216
  #endif
168
217
  }
169
218
 
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
- Presentation canonicalFrozenPresentation(DriverState &state) {
219
+ Presentation canonicalFrozenPresentation(
220
+ DriverState &state,
221
+ SmoothClipView *preferred = nil) {
179
222
  // A latched animation never rendered, so state.latest already holds its
180
223
  // target; freezing there would jump the clip. Freeze at the start instead.
181
224
  Presentation canonical =
@@ -183,17 +226,35 @@ Presentation canonicalFrozenPresentation(DriverState &state) {
183
226
  ? state.animation->start
184
227
  : state.latest;
185
228
  bool hasCanonical = false;
229
+ bool hasParticipantFallback = false;
230
+ Presentation participantFallback{{0, 0, 0, 0, 0}, 0, 0};
186
231
  for (const ViewKey key : state.views) {
187
232
  // Freeze every view (the teardown side effect must reach all of them),
188
233
  // but only a laid-out view may define the canonical geometry — an
189
234
  // unlaid-out one reports {0,0,0,0} and would collapse the clip.
190
235
  SmoothClipView *candidate = viewForKey(key);
191
236
  const Presentation frozen = [candidate smoothClipFreezePresentation];
192
- if (!hasCanonical && [candidate smoothClipIsJoinable]) {
237
+ if (candidate == preferred && [candidate smoothClipIsJoinable]) {
238
+ canonical = frozen;
239
+ hasCanonical = true;
240
+ } else if (!hasCanonical && [candidate smoothClipIsJoinable]) {
193
241
  canonical = frozen;
194
242
  hasCanonical = true;
243
+ } else if (!hasParticipantFallback && state.animation.has_value() &&
244
+ (state.animation->participants.count(key) > 0 ||
245
+ state.animation->suspendedParticipants.count(key) > 0)) {
246
+ // An installed participant whose HOST just lost its size still holds
247
+ // real mid-flight geometry on its clip layer (host bounds do not feed
248
+ // the clip container). Without this fallback, a sole running host
249
+ // resized to zero froze the re-latch at state.latest — the target —
250
+ // and the resumed animation snapped to its endpoint.
251
+ hasParticipantFallback = true;
252
+ participantFallback = frozen;
195
253
  }
196
254
  }
255
+ if (!hasCanonical && hasParticipantFallback) {
256
+ canonical = participantFallback;
257
+ }
197
258
  return canonical;
198
259
  }
199
260
 
@@ -203,18 +264,44 @@ void cancelActive(
203
264
  bool useTarget) {
204
265
  if (!state.animation.has_value()) return;
205
266
  const int32_t animationId = state.animation->animationId;
206
- for (const ViewKey key : state.animation->participants) {
267
+ // Deferred views are deliberately not completion participants, but they do
268
+ // hold a pending install and requested target. Cancellation must still reach
269
+ // every registered view so a later attach cannot resurrect the old id.
270
+ for (const ViewKey key : state.views) {
207
271
  [viewForKey(key) smoothClipCancelAnimationUsingTarget:useTarget];
208
272
  }
209
273
  emitCompletion(driverId, state, animationId, false);
210
274
  }
211
275
 
212
- void applyPresentation(DriverState &state, Presentation presentation) {
276
+ void finishIfNoInstalledParticipants(uint64_t driverId, DriverState &state) {
277
+ if (!state.animation.has_value() || !state.animation->started ||
278
+ !state.animation->participants.empty()) {
279
+ return;
280
+ }
281
+ const int32_t animationId = state.animation->animationId;
282
+ const bool finished = state.animation->finished &&
283
+ state.animation->suspendedParticipants.empty();
284
+ // A detached/unlaid-out view can have a requested animation without any CA
285
+ // delegate capable of completing it. Once every installed animation ends,
286
+ // make those deferred views statically correct and invalidate their pending
287
+ // ids before releasing the driver-level completion.
288
+ for (const ViewKey key : state.views) {
289
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
290
+ }
291
+ state.ownership = Ownership::Interactive;
292
+ emitCompletion(driverId, state, animationId, finished);
293
+ }
294
+
295
+ void applyPresentation(
296
+ DriverState &state,
297
+ Presentation presentation,
298
+ bool recordVelocitySample = true) {
213
299
  state.latest = presentation;
214
300
  state.hasLatest = true;
215
301
  state.ownership = Ownership::Interactive;
216
302
  for (const ViewKey key : state.views) {
217
- [viewForKey(key) smoothClipApplyPresentation:presentation];
303
+ [viewForKey(key) smoothClipApplyPresentation:presentation
304
+ recordVelocitySample:recordVelocitySample];
218
305
  }
219
306
  }
220
307
 
@@ -258,21 +345,33 @@ void dispatchActiveAnimationJoin(
258
345
  DriverState &state,
259
346
  SmoothClipView *view,
260
347
  ViewKey key,
261
- const Presentation &visible) {
348
+ const Presentation &visible,
349
+ SmoothClipView *reference = nil) {
262
350
  auto &active = *state.animation;
263
- active.participants.insert(key);
264
351
  const double elapsedMs = (CACurrentMediaTime() - active.startedAt) * 1000.0;
265
352
  const double remainingMs = MAX(0, active.durationMs - elapsedMs);
353
+ BOOL installed = NO;
266
354
  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];
355
+ const double progress = active.durationMs <= 0
356
+ ? 1
357
+ : MIN(1, MAX(0, elapsedMs / active.durationMs));
358
+ TimingAnimation timing = timingRemainder(active.timing, progress).animation;
359
+ installed = [view smoothClipAnimateTiming:state.latest
360
+ animation:timing
361
+ animationId:active.animationId];
272
362
  } else if (active.kind == AnimationKind::Spring) {
273
- [view smoothClipAnimateSpring:state.latest
274
- animation:active.spring
275
- animationId:active.animationId];
363
+ SpringAnimation spring = active.spring;
364
+ if (reference != nil) {
365
+ // A late host must join the spring's CURRENT physical state. Reusing the
366
+ // original launch velocity (or resolving `inherit` from the joining
367
+ // host's unrelated samples) creates a different spring at the seam.
368
+ spring.inheritVelocity = false;
369
+ spring.initialVelocity =
370
+ [reference smoothClipSpringContinuationVelocity];
371
+ }
372
+ installed = [view smoothClipAnimateSpring:state.latest
373
+ animation:spring
374
+ animationId:active.animationId];
276
375
  } else {
277
376
  const double progress = active.durationMs <= 0
278
377
  ? 1
@@ -285,10 +384,14 @@ void dispatchActiveAnimationJoin(
285
384
  remaining.push_back({offset, frame.presentation});
286
385
  }
287
386
  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];
387
+ installed = [view smoothClipAnimateKeyframes:state.latest
388
+ keyframes:remaining
389
+ durationMs:remainingMs
390
+ animationId:active.animationId];
391
+ }
392
+ if (installed) {
393
+ active.participants.insert(key);
394
+ active.suspendedParticipants.erase(key);
292
395
  }
293
396
  }
294
397
 
@@ -304,23 +407,125 @@ bool anyDisplayableView(const DriverState &state) {
304
407
  return false;
305
408
  }
306
409
 
410
+ void relatchActiveAnimation(
411
+ uint64_t driverId,
412
+ DriverState &state,
413
+ SmoothClipView *preferred = nil) {
414
+ if (!state.animation.has_value() || !state.animation->started) return;
415
+ auto &active = *state.animation;
416
+ double springVelocity = 0;
417
+ if (active.kind == AnimationKind::Spring) {
418
+ SmoothClipView *source = preferred;
419
+ if (source == nil ||
420
+ active.participants.count(keyForView(source)) == 0) {
421
+ source = active.participants.empty()
422
+ ? nil
423
+ : viewForKey(*active.participants.begin());
424
+ }
425
+ if (source != nil) {
426
+ springVelocity = [source smoothClipSpringContinuationVelocity];
427
+ }
428
+ }
429
+ const Presentation frozen = canonicalFrozenPresentation(state, preferred);
430
+ // Captured before the migration below: only hosts that were ALREADY
431
+ // suspended are unresolved. The active participants being frozen right now
432
+ // rendered every frame up to this instant.
433
+ const bool hadUnresolvedSuspended = !active.suspendedParticipants.empty();
434
+ active.suspendedParticipants.insert(
435
+ active.participants.begin(), active.participants.end());
436
+ active.participants.clear();
437
+
438
+ const double elapsedMs = MAX(
439
+ 0, (CACurrentMediaTime() - active.startedAt) * 1000.0);
440
+ const double progress = active.durationMs <= 0
441
+ ? 1
442
+ : MIN(1, MAX(0, elapsedMs / active.durationMs));
443
+ if (active.kind == AnimationKind::Timing) {
444
+ active.timing = timingRemainder(active.timing, progress).animation;
445
+ active.durationMs = active.timing.durationMs;
446
+ } else if (active.kind == AnimationKind::Keyframes) {
447
+ const KeyframeContinuation continuation = keyframeContinuation(
448
+ active.keyframes,
449
+ frozen,
450
+ state.latest,
451
+ active.durationMs,
452
+ progress);
453
+ active.keyframes = continuation.frames;
454
+ active.durationMs = continuation.durationMs;
455
+ } else {
456
+ active.spring.inheritVelocity = false;
457
+ active.spring.initialVelocity = springVelocity;
458
+ }
459
+
460
+ if (state.destroyed ||
461
+ (active.kind != AnimationKind::Spring && active.durationMs <= 0)) {
462
+ // A residual trimmed to zero means the curve fully elapsed while a host
463
+ // displayed every frame — only the asynchronous didStop was outstanding
464
+ // when this freeze arrived. Report that run honestly instead of stamping
465
+ // the freeze-path false; hosts that were suspended before the freeze are
466
+ // unresolved and still poison it.
467
+ const bool ranToEnd = !state.destroyed && progress >= 1 &&
468
+ active.finished && !hadUnresolvedSuspended;
469
+ // Deferred peers hold pending installs for this id; invalidate them and
470
+ // finalize statically at the target, exactly as the didStop completion
471
+ // path does. Frozen participants already have no animation to cancel.
472
+ for (const ViewKey key : state.views) {
473
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
474
+ }
475
+ state.ownership = Ownership::Interactive;
476
+ emitCompletion(driverId, state, active.animationId, ranToEnd);
477
+ return;
478
+ }
479
+ active.start = frozen;
480
+ active.started = false;
481
+ }
482
+
307
483
  // Starts a latched animation: rebases the clock so no progress was burned
308
484
  // while no view could display, then dispatches the join to every registered
309
485
  // view. Views that still cannot display defer their install per-view and
310
- // resume via viewBecameDisplayable.
486
+ // resume via a later positive displayability update.
311
487
  void startLatchedAnimation(DriverState &state) {
312
488
  state.animation->startedAt = CACurrentMediaTime();
313
489
  state.animation->started = true;
490
+ // Participants describe live CA delegates, not registered views. Anything
491
+ // left from the frozen run can only produce a stale callback for that run.
492
+ state.animation->participants.clear();
314
493
  const Presentation start = state.animation->start;
315
494
  for (const ViewKey key : state.views) {
316
495
  SmoothClipView *participant = viewForKey(key);
317
- [participant smoothClipApplyPresentation:start];
496
+ [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
318
497
  dispatchActiveAnimationJoin(state, participant, key, start);
319
498
  }
320
499
  }
321
500
 
322
501
  } // namespace
323
502
 
503
+ bool applicationIsActive() {
504
+ return applicationActiveState();
505
+ }
506
+
507
+ void applicationWillResignActive() {
508
+ NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
509
+ if (!applicationActiveState()) return;
510
+ applicationActiveState() = false;
511
+ for (auto &[driverId, state] : registry()) {
512
+ relatchActiveAnimation(driverId, state);
513
+ }
514
+ }
515
+
516
+ void applicationDidBecomeActive() {
517
+ NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
518
+ if (applicationActiveState()) return;
519
+ applicationActiveState() = true;
520
+ for (auto &[driverId, state] : registry()) {
521
+ (void)driverId;
522
+ if (state.animation.has_value() && !state.animation->started &&
523
+ anyDisplayableView(state)) {
524
+ startLatchedAnimation(state);
525
+ }
526
+ }
527
+ }
528
+
324
529
  void setCompletionCallback(
325
530
  const void *owner,
326
531
  CompletionCallback callback) {
@@ -356,18 +561,18 @@ void registerView(
356
561
  const bool hasLatch = hasAnimation && !state.animation->started;
357
562
  // A latch may only start once this view can produce a visible frame; a
358
563
  // registration from a detached subtree (transparentModal before its view
359
- // controller is presented) keeps the latch held and viewBecameDisplayable
360
- // starts it inside the attach commit.
564
+ // controller is presented) keeps the latch held; the positive
565
+ // displayability update starts it inside the attach commit.
361
566
  const bool startsLatch = hasLatch && [view smoothClipCanDisplay];
362
567
  const bool shouldReplay =
363
568
  hasAnimation && state.animation->started && !state.views.empty();
364
569
  Presentation visible = state.latest;
570
+ SmoothClipView *reference = nil;
365
571
  if (shouldReplay) {
366
572
  // A registered peer that never laid out reports zero geometry; joining
367
573
  // from it would visibly collapse the clip. Prefer the first laid-out
368
574
  // peer and fall back to the animation's start.
369
- SmoothClipView *reference = nil;
370
- for (const ViewKey candidate : state.views) {
575
+ for (const ViewKey candidate : state.animation->participants) {
371
576
  SmoothClipView *candidateView = viewForKey(candidate);
372
577
  if ([candidateView smoothClipIsJoinable]) {
373
578
  reference = candidateView;
@@ -387,19 +592,18 @@ void registerView(
387
592
  state.views.end()) {
388
593
  state.views.push_back(key);
389
594
  }
390
- [view smoothClipApplyPresentation:visible];
595
+ [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
391
596
  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).
597
+ // Rebase the join clock and dispatch to every registered view. Only views
598
+ // that actually install their CA groups become completion participants.
395
599
  startLatchedAnimation(state);
396
600
  return;
397
601
  }
398
602
  if (!shouldReplay) return;
399
- dispatchActiveAnimationJoin(state, view, key, visible);
603
+ dispatchActiveAnimationJoin(state, view, key, visible, reference);
400
604
  }
401
605
 
402
- void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
606
+ void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
403
607
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
404
608
  auto iterator = registry().find(driverId);
405
609
  if (iterator == registry().end()) return;
@@ -410,15 +614,25 @@ void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
410
614
  state.views.end()) {
411
615
  return;
412
616
  }
617
+ if (![view smoothClipCanDisplay]) {
618
+ if (!state.animation->started) return;
619
+ if (state.animation->participants.count(key) == 0) return;
620
+ if (!anyDisplayableView(state)) {
621
+ relatchActiveAnimation(driverId, state, view);
622
+ return;
623
+ }
624
+ [view smoothClipFreezePresentation];
625
+ state.animation->participants.erase(key);
626
+ state.animation->suspendedParticipants.insert(key);
627
+ return;
628
+ }
413
629
  if (!state.animation->started) {
414
- if (![view smoothClipCanDisplay]) return;
415
630
  startLatchedAnimation(state);
416
631
  return;
417
632
  }
418
- // A running animation whose install this view deferred while it could not
419
- // display: resume through the standard mid-flight join (original clock —
420
- // late joiners stay in sync with peers that were already rendering).
421
- if ([view smoothClipHasPendingInstall]) {
633
+ // A deferred or suspended host joins from the registry's rebased remainder.
634
+ if ([view smoothClipHasPendingInstall] ||
635
+ state.animation->suspendedParticipants.count(key) > 0) {
422
636
  joinActiveAnimation(driverId, view);
423
637
  }
424
638
  }
@@ -433,33 +647,41 @@ bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
433
647
  if (!state.animation.has_value() || state.views.empty()) {
434
648
  return false;
435
649
  }
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
650
  const ViewKey key = keyForView(view);
443
651
  if (std::find(state.views.begin(), state.views.end(), key) ==
444
652
  state.views.end()) {
445
653
  return false;
446
654
  }
655
+ // A pending install used to imply the animation was dispatched, which
656
+ // implied started. Re-latching on displayability broke that: when the last
657
+ // displayable host leaves, a peer that deferred its install keeps
658
+ // `_pendingAnimationInstall` while the animation goes back to un-started, and
659
+ // its first layout lands here. Start the latch — returning false would make
660
+ // the caller clear `_activeAnimationId` and apply the requested geometry,
661
+ // snapping this view straight to the target. A *join* must still never
662
+ // compute elapsed time against an un-rebased latch clock, so this returns
663
+ // instead of falling through.
664
+ if (!state.animation->started) {
665
+ if (![view smoothClipCanDisplay]) return false;
666
+ startLatchedAnimation(state);
667
+ return true;
668
+ }
447
669
  // Join from a laid-out peer's live presentation, exactly as a view that had
448
670
  // registered at this moment would. When the joining view is the only
449
671
  // registered host, its own layer never received geometry (the install was
450
672
  // deferred for lack of layout), so fall back to the animation's start.
451
- ViewKey canonicalKey = 0;
452
- for (const ViewKey candidate : state.views) {
673
+ SmoothClipView *reference = nil;
674
+ for (const ViewKey candidate : state.animation->participants) {
453
675
  if (candidate != key && [viewForKey(candidate) smoothClipIsJoinable]) {
454
- canonicalKey = candidate;
676
+ reference = viewForKey(candidate);
455
677
  break;
456
678
  }
457
679
  }
458
- const Presentation visible = canonicalKey != 0
459
- ? [viewForKey(canonicalKey) smoothClipCurrentPresentation]
680
+ const Presentation visible = reference != nil
681
+ ? [reference smoothClipCurrentPresentation]
460
682
  : state.animation->start;
461
- [view smoothClipApplyPresentation:visible];
462
- dispatchActiveAnimationJoin(state, view, key, visible);
683
+ [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
684
+ dispatchActiveAnimationJoin(state, view, key, visible, reference);
463
685
  return true;
464
686
  }
465
687
 
@@ -470,59 +692,36 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
470
692
 
471
693
  auto &state = iterator->second;
472
694
  const ViewKey key = keyForView(view);
473
- if (state.animation.has_value() &&
474
- state.animation->participants.erase(key) > 0) {
475
- state.animation->finished = false;
476
- if (state.animation->participants.empty()) {
477
- auto &active = *state.animation;
478
- const double elapsedMs =
479
- (CACurrentMediaTime() - active.startedAt) * 1000.0;
480
- const double remainingMs = MAX(0, active.durationMs - elapsedMs);
481
- const bool canResume = !state.destroyed &&
482
- (active.kind == AnimationKind::Spring || remainingMs > 0);
483
- if (!canResume) {
484
- // The animation ends here; without releasing ownership a host that
485
- // remounts on this driver later would drop every interactive update.
486
- state.ownership = Ownership::Interactive;
487
- emitCompletion(driverId, state, active.animationId, false);
488
- } else {
489
- // The last rendering host left mid-flight (remount, screen swap).
490
- // Completing here would leave `latest` at the target and statically
491
- // 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
494
- // remaining time. Ownership stays Native — the pending animation
495
- // still owns rendering intent; destroyDriver cancels a latch that
496
- // never finds a host, so the completion cannot hang.
497
- const Presentation visible = [view smoothClipIsJoinable]
498
- ? [view smoothClipCurrentPresentation]
499
- : active.start;
500
- if (active.kind == AnimationKind::Timing) {
501
- active.durationMs = remainingMs;
502
- active.timing.durationMs = remainingMs;
503
- } else if (active.kind == AnimationKind::Keyframes) {
504
- const double progress = active.durationMs <= 0
505
- ? 1
506
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
507
- std::vector<Keyframe> remaining;
508
- remaining.push_back({0, visible});
509
- for (const Keyframe &frame : active.keyframes) {
510
- if (frame.offset <= progress) continue;
511
- const double offset = (frame.offset - progress) / (1 - progress);
512
- remaining.push_back({offset, frame.presentation});
513
- }
514
- if (remaining.size() == 1) remaining.push_back({1, state.latest});
515
- active.keyframes = std::move(remaining);
516
- active.durationMs = remainingMs;
695
+ bool installed = false;
696
+ if (state.animation.has_value()) {
697
+ installed = state.animation->participants.count(key) > 0 ||
698
+ state.animation->suspendedParticipants.count(key) > 0;
699
+ if (state.animation->started &&
700
+ state.animation->participants.count(key) > 0) {
701
+ bool otherDisplayable = false;
702
+ for (const ViewKey candidate : state.views) {
703
+ if (candidate != key && [viewForKey(candidate) smoothClipCanDisplay]) {
704
+ otherDisplayable = true;
705
+ break;
517
706
  }
518
- active.start = visible;
519
- active.started = false;
707
+ }
708
+ if (!otherDisplayable) {
709
+ relatchActiveAnimation(driverId, state, view);
520
710
  }
521
711
  }
522
712
  }
713
+
523
714
  state.views.erase(
524
715
  std::remove(state.views.begin(), state.views.end(), key),
525
716
  state.views.end());
717
+ if (state.animation.has_value()) {
718
+ state.animation->participants.erase(key);
719
+ state.animation->suspendedParticipants.erase(key);
720
+ if (installed) state.animation->finished = false;
721
+ if (state.animation->started && state.animation->participants.empty()) {
722
+ finishIfNoInstalledParticipants(driverId, state);
723
+ }
724
+ }
526
725
  if (state.destroyed && state.views.empty()) {
527
726
  registry().erase(iterator);
528
727
  }
@@ -531,7 +730,8 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
531
730
  void setPresentation(
532
731
  uint64_t driverId,
533
732
  Presentation presentation,
534
- bool takeOwnership) {
733
+ bool takeOwnership,
734
+ bool overridePendingAnimation) {
535
735
  // CALayer writes require the main thread (not main-queue drain context).
536
736
  if (!NSThread.isMainThread) {
537
737
  // Never block an off-main caller: a synchronous hop can deadlock against
@@ -539,7 +739,8 @@ void setPresentation(
539
739
  // thread while holding it, and the main thread routinely waits for the
540
740
  // same mutex when draining scheduled worklets).
541
741
  RCTExecuteOnMainQueue(^{
542
- setPresentation(driverId, presentation, takeOwnership);
742
+ setPresentation(
743
+ driverId, presentation, takeOwnership, overridePendingAnimation);
543
744
  });
544
745
  return;
545
746
  }
@@ -574,13 +775,10 @@ void setPresentation(
574
775
  }
575
776
  if (takeOwnership) {
576
777
  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) {
778
+ // Passive seeds and public setters must not displace newer pending intent.
779
+ // The fused animation.from write is authoritative and opts in explicitly.
780
+ if (state.animation.has_value() && !state.animation->started &&
781
+ !overridePendingAnimation) {
584
782
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
585
783
  if (signpostsEnabled) {
586
784
  os_signpost_interval_end(
@@ -633,18 +831,22 @@ int32_t animateTiming(
633
831
  // See setPresentation: blocking off-main risks a deadlock. 0 is the
634
832
  // documented rejection sentinel.
635
833
  return 0;
636
- } auto iterator = registry().find(driverId);
834
+ }
835
+ auto iterator = registry().find(driverId);
637
836
  if (iterator == registry().end()) {
638
- return 0;
837
+ if (!start.hasInteractiveStart) return 0;
838
+ iterator = registry().try_emplace(driverId).first;
839
+ iterator->second.latest = start.interactiveStart;
840
+ iterator->second.hasLatest = true;
639
841
  }
640
842
  auto &state = iterator->second;
641
843
  state.destroyed = false;
642
- const int32_t animationId = allocateAnimationId(state);
844
+ const int32_t animationId = allocateAnimationId();
643
845
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
644
846
  prepareAnimation(driverId, state, start, presentation);
645
847
 
646
848
  if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
647
- applyPresentation(state, presentation);
849
+ applyPresentation(state, presentation, false);
648
850
  emitCompletion(driverId, state, animationId, true);
649
851
  return animationId;
650
852
  }
@@ -655,7 +857,6 @@ int32_t animateTiming(
655
857
  active.timing = animation;
656
858
  active.durationMs = animation.durationMs;
657
859
  active.startedAt = CACurrentMediaTime();
658
- active.participants.insert(state.views.begin(), state.views.end());
659
860
  active.started = anyDisplayableView(state);
660
861
  state.animation = std::move(active);
661
862
  if (!state.animation->started) {
@@ -675,9 +876,11 @@ int32_t animateTiming(
675
876
  static_cast<unsigned long>(state.views.size()));
676
877
  #endif
677
878
  for (const ViewKey key : state.views) {
678
- [viewForKey(key) smoothClipAnimateTiming:presentation
679
- animation:animation
680
- animationId:animationId];
879
+ if ([viewForKey(key) smoothClipAnimateTiming:presentation
880
+ animation:animation
881
+ animationId:animationId]) {
882
+ state.animation->participants.insert(key);
883
+ }
681
884
  }
682
885
  return animationId;
683
886
  }
@@ -690,16 +893,22 @@ int32_t animateSpring(
690
893
  if (!NSThread.isMainThread) {
691
894
  // See setPresentation: blocking off-main risks a deadlock.
692
895
  return 0;
693
- } auto iterator = registry().find(driverId);
694
- if (iterator == registry().end()) return 0;
896
+ }
897
+ auto iterator = registry().find(driverId);
898
+ if (iterator == registry().end()) {
899
+ if (!start.hasInteractiveStart) return 0;
900
+ iterator = registry().try_emplace(driverId).first;
901
+ iterator->second.latest = start.interactiveStart;
902
+ iterator->second.hasLatest = true;
903
+ }
695
904
  auto &state = iterator->second;
696
905
  state.destroyed = false;
697
- const int32_t animationId = allocateAnimationId(state);
906
+ const int32_t animationId = allocateAnimationId();
698
907
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
699
908
  prepareAnimation(driverId, state, start, presentation);
700
909
 
701
910
  if (shouldReduceMotion(animation.reduceMotion)) {
702
- applyPresentation(state, presentation);
911
+ applyPresentation(state, presentation, false);
703
912
  emitCompletion(driverId, state, animationId, true);
704
913
  return animationId;
705
914
  }
@@ -709,7 +918,6 @@ int32_t animateSpring(
709
918
  active.start = resolvedStart;
710
919
  active.spring = animation;
711
920
  active.startedAt = CACurrentMediaTime();
712
- active.participants.insert(state.views.begin(), state.views.end());
713
921
  active.started = anyDisplayableView(state);
714
922
  state.animation = std::move(active);
715
923
  if (!state.animation->started) {
@@ -724,9 +932,11 @@ int32_t animateSpring(
724
932
  static_cast<unsigned long>(state.views.size()));
725
933
  #endif
726
934
  for (const ViewKey key : state.views) {
727
- [viewForKey(key) smoothClipAnimateSpring:presentation
728
- animation:animation
729
- animationId:animationId];
935
+ if ([viewForKey(key) smoothClipAnimateSpring:presentation
936
+ animation:animation
937
+ animationId:animationId]) {
938
+ state.animation->participants.insert(key);
939
+ }
730
940
  }
731
941
  return animationId;
732
942
  }
@@ -741,15 +951,21 @@ int32_t animateKeyframes(
741
951
  if (!NSThread.isMainThread) {
742
952
  // See setPresentation: blocking off-main risks a deadlock.
743
953
  return 0;
744
- } auto iterator = registry().find(driverId);
745
- if (iterator == registry().end()) return 0;
954
+ }
955
+ auto iterator = registry().find(driverId);
956
+ if (iterator == registry().end()) {
957
+ if (!start.hasInteractiveStart) return 0;
958
+ iterator = registry().try_emplace(driverId).first;
959
+ iterator->second.latest = start.interactiveStart;
960
+ iterator->second.hasLatest = true;
961
+ }
746
962
  auto &state = iterator->second;
747
963
  state.destroyed = false;
748
- const int32_t animationId = allocateAnimationId(state);
964
+ const int32_t animationId = allocateAnimationId();
749
965
  const Presentation resolvedStart = resolvedAnimationStart(state, start);
750
966
  prepareAnimation(driverId, state, start, presentation);
751
967
  if (shouldReduceMotion(reduceMotion) || durationMs <= 0) {
752
- applyPresentation(state, presentation);
968
+ applyPresentation(state, presentation, false);
753
969
  emitCompletion(driverId, state, animationId, true);
754
970
  return animationId;
755
971
  }
@@ -760,7 +976,6 @@ int32_t animateKeyframes(
760
976
  active.keyframes = keyframes;
761
977
  active.durationMs = durationMs;
762
978
  active.startedAt = CACurrentMediaTime();
763
- active.participants.insert(state.views.begin(), state.views.end());
764
979
  active.started = anyDisplayableView(state);
765
980
  state.animation = std::move(active);
766
981
  if (!state.animation->started) {
@@ -768,10 +983,12 @@ int32_t animateKeyframes(
768
983
  return animationId;
769
984
  }
770
985
  for (const ViewKey key : state.views) {
771
- [viewForKey(key) smoothClipAnimateKeyframes:presentation
772
- keyframes:keyframes
773
- durationMs:durationMs
774
- animationId:animationId];
986
+ if ([viewForKey(key) smoothClipAnimateKeyframes:presentation
987
+ keyframes:keyframes
988
+ durationMs:durationMs
989
+ animationId:animationId]) {
990
+ state.animation->participants.insert(key);
991
+ }
775
992
  }
776
993
  return animationId;
777
994
  }
@@ -782,7 +999,7 @@ int32_t rejectAnimation(uint64_t driverId) {
782
999
  return 0;
783
1000
  } auto iterator = registry().find(driverId);
784
1001
  if (iterator == registry().end()) return 0;
785
- const int32_t animationId = allocateAnimationId(iterator->second);
1002
+ const int32_t animationId = allocateAnimationId();
786
1003
  emitStandaloneCompletion(driverId, animationId, false);
787
1004
  return animationId;
788
1005
  }
@@ -812,7 +1029,30 @@ CancelResult cancelAnimation(
812
1029
  #endif
813
1030
  if (useTarget) {
814
1031
  const Presentation target = state.latest;
1032
+ // A latch never dispatched to its views, so every participant still has
1033
+ // _activeAnimationId == 0 and smoothClipCancelAnimationUsingTarget
1034
+ // early-returns without writing anything: state.latest would report the
1035
+ // target while every layer still showed the pre-animation geometry, and
1036
+ // nothing else re-applies it. Android's cancelAnimation fans the result out
1037
+ // unconditionally — match that. Read `started` before cancelActive, which
1038
+ // resets state.animation.
1039
+ //
1040
+ // Gated on the latch case so a started animation is not written twice
1041
+ // (smoothClipCancelAnimationUsingTarget already applied the target there),
1042
+ // and applied WITHOUT a velocity sample: a jump to the target is not
1043
+ // interactive motion, and the cancel-to-target path deliberately keeps it
1044
+ // out of the 'inherit' history on both platforms — Android's cancel
1045
+ // fan-out records nothing either.
1046
+ const bool wasLatched = !state.animation->started;
815
1047
  cancelActive(driverId, state, true);
1048
+ if (wasLatched) {
1049
+ // state.latest already holds the target (set at animate time); only the
1050
+ // view fan-out is missing.
1051
+ for (const ViewKey key : state.views) {
1052
+ [viewForKey(key) smoothClipApplyPresentation:target
1053
+ recordVelocitySample:NO];
1054
+ }
1055
+ }
816
1056
  state.ownership = Ownership::Interactive;
817
1057
  return {true, target};
818
1058
  }
@@ -830,6 +1070,12 @@ void destroyDriver(uint64_t driverId) {
830
1070
  if (iterator == registry().end()) return;
831
1071
  auto &state = iterator->second;
832
1072
  cancelActive(driverId, state, false);
1073
+ // A destroyed driver's interaction history must not seed a revived
1074
+ // incarnation: the revival seed would otherwise pair with these samples and
1075
+ // refresh the staleness clock with motion no finger produced.
1076
+ for (const ViewKey key : state.views) {
1077
+ [viewForKey(key) smoothClipClearVelocitySamples];
1078
+ }
833
1079
  if (state.views.empty()) {
834
1080
  registry().erase(iterator);
835
1081
  } else {
@@ -853,15 +1099,17 @@ void viewAnimationDidStop(
853
1099
  auto &state = iterator->second;
854
1100
  if (!state.animation.has_value() ||
855
1101
  state.animation->animationId != animationId) return;
1102
+ // A delegate from the frozen run can still report a stop for this id after
1103
+ // the animation has been re-latched. Draining participants there could
1104
+ // complete an animation that is waiting to resume. Only a running animation
1105
+ // may be completed by its installed delegates; a latch is completed by
1106
+ // replacement, cancel or destroy.
1107
+ if (!state.animation->started) return;
856
1108
 
857
1109
  const ViewKey key = keyForView(view);
858
1110
  if (state.animation->participants.erase(key) == 0) return;
859
1111
  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
- }
1112
+ finishIfNoInstalledParticipants(driverId, state);
865
1113
  }
866
1114
 
867
1115
  size_t registeredViewCount(uint64_t driverId) {