react-native-smooth-clip-view 0.2.1 → 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,16 +194,22 @@ 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).
201
- - An `animateTo` issued before any host view has registered (for example from
202
- an effect in the same commit that mounts the host, or inside a modal route
203
- that attaches late) is held pending and starts with its full duration when
204
- the first view registers. If no view ever registers, the pending animation
205
- survives until it is replaced, cancelled, overridden by a take-ownership
206
- write, or the driver is destroyed — at which point its single
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
207
213
  `finished: false` completion is delivered.
208
214
 
209
215
  Do not call `driver.ui` from React code — it throws on the React runtime; use
@@ -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
  });
@@ -458,6 +458,36 @@ Presentation prepareAnimation(
458
458
  return resolvedStart;
459
459
  }
460
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
+
461
491
  int32_t startAnimation(
462
492
  uint64_t driverId,
463
493
  DriverState &state,
@@ -469,13 +499,14 @@ int32_t startAnimation(
469
499
  // visible all read animation->current, giving a never-rendered latch
470
500
  // freeze-at-start / replace-from-start semantics with no extra branches.
471
501
  animation.current = animation.start;
472
- animation.started = !state.views.empty();
502
+ animation.started = anyDisplayableView(state);
473
503
  state.animation = std::move(animation);
474
504
  if (!state.animation->started) {
475
- // Latch: no host view yet (animateTo raced the mount). The first
476
- // registerViewAndroid rebases the clock, joins animatingDrivers() and
477
- // schedules the frame loop. Non-zero id is still returned so the JS
478
- // side does not treat this as rejection.
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.
479
510
  return state.animation->id;
480
511
  }
481
512
  auto &active = animatingDrivers();
@@ -603,6 +634,15 @@ void setPresentation(
603
634
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
604
635
  if (takeOwnership) {
605
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
+ }
606
646
  finishActive(driverId, state, false);
607
647
  }
608
648
  state.latest = presentation;
@@ -795,6 +835,12 @@ void destroyDriver(uint64_t driverId) {
795
835
 
796
836
  // --- Android view lifecycle ----------------------------------------------
797
837
 
838
+ bool JSmoothClipView::isViewAttachedToWindow() const {
839
+ static const auto method =
840
+ javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
841
+ return method(self());
842
+ }
843
+
798
844
  void JSmoothClipView::applyClip(const Presentation &presentation) const {
799
845
  static const auto method =
800
846
  javaClassStatic()
@@ -849,15 +895,16 @@ void registerViewAndroid(
849
895
  ? state.animation->current
850
896
  : state.latest;
851
897
  JNIEnv *env = facebook::jni::Environment::current();
852
- // The existing-view branch below is unreachable while an animation is
853
- // latched (a latch implies views was empty at animate time), so only the
854
- // new-view path needs to start latches.
855
898
  for (auto &existing : state.views) {
856
899
  if (env->IsSameObject(existing.view.get(), view.get())) {
857
900
  existing.density = density;
858
901
  existing.hostWidthPx = hostWidthPx;
859
902
  existing.hostHeightPx = hostHeightPx;
860
903
  deliverToView(existing, visible);
904
+ if (state.animation.has_value() && !state.animation->started &&
905
+ entryDisplayable(existing)) {
906
+ startLatchedAnimation(driverId, state);
907
+ }
861
908
  return;
862
909
  }
863
910
  }
@@ -867,22 +914,17 @@ void registerViewAndroid(
867
914
  // registering view — the correct first frame for a latched animation.
868
915
  deliverToView(entry, visible);
869
916
  state.views.push_back(std::move(entry));
870
- if (state.animation.has_value() && !state.animation->started) {
917
+ if (state.animation.has_value() && !state.animation->started &&
918
+ entryDisplayable(state.views.back())) {
871
919
  // Start the latched animation: rebase the clock so the first frame
872
- // integrates from now, not from the pre-mount animateTo call.
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.
873
923
  // Note this function has no isOnMainThread() guard (Kotlin calls it from
874
924
  // the view's main-thread attach path); scheduleFrame() is itself
875
925
  // main-thread-gated, so an off-main register cannot bind the
876
926
  // choreographer to the wrong thread.
877
- auto &animation = *state.animation;
878
- animation.started = true;
879
- animation.startedAtS = nowSeconds();
880
- animation.lastFrameS = animation.startedAtS;
881
- auto &active = animatingDrivers();
882
- if (std::find(active.begin(), active.end(), driverId) == active.end()) {
883
- active.push_back(driverId);
884
- }
885
- scheduleFrame();
927
+ startLatchedAnimation(driverId, state);
886
928
  }
887
929
  }
888
930
 
@@ -908,6 +950,29 @@ void setViewHostGeometryAndroid(
908
950
  state.animation.has_value() ? state.animation->current
909
951
  : state.latest);
910
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
+ }
911
976
  return;
912
977
  }
913
978
  }
@@ -930,11 +995,59 @@ void unregisterViewAndroid(
930
995
  }
931
996
  if (removed && state.animation.has_value()) {
932
997
  state.animation->finished = false;
933
- if (state.views.empty()) {
934
- // The animation ends here; release ownership so a later remount's
935
- // interactive updates are not dropped.
936
- state.ownership = Ownership::Interactive;
937
- 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
+ }
938
1051
  }
939
1052
  }
940
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 {
@@ -182,8 +184,12 @@ Presentation canonicalFrozenPresentation(DriverState &state) {
182
184
  : state.latest;
183
185
  bool hasCanonical = false;
184
186
  for (const ViewKey key : state.views) {
185
- const Presentation frozen = [viewForKey(key) smoothClipFreezePresentation];
186
- 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]) {
187
193
  canonical = frozen;
188
194
  hasCanonical = true;
189
195
  }
@@ -286,6 +292,33 @@ void dispatchActiveAnimationJoin(
286
292
  }
287
293
  }
288
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
+
289
322
  } // namespace
290
323
 
291
324
  void setCompletionCallback(
@@ -320,7 +353,12 @@ void registerView(
320
353
  state.hasLatest = true;
321
354
  }
322
355
  const bool hasAnimation = state.animation.has_value();
323
- const bool startsLatch = hasAnimation && !state.animation->started;
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];
324
362
  const bool shouldReplay =
325
363
  hasAnimation && state.animation->started && !state.views.empty();
326
364
  Presentation visible = state.latest;
@@ -339,7 +377,7 @@ void registerView(
339
377
  visible = reference != nil
340
378
  ? [reference smoothClipCurrentPresentation]
341
379
  : state.animation->start;
342
- } else if (startsLatch) {
380
+ } else if (hasLatch) {
343
381
  // state.latest already holds the latch's TARGET; applying it here is
344
382
  // exactly the jump this path exists to avoid.
345
383
  visible = state.animation->start;
@@ -351,28 +389,56 @@ void registerView(
351
389
  }
352
390
  [view smoothClipApplyPresentation:visible];
353
391
  if (startsLatch) {
354
- // Start the latched animation: rebase the join clock before any elapsed
355
- // math runs (dispatch below and the first-layout joinActiveAnimation).
356
- state.animation->startedAt = CACurrentMediaTime();
357
- state.animation->started = true;
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;
358
397
  }
359
- if (!(shouldReplay || startsLatch)) return;
360
-
361
- // The participant insert inside the join is also what keeps unregisterView
362
- // orphan-safe for a mount-then-unmount before first layout.
398
+ if (!shouldReplay) return;
363
399
  dispatchActiveAnimationJoin(state, view, key, visible);
364
400
  }
365
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
+
366
426
  bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
367
427
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
368
428
  auto iterator = registry().find(driverId);
369
- if (iterator == registry().end()) return false;
429
+ if (iterator == registry().end()) {
430
+ return false;
431
+ }
370
432
  auto &state = iterator->second;
371
- if (!state.animation.has_value() || state.views.empty()) return false;
433
+ if (!state.animation.has_value() || state.views.empty()) {
434
+ return false;
435
+ }
372
436
  // Unreachable in the normal flow (a pending install implies the animation
373
437
  // was dispatched, which implies started), but a join must never compute
374
438
  // elapsed time against an un-rebased latch clock.
375
- if (!state.animation->started) return false;
439
+ if (!state.animation->started) {
440
+ return false;
441
+ }
376
442
  const ViewKey key = keyForView(view);
377
443
  if (std::find(state.views.begin(), state.views.end(), key) ==
378
444
  state.views.end()) {
@@ -408,11 +474,50 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
408
474
  state.animation->participants.erase(key) > 0) {
409
475
  state.animation->finished = false;
410
476
  if (state.animation->participants.empty()) {
411
- // The animation ends here; without releasing ownership a host that
412
- // remounts on this driver later would drop every interactive update.
413
- state.ownership = Ownership::Interactive;
414
- emitCompletion(
415
- 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
+ }
416
521
  }
417
522
  }
418
523
  state.views.erase(
@@ -469,6 +574,21 @@ void setPresentation(
469
574
  }
470
575
  if (takeOwnership) {
471
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
+ }
472
592
  cancelActive(driverId, state, false);
473
593
  }
474
594
  applyPresentation(state, presentation);
@@ -514,7 +634,9 @@ int32_t animateTiming(
514
634
  // documented rejection sentinel.
515
635
  return 0;
516
636
  } auto iterator = registry().find(driverId);
517
- if (iterator == registry().end()) return 0;
637
+ if (iterator == registry().end()) {
638
+ return 0;
639
+ }
518
640
  auto &state = iterator->second;
519
641
  state.destroyed = false;
520
642
  const int32_t animationId = allocateAnimationId(state);
@@ -534,13 +656,15 @@ int32_t animateTiming(
534
656
  active.durationMs = animation.durationMs;
535
657
  active.startedAt = CACurrentMediaTime();
536
658
  active.participants.insert(state.views.begin(), state.views.end());
537
- active.started = !state.views.empty();
659
+ active.started = anyDisplayableView(state);
538
660
  state.animation = std::move(active);
539
661
  if (!state.animation->started) {
540
- // Latch: no host view yet (animateTo raced the mount). Ownership stays
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
541
664
  // Native — the pending animation owns rendering intent — and the first
542
- // registerView rebases startedAt and starts it. The non-zero id is still
543
- // returned synchronously so the JS side does not treat this as rejection.
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.
544
668
  return animationId;
545
669
  }
546
670
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -586,10 +710,10 @@ int32_t animateSpring(
586
710
  active.spring = animation;
587
711
  active.startedAt = CACurrentMediaTime();
588
712
  active.participants.insert(state.views.begin(), state.views.end());
589
- active.started = !state.views.empty();
713
+ active.started = anyDisplayableView(state);
590
714
  state.animation = std::move(active);
591
715
  if (!state.animation->started) {
592
- // Latched until the first registerView — see animateTiming.
716
+ // Latched until the first displayable view — see animateTiming.
593
717
  return animationId;
594
718
  }
595
719
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -637,10 +761,10 @@ int32_t animateKeyframes(
637
761
  active.durationMs = durationMs;
638
762
  active.startedAt = CACurrentMediaTime();
639
763
  active.participants.insert(state.views.begin(), state.views.end());
640
- active.started = !state.views.empty();
764
+ active.started = anyDisplayableView(state);
641
765
  state.animation = std::move(active);
642
766
  if (!state.animation->started) {
643
- // Latched until the first registerView — see animateTiming.
767
+ // Latched until the first displayable view — see animateTiming.
644
768
  return animationId;
645
769
  }
646
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.1",
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",