react-native-smooth-clip-view 0.2.6 → 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.
package/README.md CHANGED
@@ -208,10 +208,10 @@ puts on screen, which is the property worth keeping identical.
208
208
  (< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
209
209
  write) coalesce into one sample, and an identical re-write is ignored, so a
210
210
  fused handoff can neither zero nor inflate the inherited velocity.
211
- `beginInteraction()` records the frozen presentation as a plain sample on
212
- both platforms, so a grab-and-instant-refling inherits bounded recent
213
- motion (the staleness guard still zeroes a stale pair) instead of
214
- launching dead.
211
+ Only interactive writes contribute samples. Internal freeze/join/resume and
212
+ static-finalization writes do not, so grabbing a native animation cannot
213
+ manufacture velocity for a later `'inherit'` spring; a subsequent drag (or
214
+ explicit `animation.from`) supplies the release samples instead.
215
215
  - `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
216
216
  as Promises (`setScalars` is UI-worklet-only). React code never blocks
217
217
  waiting for main/UI-thread work. An immediate animation request resolves
@@ -249,26 +249,40 @@ puts on screen, which is the property worth keeping identical.
249
249
  authoritative interactive start, creates the missing driver state and
250
250
  returns a real id. `0` — with no completion — is reserved for off-main,
251
251
  invalid-id, or otherwise unsupported dispatch: a missing-state native request
252
- with no authoritative start, and any `driver.ui.animateTo` issued after the
253
- driver's hook has unmounted. (The second case is decided on the UI runtime,
252
+ with no authoritative start, any `driver.ui.animateTo` issued after the
253
+ driver's hook has unmounted, and an invalid-parameter request issued before
254
+ the driver's first seed reached native (validation rejections mint their
255
+ `finished: false` completion only once the native entry exists). (The
256
+ post-unmount case is decided on the UI runtime,
254
257
  because a destroyed driver and a not-yet-seeded one are the same missing
255
258
  registry entry to native — accepting it would build a latch nothing can start
256
- and nothing can cancel.) Removing the last displayable
257
- host mid-flight does not complete the animation: the remainder is re-latched
258
- even if detached or unsized peers remain registered, and the next displayable
259
- host resumes it, so the completion arrives when it finishes there — or unfinished on
260
- replacement, cancellation, `beginInteraction()`, or driver destruction.
261
- With multiple hosts on one driver, unregistering any host that installed the
262
- running animation makes the eventual completion `finished: false` — a
263
- detached or un-laid-out host does not become a completion participant until
264
- its native animation is actually installed. `finished: true` means every
265
- installed participant ran the animation to its end.
259
+ and nothing can cancel.) A host is displayable only while it is attached,
260
+ foreground/window-visible, laid out, and positive-sized (the window's own
261
+ `hidden`/scene state is not consulted — RN's single always-visible window
262
+ makes it moot). Losing the last
263
+ displayable host mid-flight — including temporary detach, zero-size layout,
264
+ or app background — freezes and re-latches the exact remainder instead of
265
+ consuming duration offscreen. Foreground/reattach resumes the same animation
266
+ ID from that stored timing/keyframe phase or spring state.
267
+ Parallel Reanimated clocks do **not** pause with it: a `withTiming` started
268
+ beside `animateTo` keeps its wall-clock start, so after backgrounding it
269
+ completes on its first resumed frame while the native run still animates its
270
+ preserved remainder. Key teardown and state transitions off
271
+ `onAnimationComplete` (or re-synchronize on `AppState`) rather than off a
272
+ duration-matched Reanimated callback.
273
+ With multiple hosts on one driver, a host becomes an installed participant
274
+ only after its native animation starts. Temporary loss moves it to suspended;
275
+ a rejoin restores active participation. Unregistering an installed/suspended
276
+ host, or reaching completion while it remains suspended, makes the eventual
277
+ completion `finished: false`. A host that stayed deferred because it was
278
+ detached, unlaid-out, or zero-sized never poisons completion. `finished: true`
279
+ means every installed participant either ran to the end or rejoined and did so.
266
280
  - An `animateTo` issued before any host view can produce a visible frame (for
267
281
  example from an effect in the same commit that mounts the host, or inside a
268
282
  modal route whose subtree attaches to its window late) is held pending and
269
283
  starts with its full duration at the first moment a registered host can
270
- produce a frame — registration, first layout, or window attach, whichever
271
- comes last. This also covers an animation worklet that runs before the
284
+ produce a frame — positive layout, window attach/visibility, and foreground
285
+ state must all be present. This also covers an animation worklet that runs before the
272
286
  hook's seed worklet: the animation creates the state and the later passive
273
287
  seed cannot reset its ownership or active id. A pending animation owns the
274
288
  driver: ordinary take-ownership writes (`set`, `setScalars`, the hook's seed)
@@ -114,6 +114,19 @@ android {
114
114
  targetCompatibility JavaVersion.VERSION_1_8
115
115
  }
116
116
 
117
+ testOptions {
118
+ unitTests {
119
+ includeAndroidResources = true
120
+ all {
121
+ // Robolectric on Java 17 reflects into these platform packages.
122
+ jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED",
123
+ "--add-opens=java.base/java.util=ALL-UNNAMED",
124
+ "--add-opens=java.base/java.io=ALL-UNNAMED",
125
+ "--add-opens=java.base/java.net=ALL-UNNAMED"
126
+ }
127
+ }
128
+ }
129
+
117
130
  packagingOptions {
118
131
  // Provided by the host application at runtime.
119
132
  excludes += [
@@ -128,4 +141,6 @@ android {
128
141
  dependencies {
129
142
  implementation "com.facebook.react:react-android"
130
143
  testImplementation "junit:junit:4.13.2"
144
+ testImplementation "org.mockito:mockito-core:5.14.2"
145
+ testImplementation "org.robolectric:robolectric:4.16"
131
146
  }
@@ -22,7 +22,6 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
22
22
  const NormalizedClip &clip,
23
23
  double contentTranslateXPx,
24
24
  double contentTranslateYPx) const;
25
- bool isViewAttachedToWindow() const;
26
25
  };
27
26
 
28
27
  // Called from Kotlin (SmoothClipViewManager) on the UI thread when a Fabric
@@ -33,7 +32,8 @@ void registerViewAndroid(
33
32
  Presentation initialPresentation,
34
33
  double density,
35
34
  double hostWidthPx,
36
- double hostHeightPx);
35
+ double hostHeightPx,
36
+ bool lifecycleVisible);
37
37
  // Refreshes a registered view's density / host size (px) and synchronously
38
38
  // redelivers the driver's visible value pre-normalized against them.
39
39
  void setViewHostGeometryAndroid(
@@ -45,13 +45,12 @@ void setViewHostGeometryAndroid(
45
45
  void unregisterViewAndroid(
46
46
  uint64_t driverId,
47
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(
48
+ // Pushes attachment/window visibility independently of host geometry. The
49
+ // registry combines both signals into its displayability/participant state.
50
+ void setViewLifecycleVisibilityAndroid(
53
51
  uint64_t driverId,
54
- facebook::jni::alias_ref<JSmoothClipView> view);
52
+ facebook::jni::alias_ref<JSmoothClipView> view,
53
+ bool lifecycleVisible);
55
54
 
56
55
  // Advances the registry frame loop. Called from Kotlin (SmoothClipBindings)
57
56
  // inside Choreographer#doFrame with the frame's vsync timestamp converted to
@@ -389,7 +389,8 @@ void nativeRegisterView(
389
389
  jdouble contentTranslateY,
390
390
  jdouble density,
391
391
  jdouble widthPx,
392
- jdouble heightPx) {
392
+ jdouble heightPx,
393
+ jboolean lifecycleVisible) {
393
394
  if (driverId <= 0 || !std::isfinite(driverId)) return;
394
395
  smoothclip::registerViewAndroid(
395
396
  static_cast<uint64_t>(driverId),
@@ -398,7 +399,8 @@ void nativeRegisterView(
398
399
  {x, y, width, height, radius}, contentTranslateX, contentTranslateY},
399
400
  density,
400
401
  widthPx,
401
- heightPx);
402
+ heightPx,
403
+ lifecycleVisible != 0);
402
404
  }
403
405
 
404
406
  void nativeSetViewHostGeometry(
@@ -421,13 +423,14 @@ void nativeUnregisterView(
421
423
  smoothclip::unregisterViewAndroid(static_cast<uint64_t>(driverId), view);
422
424
  }
423
425
 
424
- void nativeViewBecameDisplayable(
426
+ void nativeSetViewLifecycleVisibility(
425
427
  jni::alias_ref<jni::JObject>,
426
428
  jdouble driverId,
427
- jni::alias_ref<smoothclip::JSmoothClipView> view) {
429
+ jni::alias_ref<smoothclip::JSmoothClipView> view,
430
+ jboolean lifecycleVisible) {
428
431
  if (driverId <= 0 || !std::isfinite(driverId)) return;
429
- smoothclip::viewBecameDisplayableAndroid(
430
- static_cast<uint64_t>(driverId), view);
432
+ smoothclip::setViewLifecycleVisibilityAndroid(
433
+ static_cast<uint64_t>(driverId), view, lifecycleVisible != 0);
431
434
  }
432
435
 
433
436
  void nativeInvalidate(jni::alias_ref<jni::JObject>) {
@@ -451,7 +454,8 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
451
454
  "nativeSetViewHostGeometry", nativeSetViewHostGeometry),
452
455
  makeNativeMethod("nativeUnregisterView", nativeUnregisterView),
453
456
  makeNativeMethod(
454
- "nativeViewBecameDisplayable", nativeViewBecameDisplayable),
457
+ "nativeSetViewLifecycleVisibility",
458
+ nativeSetViewLifecycleVisibility),
455
459
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
456
460
  makeNativeMethod("nativeOnFrame", nativeOnFrame),
457
461
  });
@@ -26,6 +26,7 @@ using facebook::jni::global_ref;
26
26
 
27
27
  enum class Ownership { Interactive, Native };
28
28
  enum class AnimationKind { Timing, Spring, Keyframes };
29
+ enum class ViewParticipation { Deferred, Active, Suspended };
29
30
 
30
31
  // Load-bearing invariant, not just a convenience clock: advance()'s frame-clock
31
32
  // anchor compares this value ABSOLUTELY against a choreographer frame stamp
@@ -60,11 +61,6 @@ struct ActiveAnimation {
60
61
  KeyframeCurve keyframes;
61
62
  double durationS = 0;
62
63
  double startedAtS = 0;
63
- // Wall-clock twin of startedAtS: stamped together with it, NEVER moved by
64
- // the frame-clock anchor. The unregister re-latch remainder needs honest
65
- // wall elapsed and reads this; the animation curve itself paces on the
66
- // anchored startedAtS frame axis.
67
- double wallStartedAtS = 0;
68
64
  // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
69
65
  std::array<double, 7> springPosition{};
70
66
  std::array<double, 7> springVelocity{};
@@ -74,9 +70,8 @@ struct ActiveAnimation {
74
70
  // so it cannot tick. Two ways in: built before any host view could display
75
71
  // (pre-registration, or a detached/unsized host), or re-latched by
76
72
  // unregisterViewAndroid when the last DISPLAYABLE host left mid-flight.
77
- // Started by whichever of registerViewAndroid, setViewHostGeometryAndroid or
78
- // viewBecameDisplayableAndroid first sees a displayable entry; each rebases
79
- // the clock through startLatchedAnimation.
73
+ // Started by whichever lifecycle/geometry update first sees a displayable
74
+ // entry; each rebases the clock through startLatchedAnimation.
80
75
  bool started = false;
81
76
  // False until the first advance() translates the wall-clock start stamp
82
77
  // onto the choreographer frame-time axis (elapsed-preserving). Every
@@ -94,6 +89,8 @@ struct ViewEntry {
94
89
  double density = 0;
95
90
  double hostWidthPx = 0;
96
91
  double hostHeightPx = 0;
92
+ bool lifecycleVisible = false;
93
+ ViewParticipation participation = ViewParticipation::Deferred;
97
94
  };
98
95
 
99
96
  struct DriverState {
@@ -349,6 +346,9 @@ std::vector<uint64_t> &animatingDrivers() {
349
346
  // erase/recreate cycle would let one frame integrate the animation twice.
350
347
  void clearActiveAnimation(uint64_t driverId, DriverState &state) {
351
348
  state.animation.reset();
349
+ for (ViewEntry &entry : state.views) {
350
+ entry.participation = ViewParticipation::Deferred;
351
+ }
352
352
  auto &active = animatingDrivers();
353
353
  active.erase(
354
354
  std::remove(active.begin(), active.end(), driverId), active.end());
@@ -388,7 +388,7 @@ Presentation prepareAnimation(
388
388
  // no one can see (the transparentModal mount pattern).
389
389
  bool entryDisplayable(const ViewEntry &entry) {
390
390
  return entry.hostWidthPx > 0 && entry.hostHeightPx > 0 &&
391
- entry.view->isViewAttachedToWindow();
391
+ entry.lifecycleVisible;
392
392
  }
393
393
 
394
394
  bool anyDisplayableView(const DriverState &state) {
@@ -405,7 +405,6 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
405
405
  animation.started = true;
406
406
  animation.startedAtS = nowSeconds();
407
407
  animation.lastFrameS = animation.startedAtS;
408
- animation.wallStartedAtS = animation.startedAtS;
409
408
  // Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
410
409
  // ActiveAnimation in place, so a stale anchor would replay the fraction-0
411
410
  // first frame the anchor exists to remove.
@@ -414,6 +413,11 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
414
413
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
415
414
  active.push_back(driverId);
416
415
  }
416
+ for (ViewEntry &entry : state.views) {
417
+ if (entryDisplayable(entry)) {
418
+ entry.participation = ViewParticipation::Active;
419
+ }
420
+ }
417
421
  applyToViews(state, animation.start);
418
422
  scheduleFrame();
419
423
  }
@@ -435,10 +439,6 @@ int32_t startAnimation(
435
439
  const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
436
440
  animation.startedAtS = stamp.startedAtS;
437
441
  animation.lastFrameS = stamp.startedAtS;
438
- // Wall axis for the re-latch remainder keeps the native call stamp: the
439
- // hint is at most a frame older and the remainder math wants honest wall
440
- // elapsed from when the animation actually began integrating.
441
- animation.wallStartedAtS = wallNow;
442
442
  animation.frameClockAnchored = stamp.frameClockAnchored;
443
443
  // current = start even while latched is load-bearing: cancelAnimation,
444
444
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
@@ -447,6 +447,11 @@ int32_t startAnimation(
447
447
  animation.current = animation.start;
448
448
  animation.started = anyDisplayableView(state);
449
449
  state.animation = std::move(animation);
450
+ for (ViewEntry &entry : state.views) {
451
+ entry.participation = state.animation->started && entryDisplayable(entry)
452
+ ? ViewParticipation::Active
453
+ : ViewParticipation::Deferred;
454
+ }
450
455
  if (!state.animation->started) {
451
456
  // Latch: no host view can display yet (animateTo raced the mount, or
452
457
  // every host is detached/unsized). The first displayable registration,
@@ -464,6 +469,78 @@ int32_t startAnimation(
464
469
  return state.animation->id;
465
470
  }
466
471
 
472
+ void relatchIfNoDisplayable(uint64_t driverId, DriverState &state) {
473
+ if (!state.animation.has_value() || !state.animation->started ||
474
+ anyDisplayableView(state)) {
475
+ return;
476
+ }
477
+ auto &animation = *state.animation;
478
+ for (ViewEntry &entry : state.views) {
479
+ if (entry.participation == ViewParticipation::Active) {
480
+ entry.participation = ViewParticipation::Suspended;
481
+ }
482
+ }
483
+
484
+ if (animation.kind == AnimationKind::Timing) {
485
+ const TimingContinuation continuation = timingContinuationAtFrame(
486
+ animation.timing,
487
+ animation.start,
488
+ animation.target,
489
+ animation.lastFrameS,
490
+ animation.startedAtS,
491
+ animation.durationS);
492
+ animation.current = continuation.start;
493
+ animation.timing = continuation.animation;
494
+ animation.durationS = continuation.animation.durationMs / 1000.0;
495
+ } else if (animation.kind == AnimationKind::Keyframes) {
496
+ const KeyframeContinuation continuation = keyframeContinuationAtFrame(
497
+ animation.keyframes.frames(),
498
+ animation.current,
499
+ animation.target,
500
+ animation.lastFrameS,
501
+ animation.startedAtS,
502
+ animation.durationS);
503
+ animation.current = continuation.start;
504
+ animation.keyframes.reset(continuation.frames);
505
+ animation.durationS = continuation.durationMs / 1000.0;
506
+ }
507
+
508
+ if (state.destroyed ||
509
+ (animation.kind != AnimationKind::Spring && animation.durationS <= 0)) {
510
+ state.ownership = Ownership::Interactive;
511
+ finishActive(driverId, state, false);
512
+ return;
513
+ }
514
+
515
+ animation.start = animation.current;
516
+ animation.started = false;
517
+ animation.frameClockAnchored = false;
518
+ auto &active = animatingDrivers();
519
+ active.erase(
520
+ std::remove(active.begin(), active.end(), driverId), active.end());
521
+ }
522
+
523
+ void reconcileViewDisplayability(
524
+ uint64_t driverId,
525
+ DriverState &state,
526
+ ViewEntry &entry) {
527
+ if (!state.animation.has_value()) return;
528
+ if (!state.animation->started) {
529
+ if (entryDisplayable(entry)) startLatchedAnimation(driverId, state);
530
+ return;
531
+ }
532
+
533
+ if (entryDisplayable(entry)) {
534
+ if (entry.participation != ViewParticipation::Active) {
535
+ entry.participation = ViewParticipation::Active;
536
+ deliverToView(entry, state.animation->current);
537
+ }
538
+ } else if (entry.participation == ViewParticipation::Active) {
539
+ entry.participation = ViewParticipation::Suspended;
540
+ }
541
+ relatchIfNoDisplayable(driverId, state);
542
+ }
543
+
467
544
  void advance(uint64_t driverId, DriverState &state, double now) {
468
545
  ActiveAnimation &animation = *state.animation;
469
546
 
@@ -490,13 +567,15 @@ void advance(uint64_t driverId, DriverState &state, double now) {
490
567
  animation.startedAtS = anchorStartTime(animation.startedAtS, now);
491
568
  animation.lastFrameS = animation.startedAtS;
492
569
  animation.frameClockAnchored = true;
493
- // wallStartedAtS deliberately keeps the wall axis (re-latch remainder).
494
570
  }
495
571
 
496
572
  bool done = false;
497
573
  if (animation.kind == AnimationKind::Spring) {
498
574
  done = advanceSpring(animation, now);
499
575
  } else {
576
+ // This is the frame whose presentation is delivered below. Lifecycle
577
+ // re-latch must trim from this timestamp, never from a later wall sample.
578
+ animation.lastFrameS = now;
500
579
  const double fraction =
501
580
  timingFraction(now, animation.startedAtS, animation.durationS);
502
581
  if (animation.kind == AnimationKind::Keyframes) {
@@ -518,7 +597,13 @@ void advance(uint64_t driverId, DriverState &state, double now) {
518
597
 
519
598
  if (done) {
520
599
  const int32_t animationId = animation.id;
521
- const bool finished = animation.finished;
600
+ bool finished = animation.finished;
601
+ for (const ViewEntry &entry : state.views) {
602
+ if (entry.participation == ViewParticipation::Suspended) {
603
+ finished = false;
604
+ break;
605
+ }
606
+ }
522
607
  const Presentation target = animation.target;
523
608
  clearActiveAnimation(driverId, state);
524
609
  state.latest = target;
@@ -638,12 +723,8 @@ Presentation beginInteraction(uint64_t driverId) {
638
723
  state.latest = current;
639
724
  state.hasLatest = true;
640
725
  state.ownership = Ownership::Interactive;
641
- // Plain record, not a reset — iOS parity (smoothClipFreezePresentation
642
- // records the frozen value). Pairing the frozen mid-flight presentation
643
- // with the last real sample lets a grab-and-instant-refling inherit
644
- // bounded recent motion instead of launching dead; an unchanged value
645
- // dedupes to a no-op and ages out through the staleness guard.
646
- recordVelocitySample(state.samples, toChannels(current), nowSeconds());
726
+ // The frozen frame came from native animation movement, not an interactive
727
+ // write. Do not let it become a later `initialVelocity: inherit` sample.
647
728
  applyToViews(state, current);
648
729
  emitCompletion(driverId, animationId, false);
649
730
  return current;
@@ -809,6 +890,11 @@ void destroyDriver(uint64_t driverId) {
809
890
  auto &state = iterator->second;
810
891
  state.ownership = Ownership::Interactive;
811
892
  finishActive(driverId, state, false);
893
+ // A destroyed driver's interaction history must not seed a revived
894
+ // incarnation: the revival seed would otherwise pair with these samples and
895
+ // refresh the staleness clock with motion no finger produced. (iOS clears
896
+ // its per-view histories at the same point.)
897
+ state.samples = VelocitySampleHistory{};
812
898
  if (state.views.empty()) {
813
899
  registry().erase(iterator);
814
900
  } else {
@@ -822,12 +908,6 @@ void destroyDriver(uint64_t driverId) {
822
908
 
823
909
  // --- Android view lifecycle ----------------------------------------------
824
910
 
825
- bool JSmoothClipView::isViewAttachedToWindow() const {
826
- static const auto method =
827
- javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
828
- return method(self());
829
- }
830
-
831
911
  void JSmoothClipView::applyClip(const Presentation &presentation) const {
832
912
  static const auto method =
833
913
  javaClassStatic()
@@ -871,7 +951,8 @@ void registerViewAndroid(
871
951
  Presentation initialPresentation,
872
952
  double density,
873
953
  double hostWidthPx,
874
- double hostHeightPx) {
954
+ double hostHeightPx,
955
+ bool lifecycleVisible) {
875
956
  auto &state = registry()[driverId];
876
957
  state.destroyed = false;
877
958
  if (!state.hasLatest) {
@@ -887,32 +968,24 @@ void registerViewAndroid(
887
968
  existing.density = density;
888
969
  existing.hostWidthPx = hostWidthPx;
889
970
  existing.hostHeightPx = hostHeightPx;
971
+ existing.lifecycleVisible = lifecycleVisible;
890
972
  deliverToView(existing, visible);
891
- if (state.animation.has_value() && !state.animation->started &&
892
- entryDisplayable(existing)) {
893
- startLatchedAnimation(driverId, state);
894
- }
973
+ reconcileViewDisplayability(driverId, state, existing);
895
974
  return;
896
975
  }
897
976
  }
898
977
  ViewEntry entry{
899
- facebook::jni::make_global(view), density, hostWidthPx, hostHeightPx};
978
+ facebook::jni::make_global(view),
979
+ density,
980
+ hostWidthPx,
981
+ hostHeightPx,
982
+ lifecycleVisible,
983
+ ViewParticipation::Deferred};
900
984
  // `visible` above already delivered animation->current (= start) to the
901
985
  // registering view — the correct first frame for a latched animation.
902
986
  deliverToView(entry, visible);
903
987
  state.views.push_back(std::move(entry));
904
- if (state.animation.has_value() && !state.animation->started &&
905
- entryDisplayable(state.views.back())) {
906
- // Start the latched animation: rebase the clock so the first frame
907
- // integrates from now, not from the pre-mount animateTo call. Gated on
908
- // displayability — a mount-time registration from a detached subtree
909
- // holds the latch until window attach / first host geometry.
910
- // Note this function has no isOnMainThread() guard (Kotlin calls it from
911
- // the view's main-thread attach path); scheduleFrame() is itself
912
- // main-thread-gated, so an off-main register cannot bind the
913
- // choreographer to the wrong thread.
914
- startLatchedAnimation(driverId, state);
915
- }
988
+ reconcileViewDisplayability(driverId, state, state.views.back());
916
989
  }
917
990
 
918
991
  void setViewHostGeometryAndroid(
@@ -937,29 +1010,24 @@ void setViewHostGeometryAndroid(
937
1010
  state.animation.has_value() ? state.animation->current
938
1011
  : state.latest);
939
1012
  }
940
- if (state.animation.has_value() && !state.animation->started &&
941
- entryDisplayable(entry)) {
942
- // First real host geometry made this view displayable.
943
- startLatchedAnimation(driverId, state);
944
- }
1013
+ reconcileViewDisplayability(driverId, state, entry);
945
1014
  return;
946
1015
  }
947
1016
  }
948
1017
 
949
- void viewBecameDisplayableAndroid(
1018
+ void setViewLifecycleVisibilityAndroid(
950
1019
  uint64_t driverId,
951
- alias_ref<JSmoothClipView> view) {
1020
+ alias_ref<JSmoothClipView> view,
1021
+ bool lifecycleVisible) {
952
1022
  if (!isOnMainThread()) return;
953
1023
  auto iterator = registry().find(driverId);
954
1024
  if (iterator == registry().end()) return;
955
1025
  auto &state = iterator->second;
956
- if (!state.animation.has_value() || state.animation->started) return;
957
1026
  JNIEnv *env = facebook::jni::Environment::current();
958
1027
  for (auto &entry : state.views) {
959
1028
  if (!env->IsSameObject(entry.view.get(), view.get())) continue;
960
- if (entryDisplayable(entry)) {
961
- startLatchedAnimation(driverId, state);
962
- }
1029
+ entry.lifecycleVisible = lifecycleVisible;
1030
+ reconcileViewDisplayability(driverId, state, entry);
963
1031
  return;
964
1032
  }
965
1033
  }
@@ -971,9 +1039,12 @@ void unregisterViewAndroid(
971
1039
  if (iterator == registry().end()) return;
972
1040
  auto &state = iterator->second;
973
1041
  bool removed = false;
1042
+ bool removedParticipant = false;
974
1043
  JNIEnv *env = facebook::jni::Environment::current();
975
1044
  for (auto view_it = state.views.begin(); view_it != state.views.end();) {
976
1045
  if (env->IsSameObject(view_it->view.get(), view.get())) {
1046
+ removedParticipant = removedParticipant ||
1047
+ view_it->participation != ViewParticipation::Deferred;
977
1048
  view_it = state.views.erase(view_it);
978
1049
  removed = true;
979
1050
  } else {
@@ -981,93 +1052,24 @@ void unregisterViewAndroid(
981
1052
  }
982
1053
  }
983
1054
  if (removed && state.animation.has_value()) {
984
- // Participant-gated like iOS: only a started run has rendered frames a
985
- // departing view can leave unfinished. A held latch has installed nothing,
986
- // so a view leaving while it is held must not poison the eventual natural
987
- // completion with finished:false.
988
- if (state.animation->started) state.animation->finished = false;
1055
+ // Only a host that actually joined (or is unresolved after suspension)
1056
+ // affects completion. A detached/unsized registration that stayed deferred
1057
+ // never installed or rendered this animation.
1058
+ if (removedParticipant) state.animation->finished = false;
989
1059
  if (!isOnMainThread()) {
990
1060
  // Blast-radius reduction, NOT a synchronization fix — be clear about
991
1061
  // which. Both call sites (SmoothClipViewManager.onAfterUpdateTransaction
992
1062
  // and onDropViewInstance) are Fabric mount operations, so this branch is
993
- // unreachable in supported usage; the erase above and the started-gated
1063
+ // unreachable in supported usage; the erase above and participant-based
994
1064
  // `finished` write are still unsynchronized if that ever stops holding. The erase
995
1065
  // cannot be skipped — dropping it would leak this view's global_ref for
996
1066
  // the process lifetime — but everything below can be, and should be: it
997
- // crosses JNI to read view attachment (anyDisplayableView) and mutates
998
- // animatingDrivers(), the vector the choreographer callback walks on the
999
- // main thread. Record the unfinished flag (started runs only) and leave
1000
- // the animation for the next main-thread event (attach, host geometry,
1001
- // destroy) to resolve.
1002
- } else if (!state.animation->started) {
1003
- // A held latch stays pending; the next displayable registration or
1004
- // attach starts it (or destroyDriver cancels it).
1005
- } else if (!anyDisplayableView(state)) {
1006
- auto &animation = *state.animation;
1007
- // wallStartedAtS, not startedAtS: post-anchor the latter lives on the
1008
- // shifted frame axis and would overstate elapsed by the first
1009
- // callback's dispatch latency, shortening the resumed remainder and
1010
- // advancing the keyframe rebase past the rendered position.
1011
- const double elapsedS = nowSeconds() - animation.wallStartedAtS;
1012
- const double remainingS = std::max(0.0, animation.durationS - elapsedS);
1013
- const bool canResume = !state.destroyed &&
1014
- (animation.kind == AnimationKind::Spring || remainingS > 0);
1015
- if (!canResume) {
1016
- // The animation ends here; release ownership so a later remount's
1017
- // interactive updates are not dropped.
1018
- state.ownership = Ownership::Interactive;
1019
- finishActive(driverId, state, false);
1020
- } else {
1021
- // The last displayable host left mid-flight. Registered detached or
1022
- // unsized peers do not keep the clock running invisibly.
1023
- // Completing here would leave `latest` at the target and statically
1024
- // snap any re-registering host straight to it. Re-latch instead:
1025
- // freeze the remaining animation at its current geometry so the
1026
- // next displayable host resumes it with the true remaining time.
1027
- // destroyDriver cancels a latch that never finds a host, so the
1028
- // completion cannot hang.
1029
- if (animation.kind == AnimationKind::Timing) {
1030
- const double progress = animation.durationS <= 0
1031
- ? 1.0
1032
- : clamp01(elapsedS / animation.durationS);
1033
- const TimingRemainder remainder =
1034
- timingRemainder(animation.timing, progress);
1035
- // Keep the frozen start, cutoff, and shortened curve on one exact
1036
- // phase. Reusing the old easing here would restart its slope.
1037
- animation.current = interpolate(
1038
- animation.start, animation.target, remainder.easedProgress);
1039
- animation.timing = remainder.animation;
1040
- animation.durationS = animation.timing.durationMs / 1000.0;
1041
- } else if (animation.kind == AnimationKind::Keyframes) {
1042
- const double progress = animation.durationS <= 0
1043
- ? 1.0
1044
- : clamp01(elapsedS / animation.durationS);
1045
- std::vector<Keyframe> remaining;
1046
- remaining.push_back({0, animation.current});
1047
- for (const Keyframe &frame : animation.keyframes.frames()) {
1048
- if (frame.offset <= progress) continue;
1049
- remaining.push_back(
1050
- {(frame.offset - progress) / (1 - progress),
1051
- frame.presentation});
1052
- }
1053
- if (remaining.size() == 1) {
1054
- remaining.push_back({1, animation.target});
1055
- }
1056
- // reset() re-solves the tangents for the pruned curve and rewinds
1057
- // the segment cursor; the resumed run must not inherit either.
1058
- animation.keyframes.reset(std::move(remaining));
1059
- animation.durationS = remainingS;
1060
- }
1061
- animation.start = animation.current;
1062
- animation.started = false;
1063
- // A held latch must not tick: leave animatingDrivers() without
1064
- // resetting the animation (the eager-removal rule the choreographer
1065
- // loop depends on).
1066
- auto &active = animatingDrivers();
1067
- active.erase(
1068
- std::remove(active.begin(), active.end(), driverId),
1069
- active.end());
1070
- }
1067
+ // mutates animatingDrivers(), the vector the choreographer callback walks
1068
+ // on the main thread. Record the unfinished flag and leave the animation
1069
+ // for the next main-thread event (attach, host geometry, destroy) to
1070
+ // resolve.
1071
+ } else {
1072
+ relatchIfNoDisplayable(driverId, state);
1071
1073
  }
1072
1074
  }
1073
1075
  if (state.destroyed && state.views.empty()) {