react-native-smooth-clip-view 0.2.0 → 0.2.2

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.
package/README.md CHANGED
@@ -194,10 +194,23 @@ const gesture = Gesture.Pan()
194
194
  validated, monotonically increasing offsets from zero through one.
195
195
  - `cancel()` freezes visible presentation by default. Pass `'target'` as its
196
196
  behavior to jump to the requested endpoint.
197
- - `options.onAnimationComplete` fires once per animation with its ID and
198
- `finished` state, including cancellation, replacement, participant unmount,
199
- and native-side rejection (`animateTo` then returns `0` and one
200
- `finished: false` completion is delivered).
197
+ - `options.onAnimationComplete` fires exactly once per animation with its ID
198
+ and `finished` state, including cancellation, replacement, and native-side
199
+ rejection (`animateTo` then returns `0` and one `finished: false` completion
200
+ is delivered). Unmounting the last host mid-flight does not complete the
201
+ animation: the remainder is re-latched and the next displayable host resumes
202
+ it, so the completion arrives when it finishes there — or unfinished on
203
+ replacement, cancellation, `beginInteraction()`, or driver destruction.
204
+ - An `animateTo` issued before any host view can produce a visible frame (for
205
+ example from an effect in the same commit that mounts the host, or inside a
206
+ modal route whose subtree attaches to its window late) is held pending and
207
+ starts with its full duration at the first displayable registration or
208
+ window attach. A pending animation owns the driver: take-ownership writes
209
+ (`set`, `setScalars`, the hook's seed) are dropped while it is held —
210
+ replace it with another `animateTo`, or cancel it via `beginInteraction()`
211
+ or `cancel()`. If no view ever becomes displayable, it survives until it is
212
+ replaced, cancelled, or the driver is destroyed — at which point its single
213
+ `finished: false` completion is delivered.
201
214
 
202
215
  Do not call `driver.ui` from React code — it throws on the React runtime; use
203
216
  `driver.react` there. During a native transition, `driver.presentation.value`
@@ -22,6 +22,7 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
22
22
  const NormalizedClip &clip,
23
23
  double contentTranslateXPx,
24
24
  double contentTranslateYPx) const;
25
+ bool isViewAttachedToWindow() const;
25
26
  };
26
27
 
27
28
  // Called from Kotlin (SmoothClipViewManager) on the UI thread when a Fabric
@@ -44,6 +45,13 @@ void setViewHostGeometryAndroid(
44
45
  void unregisterViewAndroid(
45
46
  uint64_t driverId,
46
47
  facebook::jni::alias_ref<JSmoothClipView> view);
48
+ // Called from Kotlin when a registered view attaches to a window. A latched
49
+ // animation may only start once a registered view can actually produce a
50
+ // visible frame (attached AND has host geometry); otherwise its clock would
51
+ // burn progress no one can see.
52
+ void viewBecameDisplayableAndroid(
53
+ uint64_t driverId,
54
+ facebook::jni::alias_ref<JSmoothClipView> view);
47
55
 
48
56
  // Installs `global.__SmoothClipView` (worklet-callable host functions) into the
49
57
  // JS runtime and wires native animation completion delivery through the
@@ -406,6 +406,15 @@ void nativeUnregisterView(
406
406
  smoothclip::unregisterViewAndroid(static_cast<uint64_t>(driverId), view);
407
407
  }
408
408
 
409
+ void nativeViewBecameDisplayable(
410
+ jni::alias_ref<jni::JObject>,
411
+ jdouble driverId,
412
+ jni::alias_ref<smoothclip::JSmoothClipView> view) {
413
+ if (driverId <= 0 || !std::isfinite(driverId)) return;
414
+ smoothclip::viewBecameDisplayableAndroid(
415
+ static_cast<uint64_t>(driverId), view);
416
+ }
417
+
409
418
  void nativeInvalidate(jni::alias_ref<jni::JObject>) {
410
419
  smoothclip::invalidateBindings();
411
420
  }
@@ -422,6 +431,8 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
422
431
  makeNativeMethod(
423
432
  "nativeSetViewHostGeometry", nativeSetViewHostGeometry),
424
433
  makeNativeMethod("nativeUnregisterView", nativeUnregisterView),
434
+ makeNativeMethod(
435
+ "nativeViewBecameDisplayable", nativeViewBecameDisplayable),
425
436
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
426
437
  });
427
438
  });
@@ -47,6 +47,10 @@ struct ActiveAnimation {
47
47
  std::array<double, 7> springVelocity{};
48
48
  double lastFrameS = 0;
49
49
  bool finished = true;
50
+ // False while the animation is latched: built before any host view
51
+ // registered, held un-rendered (and out of animatingDrivers()) until the
52
+ // first registerViewAndroid rebases the clock and starts it.
53
+ bool started = false;
50
54
  };
51
55
 
52
56
  // Per-view fanout state. Density and host metrics are pushed from Kotlin at
@@ -454,20 +458,57 @@ Presentation prepareAnimation(
454
458
  return resolvedStart;
455
459
  }
456
460
 
461
+ // A view can only render the animation once it is attached to a window and
462
+ // has pushed real host geometry; a clock started before that burns progress
463
+ // no one can see (the transparentModal mount pattern).
464
+ bool entryDisplayable(const ViewEntry &entry) {
465
+ return entry.hostWidthPx > 0 && entry.hostHeightPx > 0 &&
466
+ entry.view->isViewAttachedToWindow();
467
+ }
468
+
469
+ bool anyDisplayableView(const DriverState &state) {
470
+ for (const ViewEntry &entry : state.views) {
471
+ if (entryDisplayable(entry)) return true;
472
+ }
473
+ return false;
474
+ }
475
+
476
+ // Starts a latched animation: rebases the clock so no progress was burned
477
+ // while no view could display, then joins the choreographer loop.
478
+ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
479
+ auto &animation = *state.animation;
480
+ animation.started = true;
481
+ animation.startedAtS = nowSeconds();
482
+ animation.lastFrameS = animation.startedAtS;
483
+ auto &active = animatingDrivers();
484
+ if (std::find(active.begin(), active.end(), driverId) == active.end()) {
485
+ active.push_back(driverId);
486
+ }
487
+ applyToViews(state, animation.start);
488
+ scheduleFrame();
489
+ }
490
+
457
491
  int32_t startAnimation(
458
492
  uint64_t driverId,
459
493
  DriverState &state,
460
- ActiveAnimation animation,
461
- const Presentation &target) {
462
- if (state.views.empty()) {
463
- applyPresentation(state, target);
464
- emitCompletion(driverId, animation.id, true);
465
- return animation.id;
466
- }
494
+ ActiveAnimation animation) {
467
495
  animation.startedAtS = nowSeconds();
468
496
  animation.lastFrameS = animation.startedAtS;
497
+ // current = start even while latched is load-bearing: cancelAnimation,
498
+ // beginInteraction, prepareAnimation's visibleBefore and registerView's
499
+ // visible all read animation->current, giving a never-rendered latch
500
+ // freeze-at-start / replace-from-start semantics with no extra branches.
469
501
  animation.current = animation.start;
502
+ animation.started = anyDisplayableView(state);
470
503
  state.animation = std::move(animation);
504
+ if (!state.animation->started) {
505
+ // Latch: no host view can display yet (animateTo raced the mount, or
506
+ // every host is detached/unsized). The first displayable registration,
507
+ // host-geometry push, or window attach rebases the clock, joins
508
+ // animatingDrivers() and schedules the frame loop. Non-zero id is still
509
+ // returned so the JS side does not treat this as rejection.
510
+ return state.animation->id;
511
+ }
471
512
  auto &active = animatingDrivers();
472
513
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
473
514
  active.push_back(driverId);
@@ -593,6 +634,15 @@ void setPresentation(
593
634
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
594
635
  if (takeOwnership) {
595
636
  state.destroyed = false;
637
+ // A held latch is strictly newer intent than this write: the latch was
638
+ // created after whatever value the caller read (the hook's seed replays
639
+ // a SharedValue that an earlier animateTo already advanced to its
640
+ // target). Cancelling it here would seed the target and turn the pending
641
+ // animation into a static jump. Callers that want to override a latch
642
+ // cancel it explicitly first.
643
+ if (state.animation.has_value() && !state.animation->started) {
644
+ return;
645
+ }
596
646
  finishActive(driverId, state, false);
597
647
  }
598
648
  state.latest = presentation;
@@ -652,7 +702,7 @@ int32_t animateTiming(
652
702
  active.start = resolvedStart;
653
703
  active.target = presentation;
654
704
  active.durationS = animation.durationMs / 1000.0;
655
- return startAnimation(driverId, state, std::move(active), presentation);
705
+ return startAnimation(driverId, state, std::move(active));
656
706
  }
657
707
 
658
708
  int32_t animateSpring(
@@ -695,7 +745,7 @@ int32_t animateSpring(
695
745
  active.springVelocity[index] =
696
746
  velocity * (targetChannels[index] - startChannels[index]);
697
747
  }
698
- return startAnimation(driverId, state, std::move(active), presentation);
748
+ return startAnimation(driverId, state, std::move(active));
699
749
  }
700
750
 
701
751
  int32_t animateKeyframes(
@@ -726,7 +776,7 @@ int32_t animateKeyframes(
726
776
  active.start = resolvedStart;
727
777
  active.target = presentation;
728
778
  active.durationS = durationMs / 1000.0;
729
- return startAnimation(driverId, state, std::move(active), presentation);
779
+ return startAnimation(driverId, state, std::move(active));
730
780
  }
731
781
 
732
782
  int32_t rejectAnimation(uint64_t driverId) {
@@ -785,6 +835,12 @@ void destroyDriver(uint64_t driverId) {
785
835
 
786
836
  // --- Android view lifecycle ----------------------------------------------
787
837
 
838
+ bool JSmoothClipView::isViewAttachedToWindow() const {
839
+ static const auto method =
840
+ javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
841
+ return method(self());
842
+ }
843
+
788
844
  void JSmoothClipView::applyClip(const Presentation &presentation) const {
789
845
  static const auto method =
790
846
  javaClassStatic()
@@ -845,13 +901,31 @@ void registerViewAndroid(
845
901
  existing.hostWidthPx = hostWidthPx;
846
902
  existing.hostHeightPx = hostHeightPx;
847
903
  deliverToView(existing, visible);
904
+ if (state.animation.has_value() && !state.animation->started &&
905
+ entryDisplayable(existing)) {
906
+ startLatchedAnimation(driverId, state);
907
+ }
848
908
  return;
849
909
  }
850
910
  }
851
911
  ViewEntry entry{
852
912
  facebook::jni::make_global(view), density, hostWidthPx, hostHeightPx};
913
+ // `visible` above already delivered animation->current (= start) to the
914
+ // registering view — the correct first frame for a latched animation.
853
915
  deliverToView(entry, visible);
854
916
  state.views.push_back(std::move(entry));
917
+ if (state.animation.has_value() && !state.animation->started &&
918
+ entryDisplayable(state.views.back())) {
919
+ // Start the latched animation: rebase the clock so the first frame
920
+ // integrates from now, not from the pre-mount animateTo call. Gated on
921
+ // displayability — a mount-time registration from a detached subtree
922
+ // holds the latch until window attach / first host geometry.
923
+ // Note this function has no isOnMainThread() guard (Kotlin calls it from
924
+ // the view's main-thread attach path); scheduleFrame() is itself
925
+ // main-thread-gated, so an off-main register cannot bind the
926
+ // choreographer to the wrong thread.
927
+ startLatchedAnimation(driverId, state);
928
+ }
855
929
  }
856
930
 
857
931
  void setViewHostGeometryAndroid(
@@ -876,6 +950,29 @@ void setViewHostGeometryAndroid(
876
950
  state.animation.has_value() ? state.animation->current
877
951
  : state.latest);
878
952
  }
953
+ if (state.animation.has_value() && !state.animation->started &&
954
+ entryDisplayable(entry)) {
955
+ // First real host geometry made this view displayable.
956
+ startLatchedAnimation(driverId, state);
957
+ }
958
+ return;
959
+ }
960
+ }
961
+
962
+ void viewBecameDisplayableAndroid(
963
+ uint64_t driverId,
964
+ alias_ref<JSmoothClipView> view) {
965
+ if (!isOnMainThread()) return;
966
+ auto iterator = registry().find(driverId);
967
+ if (iterator == registry().end()) return;
968
+ auto &state = iterator->second;
969
+ if (!state.animation.has_value() || state.animation->started) return;
970
+ JNIEnv *env = facebook::jni::Environment::current();
971
+ for (auto &entry : state.views) {
972
+ if (!env->IsSameObject(entry.view.get(), view.get())) continue;
973
+ if (entryDisplayable(entry)) {
974
+ startLatchedAnimation(driverId, state);
975
+ }
879
976
  return;
880
977
  }
881
978
  }
@@ -898,11 +995,59 @@ void unregisterViewAndroid(
898
995
  }
899
996
  if (removed && state.animation.has_value()) {
900
997
  state.animation->finished = false;
901
- if (state.views.empty()) {
902
- // The animation ends here; release ownership so a later remount's
903
- // interactive updates are not dropped.
904
- state.ownership = Ownership::Interactive;
905
- finishActive(driverId, state, false);
998
+ if (state.views.empty() && !state.animation->started) {
999
+ // A held latch returns to its original zero-view state; the next
1000
+ // displayable registration still starts it (or destroyDriver cancels
1001
+ // it). Completing it here would betray the pending intent.
1002
+ } else if (state.views.empty()) {
1003
+ auto &animation = *state.animation;
1004
+ const double elapsedS = nowSeconds() - animation.startedAtS;
1005
+ const double remainingS = std::max(0.0, animation.durationS - elapsedS);
1006
+ const bool canResume = !state.destroyed &&
1007
+ (animation.kind == AnimationKind::Spring || remainingS > 0);
1008
+ if (!canResume) {
1009
+ // The animation ends here; release ownership so a later remount's
1010
+ // interactive updates are not dropped.
1011
+ state.ownership = Ownership::Interactive;
1012
+ finishActive(driverId, state, false);
1013
+ } else {
1014
+ // The last rendering host left mid-flight (remount, screen swap).
1015
+ // Completing here would leave `latest` at the target and statically
1016
+ // snap any re-registering host straight to it. Re-latch instead:
1017
+ // freeze the remaining animation at its current geometry so the
1018
+ // next displayable host resumes it with the true remaining time.
1019
+ // destroyDriver cancels a latch that never finds a host, so the
1020
+ // completion cannot hang.
1021
+ if (animation.kind == AnimationKind::Timing) {
1022
+ animation.durationS = remainingS;
1023
+ } else if (animation.kind == AnimationKind::Keyframes) {
1024
+ const double progress = animation.durationS <= 0
1025
+ ? 1.0
1026
+ : clamp01(elapsedS / animation.durationS);
1027
+ std::vector<Keyframe> remaining;
1028
+ remaining.push_back({0, animation.current});
1029
+ for (const Keyframe &frame : animation.keyframes) {
1030
+ if (frame.offset <= progress) continue;
1031
+ remaining.push_back(
1032
+ {(frame.offset - progress) / (1 - progress),
1033
+ frame.presentation});
1034
+ }
1035
+ if (remaining.size() == 1) {
1036
+ remaining.push_back({1, animation.target});
1037
+ }
1038
+ animation.keyframes = std::move(remaining);
1039
+ animation.durationS = remainingS;
1040
+ }
1041
+ animation.start = animation.current;
1042
+ animation.started = false;
1043
+ // A held latch must not tick: leave animatingDrivers() without
1044
+ // resetting the animation (the eager-removal rule the choreographer
1045
+ // loop depends on).
1046
+ auto &active = animatingDrivers();
1047
+ active.erase(
1048
+ std::remove(active.begin(), active.end(), driverId),
1049
+ active.end());
1050
+ }
906
1051
  }
907
1052
  }
908
1053
  if (state.destroyed && state.views.empty()) {
@@ -47,6 +47,13 @@ internal object SmoothClipBindings {
47
47
 
48
48
  external fun nativeUnregisterView(driverId: Double, view: SmoothClipView)
49
49
 
50
+ /**
51
+ * Notifies the registry that a registered view attached to a window; a
52
+ * latched animation may only start once a view can produce a visible
53
+ * frame (attached and with real host geometry).
54
+ */
55
+ external fun nativeViewBecameDisplayable(driverId: Double, view: SmoothClipView)
56
+
50
57
  /**
51
58
  * Releases runtime-bound listener state during host teardown; must run
52
59
  * while the JS runtime is still alive (module invalidate).
@@ -232,6 +232,16 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
232
232
  }
233
233
  }
234
234
 
235
+ override fun onAttachedToWindow() {
236
+ super.onAttachedToWindow()
237
+ if (boundDriverId != 0.0) {
238
+ // A latched animation may only start once this view can produce a
239
+ // visible frame; window attach is that signal for views that
240
+ // registered from a detached subtree.
241
+ SmoothClipBindings.nativeViewBecameDisplayable(boundDriverId, this)
242
+ }
243
+ }
244
+
235
245
  override fun onConfigurationChanged(newConfig: Configuration) {
236
246
  super.onConfigurationChanged(newConfig)
237
247
  if (boundDriverId != 0.0) {
@@ -35,6 +35,8 @@
35
35
  animationId:(int32_t)animationId;
36
36
  - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
37
37
  - (BOOL)smoothClipIsJoinable;
38
+ - (BOOL)smoothClipCanDisplay;
39
+ - (BOOL)smoothClipHasPendingInstall;
38
40
  @end
39
41
 
40
42
  namespace smoothclip {
@@ -58,6 +60,10 @@ struct ActiveAnimation {
58
60
  std::vector<Keyframe> keyframes;
59
61
  double durationMs = 0;
60
62
  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.
66
+ bool started = false;
61
67
  };
62
68
 
63
69
  struct DriverState {
@@ -170,11 +176,20 @@ int32_t allocateAnimationId(DriverState &state) {
170
176
  }
171
177
 
172
178
  Presentation canonicalFrozenPresentation(DriverState &state) {
173
- Presentation canonical = state.latest;
179
+ // A latched animation never rendered, so state.latest already holds its
180
+ // target; freezing there would jump the clip. Freeze at the start instead.
181
+ Presentation canonical =
182
+ state.animation.has_value() && !state.animation->started
183
+ ? state.animation->start
184
+ : state.latest;
174
185
  bool hasCanonical = false;
175
186
  for (const ViewKey key : state.views) {
176
- const Presentation frozen = [viewForKey(key) smoothClipFreezePresentation];
177
- if (!hasCanonical) {
187
+ // Freeze every view (the teardown side effect must reach all of them),
188
+ // but only a laid-out view may define the canonical geometry — an
189
+ // unlaid-out one reports {0,0,0,0} and would collapse the clip.
190
+ SmoothClipView *candidate = viewForKey(key);
191
+ const Presentation frozen = [candidate smoothClipFreezePresentation];
192
+ if (!hasCanonical && [candidate smoothClipIsJoinable]) {
178
193
  canonical = frozen;
179
194
  hasCanonical = true;
180
195
  }
@@ -224,9 +239,16 @@ void prepareAnimation(
224
239
  Presentation resolvedAnimationStart(
225
240
  const DriverState &state,
226
241
  const AnimationStart &start) {
227
- return start.hasInteractiveStart && state.ownership == Ownership::Interactive
228
- ? start.interactiveStart
229
- : state.latest;
242
+ if (start.hasInteractiveStart && state.ownership == Ownership::Interactive) {
243
+ return start.interactiveStart;
244
+ }
245
+ // Replacing a latched animation (e.g. open→close before the host mounted):
246
+ // it never rendered, and state.latest is its target — start from where the
247
+ // clip actually is, the latch's own start.
248
+ if (state.animation.has_value() && !state.animation->started) {
249
+ return state.animation->start;
250
+ }
251
+ return state.latest;
230
252
  }
231
253
 
232
254
  // Installs the remaining part of the active animation on a view that joins
@@ -270,6 +292,33 @@ void dispatchActiveAnimationJoin(
270
292
  }
271
293
  }
272
294
 
295
+ // An animation may only start once a registered view can actually produce a
296
+ // visible frame. A CA animation committed while its layer tree is detached
297
+ // from the render tree (a transparentModal subtree before UIKit presents its
298
+ // view controller) is removed at the attach commit with finished=NO, which
299
+ // snaps the layer to its model values — the animation's target.
300
+ bool anyDisplayableView(const DriverState &state) {
301
+ for (const ViewKey key : state.views) {
302
+ if ([viewForKey(key) smoothClipCanDisplay]) return true;
303
+ }
304
+ return false;
305
+ }
306
+
307
+ // Starts a latched animation: rebases the clock so no progress was burned
308
+ // while no view could display, then dispatches the join to every registered
309
+ // view. Views that still cannot display defer their install per-view and
310
+ // resume via viewBecameDisplayable.
311
+ void startLatchedAnimation(DriverState &state) {
312
+ state.animation->startedAt = CACurrentMediaTime();
313
+ state.animation->started = true;
314
+ const Presentation start = state.animation->start;
315
+ for (const ViewKey key : state.views) {
316
+ SmoothClipView *participant = viewForKey(key);
317
+ [participant smoothClipApplyPresentation:start];
318
+ dispatchActiveAnimationJoin(state, participant, key, start);
319
+ }
320
+ }
321
+
273
322
  } // namespace
274
323
 
275
324
  void setCompletionCallback(
@@ -303,8 +352,15 @@ void registerView(
303
352
  state.latest = initialPresentation;
304
353
  state.hasLatest = true;
305
354
  }
355
+ const bool hasAnimation = state.animation.has_value();
356
+ const bool hasLatch = hasAnimation && !state.animation->started;
357
+ // A latch may only start once this view can produce a visible frame; a
358
+ // 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.
361
+ const bool startsLatch = hasLatch && [view smoothClipCanDisplay];
306
362
  const bool shouldReplay =
307
- state.animation.has_value() && !state.views.empty();
363
+ hasAnimation && state.animation->started && !state.views.empty();
308
364
  Presentation visible = state.latest;
309
365
  if (shouldReplay) {
310
366
  // A registered peer that never laid out reports zero geometry; joining
@@ -321,6 +377,10 @@ void registerView(
321
377
  visible = reference != nil
322
378
  ? [reference smoothClipCurrentPresentation]
323
379
  : state.animation->start;
380
+ } else if (hasLatch) {
381
+ // state.latest already holds the latch's TARGET; applying it here is
382
+ // exactly the jump this path exists to avoid.
383
+ visible = state.animation->start;
324
384
  }
325
385
  const ViewKey key = keyForView(view);
326
386
  if (std::find(state.views.begin(), state.views.end(), key) ==
@@ -328,17 +388,57 @@ void registerView(
328
388
  state.views.push_back(key);
329
389
  }
330
390
  [view smoothClipApplyPresentation:visible];
331
- if (!shouldReplay || !state.animation.has_value()) return;
332
-
391
+ 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).
395
+ startLatchedAnimation(state);
396
+ return;
397
+ }
398
+ if (!shouldReplay) return;
333
399
  dispatchActiveAnimationJoin(state, view, key, visible);
334
400
  }
335
401
 
402
+ void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view) {
403
+ NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
404
+ auto iterator = registry().find(driverId);
405
+ if (iterator == registry().end()) return;
406
+ auto &state = iterator->second;
407
+ if (!state.animation.has_value()) return;
408
+ const ViewKey key = keyForView(view);
409
+ if (std::find(state.views.begin(), state.views.end(), key) ==
410
+ state.views.end()) {
411
+ return;
412
+ }
413
+ if (!state.animation->started) {
414
+ if (![view smoothClipCanDisplay]) return;
415
+ startLatchedAnimation(state);
416
+ return;
417
+ }
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]) {
422
+ joinActiveAnimation(driverId, view);
423
+ }
424
+ }
425
+
336
426
  bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
337
427
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
338
428
  auto iterator = registry().find(driverId);
339
- if (iterator == registry().end()) return false;
429
+ if (iterator == registry().end()) {
430
+ return false;
431
+ }
340
432
  auto &state = iterator->second;
341
- if (!state.animation.has_value() || state.views.empty()) return false;
433
+ if (!state.animation.has_value() || state.views.empty()) {
434
+ return false;
435
+ }
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
+ }
342
442
  const ViewKey key = keyForView(view);
343
443
  if (std::find(state.views.begin(), state.views.end(), key) ==
344
444
  state.views.end()) {
@@ -374,11 +474,50 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
374
474
  state.animation->participants.erase(key) > 0) {
375
475
  state.animation->finished = false;
376
476
  if (state.animation->participants.empty()) {
377
- // The animation ends here; without releasing ownership a host that
378
- // remounts on this driver later would drop every interactive update.
379
- state.ownership = Ownership::Interactive;
380
- emitCompletion(
381
- driverId, state, state.animation->animationId, false);
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;
517
+ }
518
+ active.start = visible;
519
+ active.started = false;
520
+ }
382
521
  }
383
522
  }
384
523
  state.views.erase(
@@ -435,6 +574,21 @@ void setPresentation(
435
574
  }
436
575
  if (takeOwnership) {
437
576
  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) {
584
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
585
+ if (signpostsEnabled) {
586
+ os_signpost_interval_end(
587
+ signpostLog(), identifier, "lookup-fanout", "ignored=held-latch");
588
+ }
589
+ #endif
590
+ return;
591
+ }
438
592
  cancelActive(driverId, state, false);
439
593
  }
440
594
  applyPresentation(state, presentation);
@@ -480,7 +634,9 @@ int32_t animateTiming(
480
634
  // documented rejection sentinel.
481
635
  return 0;
482
636
  } auto iterator = registry().find(driverId);
483
- if (iterator == registry().end()) return 0;
637
+ if (iterator == registry().end()) {
638
+ return 0;
639
+ }
484
640
  auto &state = iterator->second;
485
641
  state.destroyed = false;
486
642
  const int32_t animationId = allocateAnimationId(state);
@@ -500,10 +656,15 @@ int32_t animateTiming(
500
656
  active.durationMs = animation.durationMs;
501
657
  active.startedAt = CACurrentMediaTime();
502
658
  active.participants.insert(state.views.begin(), state.views.end());
659
+ active.started = anyDisplayableView(state);
503
660
  state.animation = std::move(active);
504
- if (state.views.empty()) {
505
- state.ownership = Ownership::Interactive;
506
- emitCompletion(driverId, state, animationId, true);
661
+ if (!state.animation->started) {
662
+ // Latch: no host view can display yet (animateTo raced the mount, or the
663
+ // host sits in a detached subtree awaiting presentation). Ownership stays
664
+ // Native — the pending animation owns rendering intent — and the first
665
+ // displayable registration/attach rebases startedAt and starts it. The
666
+ // non-zero id is still returned synchronously so the JS side does not
667
+ // treat this as rejection.
507
668
  return animationId;
508
669
  }
509
670
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -549,10 +710,10 @@ int32_t animateSpring(
549
710
  active.spring = animation;
550
711
  active.startedAt = CACurrentMediaTime();
551
712
  active.participants.insert(state.views.begin(), state.views.end());
713
+ active.started = anyDisplayableView(state);
552
714
  state.animation = std::move(active);
553
- if (state.views.empty()) {
554
- state.ownership = Ownership::Interactive;
555
- emitCompletion(driverId, state, animationId, true);
715
+ if (!state.animation->started) {
716
+ // Latched until the first displayable view — see animateTiming.
556
717
  return animationId;
557
718
  }
558
719
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -600,10 +761,10 @@ int32_t animateKeyframes(
600
761
  active.durationMs = durationMs;
601
762
  active.startedAt = CACurrentMediaTime();
602
763
  active.participants.insert(state.views.begin(), state.views.end());
764
+ active.started = anyDisplayableView(state);
603
765
  state.animation = std::move(active);
604
- if (state.views.empty()) {
605
- state.ownership = Ownership::Interactive;
606
- emitCompletion(driverId, state, animationId, true);
766
+ if (!state.animation->started) {
767
+ // Latched until the first displayable view — see animateTiming.
607
768
  return animationId;
608
769
  }
609
770
  for (const ViewKey key : state.views) {
@@ -167,6 +167,13 @@ using namespace facebook::react;
167
167
  }
168
168
  }
169
169
 
170
+ - (void)didMoveToWindow {
171
+ [super didMoveToWindow];
172
+ if (_driverId != 0 && [self smoothClipCanDisplay]) {
173
+ smoothclip::viewBecameDisplayable(_driverId, self);
174
+ }
175
+ }
176
+
170
177
  - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
171
178
  index:(NSInteger)index {
172
179
  NSAssert(childComponentView.superview == nil,
@@ -403,6 +410,17 @@ using namespace facebook::react;
403
410
  return _hasLayout;
404
411
  }
405
412
 
413
+ - (BOOL)smoothClipCanDisplay {
414
+ // A CA animation committed while this view's layer tree is detached from
415
+ // the render tree does not survive the later attach commit; installs and
416
+ // latch starts must wait until a frame can actually be produced.
417
+ return _hasLayout && self.window != nil;
418
+ }
419
+
420
+ - (BOOL)smoothClipHasPendingInstall {
421
+ return _pendingAnimationInstall;
422
+ }
423
+
406
424
  - (smoothclip::Presentation)smoothClipFreezePresentation {
407
425
  const smoothclip::Presentation visible =
408
426
  [self smoothClipCurrentPresentation];
@@ -647,7 +665,7 @@ using namespace facebook::react;
647
665
  }
648
666
 
649
667
  - (void)startAnimationToRequestedGeometryWithDuration:(CFTimeInterval)duration {
650
- if (!_hasLayout) {
668
+ if (![self smoothClipCanDisplay]) {
651
669
  _pendingAnimationInstall = YES;
652
670
  return;
653
671
  }
@@ -720,7 +738,7 @@ using namespace facebook::react;
720
738
  [self storeRequestedPresentation:presentation];
721
739
  _activeAnimationKind = 3;
722
740
  _activeAnimationId = animationId;
723
- if (!_hasLayout) {
741
+ if (![self smoothClipCanDisplay]) {
724
742
  _pendingAnimationInstall = YES;
725
743
  return;
726
744
  }
@@ -937,8 +955,14 @@ using namespace facebook::react;
937
955
  // the true remaining duration instead of jumping to the target.
938
956
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
939
957
  _hasLayout = YES;
940
- _pendingAnimationInstall = NO;
941
958
  [self layoutContentContainer];
959
+ if (self.window == nil) {
960
+ // Still detached (transparentModal subtree before presentation): an
961
+ // install here would not survive the attach commit. Keep the install
962
+ // pending; didMoveToWindow completes it.
963
+ return;
964
+ }
965
+ _pendingAnimationInstall = NO;
942
966
  if (!smoothclip::joinActiveAnimation(_driverId, self)) {
943
967
  // The animation finished or was cancelled while awaiting layout.
944
968
  _activeAnimationId = 0;
@@ -984,6 +1008,14 @@ using namespace facebook::react;
984
1008
  [self writeLayerRect:paused.rect radius:paused.radius];
985
1009
  }
986
1010
  } else if (wasAnimating) {
1011
+ if (![self smoothClipCanDisplay]) {
1012
+ // Relayout while detached, with an animation dispatched to this view
1013
+ // (a transparentModal subtree resized before presentation): an install
1014
+ // from this commit would not survive the attach commit. Keep the
1015
+ // install deferred; didMoveToWindow completes it via the registry join.
1016
+ _pendingAnimationInstall = YES;
1017
+ return;
1018
+ }
987
1019
  const int32_t animationId = _activeAnimationId;
988
1020
  const NSInteger animationKind = _activeAnimationKind;
989
1021
  [self stopLayerAnimationWithoutCallback];
@@ -16,6 +16,14 @@ void unregisterView(uint64_t driverId, SmoothClipView *view);
16
16
  // install was deferred because it had no layout yet. Returns false when the
17
17
  // driver or its active animation no longer exists.
18
18
  bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view);
19
+ // Called when a registered view first becomes able to produce a visible
20
+ // frame (has layout AND is attached to a window). Starts a latched animation
21
+ // — rebasing its clock so no progress is burned while the view was
22
+ // detached — or completes a deferred install for an already-running one.
23
+ // A CA animation committed while the host's layer tree is detached (e.g. a
24
+ // transparentModal subtree before its view controller is presented) does not
25
+ // survive the attach commit; installs must therefore wait for this signal.
26
+ void viewBecameDisplayable(uint64_t driverId, SmoothClipView *view);
19
27
  void viewAnimationDidStop(
20
28
  uint64_t driverId,
21
29
  int32_t animationId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-smooth-clip-view",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",