react-native-smooth-clip-view 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +49 -21
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/src/main/cpp/SmoothClipAndroid.h +5 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +28 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +192 -191
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +20 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +105 -34
  10. package/cpp/SmoothClipAnimationCurve.h +468 -0
  11. package/cpp/SmoothClipAnimationId.h +32 -0
  12. package/cpp/SmoothClipRegistry.h +11 -1
  13. package/cpp/SmoothClipVelocityTracker.h +27 -5
  14. package/ios/SmoothClipRegistry.mm +246 -97
  15. package/ios/SmoothClipTurboModule.cpp +4 -2
  16. package/ios/SmoothClipTurboModule.h +2 -1
  17. package/ios/SmoothClipView.mm +73 -16
  18. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  19. package/lib/module/drivers.native.js +98 -29
  20. package/lib/module/drivers.native.js.map +1 -1
  21. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  22. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  23. package/lib/typescript/src/driverTypes.d.ts +3 -2
  24. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  25. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/NativeSmoothClipModule.ts +2 -1
  28. package/src/driverTypes.ts +3 -2
  29. package/src/drivers.native.ts +132 -25
@@ -1,7 +1,8 @@
1
1
  #include "SmoothClipAndroid.h"
2
+ #include "SmoothClipAnimationId.h"
3
+ #include "SmoothClipAnimationCurve.h"
2
4
  #include "SmoothClipVelocityTracker.h"
3
5
 
4
- #include <android/choreographer.h>
5
6
  #include <fbjni/fbjni.h>
6
7
 
7
8
  #include <algorithm>
@@ -26,6 +27,20 @@ using facebook::jni::global_ref;
26
27
  enum class Ownership { Interactive, Native };
27
28
  enum class AnimationKind { Timing, Spring, Keyframes };
28
29
 
30
+ // Load-bearing invariant, not just a convenience clock: advance()'s frame-clock
31
+ // anchor compares this value ABSOLUTELY against a choreographer frame stamp
32
+ // (std::min), so the two must share an epoch. They do — bionic's steady_clock,
33
+ // System.nanoTime() behind Choreographer#frameTimeNanos, and the
34
+ // SystemClock.uptimeMillis() Reanimated stamps its own animations with are all
35
+ // CLOCK_MONOTONIC since boot. (The anchor this replaced subtracted two samples
36
+ // of this clock and was epoch-independent; the min() is not.)
37
+ //
38
+ // Breaking that invariant fails silently and asymmetrically. A frame axis
39
+ // running BEHIND the wall axis degrades quietly: min() always adopts the frame
40
+ // stamp and animations lose only their sub-frame start offset. A frame axis
41
+ // running AHEAD is fatal: min() always keeps startedAtS, the first fraction
42
+ // (frame - startedAtS)/duration clamps to 1, and every animation completes on
43
+ // its first frame.
29
44
  double nowSeconds() {
30
45
  return std::chrono::duration<double>(
31
46
  std::chrono::steady_clock::now().time_since_epoch())
@@ -40,26 +55,34 @@ struct ActiveAnimation {
40
55
  Presentation current{{0, 0, 0, 0, 0}, 0, 0};
41
56
  TimingAnimation timing{};
42
57
  SpringAnimation spring{};
43
- std::vector<Keyframe> keyframes;
58
+ // Owns the keyframes and their precomputed monotone-cubic tangents; the
59
+ // tangent solve happens on reset(), never per frame.
60
+ KeyframeCurve keyframes;
44
61
  double durationS = 0;
45
62
  double startedAtS = 0;
46
- // Wall-clock twin of startedAtS: stamped together with it, NEVER rebased
47
- // by the frame-clock anchor. Readers that need honest wall elapsed (the
48
- // unregister re-latch remainder, the run-ahead guard) use this; the
49
- // animation curve itself paces on the anchored startedAtS frame axis.
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.
50
67
  double wallStartedAtS = 0;
51
68
  // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
52
69
  std::array<double, 7> springPosition{};
53
70
  std::array<double, 7> springVelocity{};
54
71
  double lastFrameS = 0;
55
72
  bool finished = true;
56
- // False while the animation is latched: built before any host view
57
- // registered, held un-rendered (and out of animatingDrivers()) until the
58
- // first registerViewAndroid rebases the clock and starts it.
73
+ // False while the animation is latched, and held out of animatingDrivers()
74
+ // so it cannot tick. Two ways in: built before any host view could display
75
+ // (pre-registration, or a detached/unsized host), or re-latched by
76
+ // 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.
59
80
  bool started = false;
60
81
  // False until the first advance() translates the wall-clock start stamp
61
82
  // onto the choreographer frame-time axis (elapsed-preserving). Every
62
- // startedAtS stamp clears it so a re-latched resume re-anchors too.
83
+ // startedAtS stamp clears it so a re-latched resume re-anchors too — except
84
+ // a start that carried the JS-captured Reanimated stamp, which arrives
85
+ // pre-anchored (see startAnimation) and must not be min()'d again.
63
86
  bool frameClockAnchored = false;
64
87
  };
65
88
 
@@ -79,7 +102,6 @@ struct DriverState {
79
102
  Ownership ownership = Ownership::Interactive;
80
103
  std::vector<ViewEntry> views;
81
104
  std::optional<ActiveAnimation> animation;
82
- int32_t nextAnimationId = 0;
83
105
  // Set by destroyDriver while views are still registered (StrictMode effect
84
106
  // replay, hosts mounted in another subtree). The entry is erased when the
85
107
  // last view leaves and revived by a take-ownership setPresentation.
@@ -131,7 +153,7 @@ struct JLooper : facebook::jni::JavaClass<JLooper> {
131
153
  std::atomic<pid_t> gMainThreadId{0};
132
154
 
133
155
  // The registry, the frame loop, and every view mutation are main-thread only:
134
- // AChoreographer_getInstance is per-thread, and binding the loop to the JS
156
+ // Choreographer.getInstance() is per-thread, and binding the loop to the JS
135
157
  // thread would race Fabric mounting and mutate views off-main. The worklets
136
158
  // UI runtime executes on the main thread, so supported usage always passes;
137
159
  // direct off-main calls fail defined instead of racing.
@@ -155,72 +177,47 @@ bool isOnMainThread() {
155
177
 
156
178
  // --- Frame loop -----------------------------------------------------------
157
179
 
180
+ // fbjni handle for the Kotlin frame-loop bridge. The loop posts through the
181
+ // Java Choreographer (CALLBACK_ANIMATION) rather than the NDK AChoreographer:
182
+ // the NDK instance is a separate frame source on the main Looper whose
183
+ // ordering against the Java Choreographer's traversal (draw) phase is
184
+ // undefined, so advances landing after the traversal presented one vsync
185
+ // late — a randomly flipping ±1-frame phase between the clip and Reanimated
186
+ // content. Posting into the same doFrame pass as Reanimated makes every
187
+ // advance precede the draw by construction. It also retires the 32-bit
188
+ // truncated-timestamp fallback: Java frameTimeNanos is a jlong everywhere.
189
+ //
190
+ // doCallbacks() extracts by wall-now rather than by frame time, so a post
191
+ // issued from an earlier phase of a frame already in flight (CALLBACK_INPUT —
192
+ // a gesture end that starts an animation) runs in that same doFrame instead
193
+ // of the next one. Harmless (the fraction is ~0 and the write dedupes in
194
+ // Kotlin) and one frame cheaper at the handoff than the NDK path allowed.
195
+ struct JSmoothClipBindings : facebook::jni::JavaClass<JSmoothClipBindings> {
196
+ static constexpr auto kJavaDescriptor =
197
+ "Lcom/smoothclipview/SmoothClipBindings;";
198
+ };
199
+
158
200
  bool gFrameScheduled = false;
159
201
  void onFrameImpl(double now);
160
- void onFrameLegacy(long frameTimeNanos, void *data);
161
202
 
162
203
  void scheduleFrame() {
163
204
  if (gFrameScheduled) return;
164
205
  // Defensive: never bind the per-thread choreographer to a non-main thread.
165
206
  if (!isOnMainThread()) return;
166
- AChoreographer *choreographer = AChoreographer_getInstance();
167
- if (choreographer == nullptr) return;
207
+ static const auto scheduleMethod =
208
+ JSmoothClipBindings::javaClassStatic()->getStaticMethod<void()>(
209
+ "scheduleFrame");
210
+ scheduleMethod(JSmoothClipBindings::javaClassStatic());
211
+ // Set only after a successful post: if the up-call ever threw with the flag
212
+ // already true, the loop would be dead until process restart.
168
213
  gFrameScheduled = true;
169
- // AChoreographer_postFrameCallback64 (API 29+) only matters on 32-bit ABIs,
170
- // where `long` truncates the timestamp; onFrameLegacy falls back to the
171
- // clock there, so the legacy entry point covers every case.
172
- #pragma clang diagnostic push
173
- #pragma clang diagnostic ignored "-Wdeprecated-declarations"
174
- AChoreographer_postFrameCallback(choreographer, &onFrameLegacy, nullptr);
175
- #pragma clang diagnostic pop
176
214
  }
177
215
 
178
216
  // --- Math (ported from SmoothClipRegistry.kt so both platforms match) -----
179
-
180
- double clamp01(double value) { return std::min(1.0, std::max(0.0, value)); }
181
-
182
- double cubicBezier(
183
- double x1,
184
- double y1,
185
- double x2,
186
- double y2,
187
- double input) {
188
- double low = 0;
189
- double high = 1;
190
- for (int iteration = 0; iteration < 14; iteration += 1) {
191
- const double t = (low + high) / 2;
192
- const double inverse = 1 - t;
193
- const double x = 3 * inverse * inverse * t * x1 +
194
- 3 * inverse * t * t * x2 + t * t * t;
195
- if (x < input) {
196
- low = t;
197
- } else {
198
- high = t;
199
- }
200
- }
201
- const double t = (low + high) / 2;
202
- const double inverse = 1 - t;
203
- return 3 * inverse * inverse * t * y1 + 3 * inverse * t * t * y2 +
204
- t * t * t;
205
- }
206
-
207
- // Presentation <-> per-channel array (x, y, width, height, radius, tx, ty).
208
- std::array<double, 7> toChannels(const Presentation &presentation) {
209
- return {presentation.clip.x,
210
- presentation.clip.y,
211
- presentation.clip.width,
212
- presentation.clip.height,
213
- presentation.clip.radius,
214
- presentation.contentTranslateX,
215
- presentation.contentTranslateY};
216
- }
217
-
218
- Presentation fromChannels(const std::array<double, 7> &channels) {
219
- return Presentation{
220
- {channels[0], channels[1], channels[2], channels[3], channels[4]},
221
- channels[5],
222
- channels[6]};
223
- }
217
+ //
218
+ // clamp01, toChannels/fromChannels, interpolate, the frame-clock anchor and the
219
+ // keyframe curve now live in cpp/SmoothClipAnimationCurve.h so a test binary can
220
+ // reach them without linking fbjni; ios/tests pins their behavior.
224
221
 
225
222
  // Springs settle below these per-channel thresholds (DIP and DIP/s).
226
223
  constexpr double kSpringSettleDisplacement = 0.05;
@@ -228,13 +225,6 @@ constexpr double kSpringSettleVelocity = 1.0;
228
225
  // Settle-based termination is backed by a hard cap so a pathological
229
226
  // configuration cannot run the frame loop forever.
230
227
  constexpr double kSpringMaxDurationS = 10.0;
231
- // Fixed slack for the post-stall run-ahead guard in advance(). The lead it
232
- // bounds is a latency DIFFERENTIAL — the anchor frame's dispatch latency
233
- // minus the current frame's — so the constant is refresh-rate independent
234
- // (it is not one vsync period; at 120 Hz it spans two). 17 ms keeps the
235
- // guard silent through normal callback jitter while capping post-stall
236
- // run-ahead at one 60 Hz frame of curve time.
237
- constexpr double kMaxFrameLeadS = 0.017;
238
228
 
239
229
  // Semi-implicit Euler per channel, substepped: one frame-sized step is only
240
230
  // stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
@@ -285,37 +275,6 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
285
275
  return settled || now - animation.startedAtS >= kSpringMaxDurationS;
286
276
  }
287
277
 
288
- Presentation interpolate(
289
- const Presentation &from,
290
- const Presentation &to,
291
- double progress) {
292
- const auto mix = [progress](double start, double end) {
293
- return start + (end - start) * progress;
294
- };
295
- return Presentation{
296
- {mix(from.clip.x, to.clip.x),
297
- mix(from.clip.y, to.clip.y),
298
- mix(from.clip.width, to.clip.width),
299
- mix(from.clip.height, to.clip.height),
300
- mix(from.clip.radius, to.clip.radius)},
301
- mix(from.contentTranslateX, to.contentTranslateX),
302
- mix(from.contentTranslateY, to.contentTranslateY)};
303
- }
304
-
305
- Presentation interpolateKeyframes(
306
- const std::vector<Keyframe> &frames,
307
- double progress) {
308
- size_t upper = 1;
309
- while (upper < frames.size() - 1 && progress > frames[upper].offset) {
310
- upper += 1;
311
- }
312
- const Keyframe &lower = frames[upper - 1];
313
- const Keyframe &higher = frames[upper];
314
- const double span = higher.offset - lower.offset;
315
- const double local = span <= 0 ? 1.0 : (progress - lower.offset) / span;
316
- return interpolate(lower.presentation, higher.presentation, clamp01(local));
317
- }
318
-
319
278
  bool systemAnimatorsEnabled() {
320
279
  static const auto valueAnimatorClass =
321
280
  facebook::jni::findClassStatic("android/animation/ValueAnimator");
@@ -339,14 +298,6 @@ void emitCompletion(uint64_t driverId, int32_t animationId, bool finished) {
339
298
  }
340
299
  }
341
300
 
342
- int32_t allocateAnimationId(DriverState &state) {
343
- state.nextAnimationId =
344
- state.nextAnimationId == std::numeric_limits<int32_t>::max()
345
- ? 1
346
- : state.nextAnimationId + 1;
347
- return state.nextAnimationId;
348
- }
349
-
350
301
  // Terminal per-frame delivery: scale DIP -> px and normalize against the
351
302
  // pushed host metrics in C++, so the JNI call carries final pixel floats and
352
303
  // the Kotlin side reduces to field stores + invalidateOutline().
@@ -470,11 +421,25 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
470
421
  int32_t startAnimation(
471
422
  uint64_t driverId,
472
423
  DriverState &state,
473
- ActiveAnimation animation) {
474
- animation.startedAtS = nowSeconds();
475
- animation.lastFrameS = animation.startedAtS;
476
- animation.wallStartedAtS = animation.startedAtS;
477
- animation.frameClockAnchored = false;
424
+ ActiveAnimation animation,
425
+ double startedAtHintS) {
426
+ // With a JS-captured hint the start stamp is Reanimated's own
427
+ // (`__frameTimestamp || _getAnimationTimestamp()`, read in the worklet that
428
+ // issued this call), already on the axis min() only approximates — so the
429
+ // animation arrives pre-anchored and advance() must not min() it against
430
+ // the dispatching frame, which for a CALLBACK_INPUT start is EARLIER than
431
+ // the call and would re-open the intra-frame lead the hint closes. Without
432
+ // a hint (NaN: latch-less legacy callers, tests, iOS ignoring the field)
433
+ // this reduces exactly to the old nowSeconds() + min() anchor path.
434
+ const double wallNow = nowSeconds();
435
+ const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
436
+ animation.startedAtS = stamp.startedAtS;
437
+ 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
+ animation.frameClockAnchored = stamp.frameClockAnchored;
478
443
  // current = start even while latched is load-bearing: cancelAnimation,
479
444
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
480
445
  // visible all read animation->current, giving a never-rendered latch
@@ -503,35 +468,29 @@ void advance(uint64_t driverId, DriverState &state, double now) {
503
468
  ActiveAnimation &animation = *state.animation;
504
469
 
505
470
  if (!animation.frameClockAnchored) {
506
- // startedAtS/lastFrameS hold nowSeconds() sampled mid-frame at the
507
- // animateTo call / latch attach, but `now` is the frame's vsync
508
- // timestamp — the same CLOCK_MONOTONIC timebase at an earlier sampling
509
- // point. Un-anchored, the first fraction clamps to 0 and re-renders the
510
- // start (1-2 duplicated frames at every interactive→animateTo handoff).
511
- // Re-anchor onto the frame-time axis keeping the real elapsed time, so
512
- // the first rendered frame advances honestly and later frames pace on
513
- // vsync. On the 32-bit path `now` is itself nowSeconds() → ~identity.
514
- const double wallElapsedS =
515
- std::max(0.0, nowSeconds() - animation.startedAtS);
516
- animation.startedAtS = now - wallElapsedS;
471
+ // This is the stamp-less/latch fallback. Worklet-issued animations carry
472
+ // Reanimated's exact `__frameTimestamp || _getAnimationTimestamp()` value
473
+ // and arrive pre-anchored, so they skip this approximation — including the
474
+ // CALLBACK_INPUT case where the current frame stamp predates the call.
475
+ // Here startedAtS/lastFrameS hold nowSeconds() sampled at native latch
476
+ // attach or by an older caller, while `now` is the frame's vsync stamp on
477
+ // the same CLOCK_MONOTONIC timebase. Taking the earlier stamp prevents a
478
+ // mid-frame native start from duplicating fraction zero.
479
+ //
480
+ // Deliberately NOT a wall-elapsed rebase (`now - (nowSeconds() -
481
+ // startedAtS)`): that bakes THIS frame's dispatch latency into the curve
482
+ // for the animation's whole duration, de-phasing the clip from parallel
483
+ // Reanimated content by however much main-thread work happened to run
484
+ // before us on frame one — worst on the first frame of a heavy
485
+ // transition, which is exactly when it is sampled. Catching up after a
486
+ // stall needs no help here either: Choreographer#doFrame snaps a late
487
+ // frameTimeNanos forward to within one frame interval of now (jitter
488
+ // correction), for every CALLBACK_ANIMATION client at once, so the clip
489
+ // and the content stall and catch up together.
490
+ animation.startedAtS = anchorStartTime(animation.startedAtS, now);
517
491
  animation.lastFrameS = animation.startedAtS;
518
492
  animation.frameClockAnchored = true;
519
- // wallStartedAtS deliberately keeps the wall axis through this rebase.
520
- }
521
-
522
- // Run-ahead guard: the anchor's one-time shift bakes the first callback's
523
- // dispatch latency into the frame axis. If a stalled handoff anchored
524
- // against a stale frame stamp and later stamps catch up to real time, the
525
- // frame axis leads wall time by up to the stall length and the animation
526
- // completes early. Re-anchor forward when the lead exceeds one vsync of
527
- // slack; on calm frames the condition (first callback's latency exceeding
528
- // this frame's by > 17 ms) cannot hold, so the hot path is unchanged, and
529
- // the 32-bit path (frame axis == wall axis) never fires it. lastFrameS is
530
- // deliberately untouched: springs pace on dt, already clamped to 64 ms.
531
- const double wallElapsedNowS =
532
- std::max(0.0, nowSeconds() - animation.wallStartedAtS);
533
- if (now - animation.startedAtS > wallElapsedNowS + kMaxFrameLeadS) {
534
- animation.startedAtS = now - wallElapsedNowS;
493
+ // wallStartedAtS deliberately keeps the wall axis (re-latch remainder).
535
494
  }
536
495
 
537
496
  bool done = false;
@@ -539,11 +498,9 @@ void advance(uint64_t driverId, DriverState &state, double now) {
539
498
  done = advanceSpring(animation, now);
540
499
  } else {
541
500
  const double fraction =
542
- animation.durationS <= 0
543
- ? 1.0
544
- : clamp01((now - animation.startedAtS) / animation.durationS);
501
+ timingFraction(now, animation.startedAtS, animation.durationS);
545
502
  if (animation.kind == AnimationKind::Keyframes) {
546
- animation.current = interpolateKeyframes(animation.keyframes, fraction);
503
+ animation.current = animation.keyframes.evaluate(fraction);
547
504
  } else {
548
505
  const double eased = cubicBezier(
549
506
  animation.timing.controlPoint1X,
@@ -593,26 +550,27 @@ void onFrameImpl(double now) {
593
550
  if (!animatingDrivers().empty()) scheduleFrame();
594
551
  }
595
552
 
596
- void onFrameLegacy(long frameTimeNanos, void * /*data*/) {
597
- // An fbjni exception must not unwind out of a C callback (std::terminate).
598
- // Clear it, keep the loop scheduled, and let the next frame re-scan.
553
+ } // namespace
554
+
555
+ // Advances the frame loop; reached from SmoothClipBindings.nativeOnFrame
556
+ // inside Choreographer#doFrame. Nothing may unwind back into doFrame — fbjni
557
+ // would rethrow it as a Java exception and take down the main thread. Clear
558
+ // any pending JNI exception, keep the loop scheduled, and let the next frame
559
+ // re-scan.
560
+ void onFrameAndroid(double frameTimeS) {
599
561
  try {
600
- #if defined(__LP64__) || (defined(__SIZEOF_POINTER__) && __SIZEOF_POINTER__ == 8)
601
- onFrameImpl(static_cast<double>(frameTimeNanos) / 1e9);
602
- #else
603
- // 32-bit `long` truncates the nanosecond timestamp; fall back to the
604
- // clock.
605
- onFrameImpl(nowSeconds());
606
- #endif
562
+ onFrameImpl(frameTimeS);
607
563
  } catch (...) {
608
564
  JNIEnv *env = facebook::jni::Environment::current();
609
565
  if (env != nullptr && env->ExceptionCheck()) env->ExceptionClear();
610
- scheduleFrame();
566
+ try {
567
+ scheduleFrame();
568
+ } catch (...) {
569
+ // gFrameScheduled stays false; the next animation start re-schedules.
570
+ }
611
571
  }
612
572
  }
613
573
 
614
- } // namespace
615
-
616
574
  // --- Public smoothclip:: interface (shared with iOS via SmoothClipRegistry.h)
617
575
 
618
576
  void setCompletionCallback(const void *owner, CompletionCallback callback) {
@@ -631,7 +589,8 @@ void clearCompletionCallback(const void *owner) {
631
589
  void setPresentation(
632
590
  uint64_t driverId,
633
591
  Presentation presentation,
634
- bool takeOwnership) {
592
+ bool takeOwnership,
593
+ bool overridePendingAnimation) {
635
594
  if (!isOnMainThread()) return;
636
595
  auto iterator = registry().find(driverId);
637
596
  if (!takeOwnership) {
@@ -647,13 +606,11 @@ void setPresentation(
647
606
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
648
607
  if (takeOwnership) {
649
608
  state.destroyed = false;
650
- // A held latch is strictly newer intent than this write: the latch was
651
- // created after whatever value the caller read (the hook's seed replays
652
- // a SharedValue that an earlier animateTo already advanced to its
653
- // target). Cancelling it here would seed the target and turn the pending
654
- // animation into a static jump. Callers that want to override a latch
655
- // cancel it explicitly first.
656
- if (state.animation.has_value() && !state.animation->started) {
609
+ // Passive seeds and public setters must not displace newer pending intent.
610
+ // The fused animation.from write is different: it is the caller's newest,
611
+ // authoritative start and explicitly opts in to cancelling the old latch.
612
+ if (state.animation.has_value() && !state.animation->started &&
613
+ !overridePendingAnimation) {
657
614
  return;
658
615
  }
659
616
  finishActive(driverId, state, false);
@@ -699,10 +656,15 @@ int32_t animateTiming(
699
656
  TimingAnimation animation) {
700
657
  if (!isOnMainThread()) return 0;
701
658
  auto iterator = registry().find(driverId);
702
- if (iterator == registry().end()) return 0;
659
+ if (iterator == registry().end()) {
660
+ if (!start.hasInteractiveStart) return 0;
661
+ iterator = registry().try_emplace(driverId).first;
662
+ iterator->second.latest = start.interactiveStart;
663
+ iterator->second.hasLatest = true;
664
+ }
703
665
  auto &state = iterator->second;
704
666
  state.destroyed = false;
705
- const int32_t animationId = allocateAnimationId(state);
667
+ const int32_t animationId = allocateAnimationId();
706
668
  const Presentation resolvedStart =
707
669
  prepareAnimation(driverId, state, start, presentation);
708
670
  if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
@@ -717,7 +679,7 @@ int32_t animateTiming(
717
679
  active.start = resolvedStart;
718
680
  active.target = presentation;
719
681
  active.durationS = animation.durationMs / 1000.0;
720
- return startAnimation(driverId, state, std::move(active));
682
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
721
683
  }
722
684
 
723
685
  int32_t animateSpring(
@@ -727,10 +689,15 @@ int32_t animateSpring(
727
689
  SpringAnimation animation) {
728
690
  if (!isOnMainThread()) return 0;
729
691
  auto iterator = registry().find(driverId);
730
- if (iterator == registry().end()) return 0;
692
+ if (iterator == registry().end()) {
693
+ if (!start.hasInteractiveStart) return 0;
694
+ iterator = registry().try_emplace(driverId).first;
695
+ iterator->second.latest = start.interactiveStart;
696
+ iterator->second.hasLatest = true;
697
+ }
731
698
  auto &state = iterator->second;
732
699
  state.destroyed = false;
733
- const int32_t animationId = allocateAnimationId(state);
700
+ const int32_t animationId = allocateAnimationId();
734
701
  // Scalar velocity along the current-to-target trajectory; each channel is
735
702
  // seeded with velocity·displacement to match the iOS CASpringAnimation
736
703
  // per-keypath behavior.
@@ -760,7 +727,7 @@ int32_t animateSpring(
760
727
  active.springVelocity[index] =
761
728
  velocity * (targetChannels[index] - startChannels[index]);
762
729
  }
763
- return startAnimation(driverId, state, std::move(active));
730
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
764
731
  }
765
732
 
766
733
  int32_t animateKeyframes(
@@ -772,10 +739,15 @@ int32_t animateKeyframes(
772
739
  int32_t reduceMotion) {
773
740
  if (!isOnMainThread()) return 0;
774
741
  auto iterator = registry().find(driverId);
775
- if (iterator == registry().end()) return 0;
742
+ if (iterator == registry().end()) {
743
+ if (!start.hasInteractiveStart) return 0;
744
+ iterator = registry().try_emplace(driverId).first;
745
+ iterator->second.latest = start.interactiveStart;
746
+ iterator->second.hasLatest = true;
747
+ }
776
748
  auto &state = iterator->second;
777
749
  state.destroyed = false;
778
- const int32_t animationId = allocateAnimationId(state);
750
+ const int32_t animationId = allocateAnimationId();
779
751
  const Presentation resolvedStart =
780
752
  prepareAnimation(driverId, state, start, presentation);
781
753
  if (shouldReduceMotion(reduceMotion) || durationMs <= 0 ||
@@ -787,18 +759,18 @@ int32_t animateKeyframes(
787
759
  ActiveAnimation active;
788
760
  active.id = animationId;
789
761
  active.kind = AnimationKind::Keyframes;
790
- active.keyframes = std::move(keyframes);
762
+ active.keyframes.reset(std::move(keyframes));
791
763
  active.start = resolvedStart;
792
764
  active.target = presentation;
793
765
  active.durationS = durationMs / 1000.0;
794
- return startAnimation(driverId, state, std::move(active));
766
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
795
767
  }
796
768
 
797
769
  int32_t rejectAnimation(uint64_t driverId) {
798
770
  if (!isOnMainThread()) return 0;
799
771
  auto iterator = registry().find(driverId);
800
772
  if (iterator == registry().end()) return 0;
801
- const int32_t animationId = allocateAnimationId(iterator->second);
773
+ const int32_t animationId = allocateAnimationId();
802
774
  emitCompletion(driverId, animationId, false);
803
775
  return animationId;
804
776
  }
@@ -1009,12 +981,28 @@ void unregisterViewAndroid(
1009
981
  }
1010
982
  }
1011
983
  if (removed && state.animation.has_value()) {
1012
- state.animation->finished = false;
1013
- if (state.views.empty() && !state.animation->started) {
1014
- // A held latch returns to its original zero-view state; the next
1015
- // displayable registration still starts it (or destroyDriver cancels
1016
- // it). Completing it here would betray the pending intent.
1017
- } else if (state.views.empty()) {
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;
989
+ if (!isOnMainThread()) {
990
+ // Blast-radius reduction, NOT a synchronization fix — be clear about
991
+ // which. Both call sites (SmoothClipViewManager.onAfterUpdateTransaction
992
+ // and onDropViewInstance) are Fabric mount operations, so this branch is
993
+ // unreachable in supported usage; the erase above and the started-gated
994
+ // `finished` write are still unsynchronized if that ever stops holding. The erase
995
+ // cannot be skipped — dropping it would leak this view's global_ref for
996
+ // 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)) {
1018
1006
  auto &animation = *state.animation;
1019
1007
  // wallStartedAtS, not startedAtS: post-anchor the latter lives on the
1020
1008
  // shifted frame axis and would overstate elapsed by the first
@@ -1030,7 +1018,8 @@ void unregisterViewAndroid(
1030
1018
  state.ownership = Ownership::Interactive;
1031
1019
  finishActive(driverId, state, false);
1032
1020
  } else {
1033
- // The last rendering host left mid-flight (remount, screen swap).
1021
+ // The last displayable host left mid-flight. Registered detached or
1022
+ // unsized peers do not keep the clock running invisibly.
1034
1023
  // Completing here would leave `latest` at the target and statically
1035
1024
  // snap any re-registering host straight to it. Re-latch instead:
1036
1025
  // freeze the remaining animation at its current geometry so the
@@ -1038,14 +1027,24 @@ void unregisterViewAndroid(
1038
1027
  // destroyDriver cancels a latch that never finds a host, so the
1039
1028
  // completion cannot hang.
1040
1029
  if (animation.kind == AnimationKind::Timing) {
1041
- animation.durationS = remainingS;
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;
1042
1041
  } else if (animation.kind == AnimationKind::Keyframes) {
1043
1042
  const double progress = animation.durationS <= 0
1044
1043
  ? 1.0
1045
1044
  : clamp01(elapsedS / animation.durationS);
1046
1045
  std::vector<Keyframe> remaining;
1047
1046
  remaining.push_back({0, animation.current});
1048
- for (const Keyframe &frame : animation.keyframes) {
1047
+ for (const Keyframe &frame : animation.keyframes.frames()) {
1049
1048
  if (frame.offset <= progress) continue;
1050
1049
  remaining.push_back(
1051
1050
  {(frame.offset - progress) / (1 - progress),
@@ -1054,7 +1053,9 @@ void unregisterViewAndroid(
1054
1053
  if (remaining.size() == 1) {
1055
1054
  remaining.push_back({1, animation.target});
1056
1055
  }
1057
- animation.keyframes = std::move(remaining);
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));
1058
1059
  animation.durationS = remainingS;
1059
1060
  }
1060
1061
  animation.start = animation.current;