react-native-smooth-clip-view 0.2.4 → 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 (40) hide show
  1. package/README.md +62 -25
  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 +198 -189
  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 +32 -15
  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.android.js +3 -1
  20. package/lib/module/drivers.android.js.map +1 -1
  21. package/lib/module/drivers.ios.js +3 -391
  22. package/lib/module/drivers.ios.js.map +1 -1
  23. package/lib/module/drivers.native.js +470 -0
  24. package/lib/module/drivers.native.js.map +1 -0
  25. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  26. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  27. package/lib/typescript/src/driverTypes.d.ts +6 -3
  28. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  29. package/lib/typescript/src/drivers.android.d.ts +2 -2
  30. package/lib/typescript/src/drivers.android.d.ts.map +1 -1
  31. package/lib/typescript/src/drivers.ios.d.ts +2 -4
  32. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts +5 -0
  34. package/lib/typescript/src/drivers.native.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/NativeSmoothClipModule.ts +2 -1
  37. package/src/driverTypes.ts +6 -3
  38. package/src/drivers.android.ts +4 -2
  39. package/src/drivers.ios.ts +4 -687
  40. package/src/drivers.native.ts +805 -0
@@ -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,10 +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
- // One vsync of slack for the post-stall run-ahead guard in advance():
232
- // frame-axis elapsed may lead wall elapsed by at most this much before the
233
- // animation is re-anchored onto honest wall time.
234
- constexpr double kMaxFrameLeadS = 0.017;
235
228
 
236
229
  // Semi-implicit Euler per channel, substepped: one frame-sized step is only
237
230
  // stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
@@ -282,37 +275,6 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
282
275
  return settled || now - animation.startedAtS >= kSpringMaxDurationS;
283
276
  }
284
277
 
285
- Presentation interpolate(
286
- const Presentation &from,
287
- const Presentation &to,
288
- double progress) {
289
- const auto mix = [progress](double start, double end) {
290
- return start + (end - start) * progress;
291
- };
292
- return Presentation{
293
- {mix(from.clip.x, to.clip.x),
294
- mix(from.clip.y, to.clip.y),
295
- mix(from.clip.width, to.clip.width),
296
- mix(from.clip.height, to.clip.height),
297
- mix(from.clip.radius, to.clip.radius)},
298
- mix(from.contentTranslateX, to.contentTranslateX),
299
- mix(from.contentTranslateY, to.contentTranslateY)};
300
- }
301
-
302
- Presentation interpolateKeyframes(
303
- const std::vector<Keyframe> &frames,
304
- double progress) {
305
- size_t upper = 1;
306
- while (upper < frames.size() - 1 && progress > frames[upper].offset) {
307
- upper += 1;
308
- }
309
- const Keyframe &lower = frames[upper - 1];
310
- const Keyframe &higher = frames[upper];
311
- const double span = higher.offset - lower.offset;
312
- const double local = span <= 0 ? 1.0 : (progress - lower.offset) / span;
313
- return interpolate(lower.presentation, higher.presentation, clamp01(local));
314
- }
315
-
316
278
  bool systemAnimatorsEnabled() {
317
279
  static const auto valueAnimatorClass =
318
280
  facebook::jni::findClassStatic("android/animation/ValueAnimator");
@@ -336,14 +298,6 @@ void emitCompletion(uint64_t driverId, int32_t animationId, bool finished) {
336
298
  }
337
299
  }
338
300
 
339
- int32_t allocateAnimationId(DriverState &state) {
340
- state.nextAnimationId =
341
- state.nextAnimationId == std::numeric_limits<int32_t>::max()
342
- ? 1
343
- : state.nextAnimationId + 1;
344
- return state.nextAnimationId;
345
- }
346
-
347
301
  // Terminal per-frame delivery: scale DIP -> px and normalize against the
348
302
  // pushed host metrics in C++, so the JNI call carries final pixel floats and
349
303
  // the Kotlin side reduces to field stores + invalidateOutline().
@@ -467,11 +421,25 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
467
421
  int32_t startAnimation(
468
422
  uint64_t driverId,
469
423
  DriverState &state,
470
- ActiveAnimation animation) {
471
- animation.startedAtS = nowSeconds();
472
- animation.lastFrameS = animation.startedAtS;
473
- animation.wallStartedAtS = animation.startedAtS;
474
- 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;
475
443
  // current = start even while latched is load-bearing: cancelAnimation,
476
444
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
477
445
  // visible all read animation->current, giving a never-rendered latch
@@ -500,35 +468,29 @@ void advance(uint64_t driverId, DriverState &state, double now) {
500
468
  ActiveAnimation &animation = *state.animation;
501
469
 
502
470
  if (!animation.frameClockAnchored) {
503
- // startedAtS/lastFrameS hold nowSeconds() sampled mid-frame at the
504
- // animateTo call / latch attach, but `now` is the frame's vsync
505
- // timestamp — the same CLOCK_MONOTONIC timebase at an earlier sampling
506
- // point. Un-anchored, the first fraction clamps to 0 and re-renders the
507
- // start (1-2 duplicated frames at every interactive→animateTo handoff).
508
- // Re-anchor onto the frame-time axis keeping the real elapsed time, so
509
- // the first rendered frame advances honestly and later frames pace on
510
- // vsync. On the 32-bit path `now` is itself nowSeconds() → ~identity.
511
- const double wallElapsedS =
512
- std::max(0.0, nowSeconds() - animation.startedAtS);
513
- 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);
514
491
  animation.lastFrameS = animation.startedAtS;
515
492
  animation.frameClockAnchored = true;
516
- // wallStartedAtS deliberately keeps the wall axis through this rebase.
517
- }
518
-
519
- // Run-ahead guard: the anchor's one-time shift bakes the first callback's
520
- // dispatch latency into the frame axis. If a stalled handoff anchored
521
- // against a stale frame stamp and later stamps catch up to real time, the
522
- // frame axis leads wall time by up to the stall length and the animation
523
- // completes early. Re-anchor forward when the lead exceeds one vsync of
524
- // slack; on calm frames the condition (first callback's latency exceeding
525
- // this frame's by > 17 ms) cannot hold, so the hot path is unchanged, and
526
- // the 32-bit path (frame axis == wall axis) never fires it. lastFrameS is
527
- // deliberately untouched: springs pace on dt, already clamped to 64 ms.
528
- const double wallElapsedNowS =
529
- std::max(0.0, nowSeconds() - animation.wallStartedAtS);
530
- if (now - animation.startedAtS > wallElapsedNowS + kMaxFrameLeadS) {
531
- animation.startedAtS = now - wallElapsedNowS;
493
+ // wallStartedAtS deliberately keeps the wall axis (re-latch remainder).
532
494
  }
533
495
 
534
496
  bool done = false;
@@ -536,11 +498,9 @@ void advance(uint64_t driverId, DriverState &state, double now) {
536
498
  done = advanceSpring(animation, now);
537
499
  } else {
538
500
  const double fraction =
539
- animation.durationS <= 0
540
- ? 1.0
541
- : clamp01((now - animation.startedAtS) / animation.durationS);
501
+ timingFraction(now, animation.startedAtS, animation.durationS);
542
502
  if (animation.kind == AnimationKind::Keyframes) {
543
- animation.current = interpolateKeyframes(animation.keyframes, fraction);
503
+ animation.current = animation.keyframes.evaluate(fraction);
544
504
  } else {
545
505
  const double eased = cubicBezier(
546
506
  animation.timing.controlPoint1X,
@@ -590,26 +550,27 @@ void onFrameImpl(double now) {
590
550
  if (!animatingDrivers().empty()) scheduleFrame();
591
551
  }
592
552
 
593
- void onFrameLegacy(long frameTimeNanos, void * /*data*/) {
594
- // An fbjni exception must not unwind out of a C callback (std::terminate).
595
- // 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) {
596
561
  try {
597
- #if defined(__LP64__) || (defined(__SIZEOF_POINTER__) && __SIZEOF_POINTER__ == 8)
598
- onFrameImpl(static_cast<double>(frameTimeNanos) / 1e9);
599
- #else
600
- // 32-bit `long` truncates the nanosecond timestamp; fall back to the
601
- // clock.
602
- onFrameImpl(nowSeconds());
603
- #endif
562
+ onFrameImpl(frameTimeS);
604
563
  } catch (...) {
605
564
  JNIEnv *env = facebook::jni::Environment::current();
606
565
  if (env != nullptr && env->ExceptionCheck()) env->ExceptionClear();
607
- scheduleFrame();
566
+ try {
567
+ scheduleFrame();
568
+ } catch (...) {
569
+ // gFrameScheduled stays false; the next animation start re-schedules.
570
+ }
608
571
  }
609
572
  }
610
573
 
611
- } // namespace
612
-
613
574
  // --- Public smoothclip:: interface (shared with iOS via SmoothClipRegistry.h)
614
575
 
615
576
  void setCompletionCallback(const void *owner, CompletionCallback callback) {
@@ -628,7 +589,8 @@ void clearCompletionCallback(const void *owner) {
628
589
  void setPresentation(
629
590
  uint64_t driverId,
630
591
  Presentation presentation,
631
- bool takeOwnership) {
592
+ bool takeOwnership,
593
+ bool overridePendingAnimation) {
632
594
  if (!isOnMainThread()) return;
633
595
  auto iterator = registry().find(driverId);
634
596
  if (!takeOwnership) {
@@ -644,13 +606,11 @@ void setPresentation(
644
606
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
645
607
  if (takeOwnership) {
646
608
  state.destroyed = false;
647
- // A held latch is strictly newer intent than this write: the latch was
648
- // created after whatever value the caller read (the hook's seed replays
649
- // a SharedValue that an earlier animateTo already advanced to its
650
- // target). Cancelling it here would seed the target and turn the pending
651
- // animation into a static jump. Callers that want to override a latch
652
- // cancel it explicitly first.
653
- 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) {
654
614
  return;
655
615
  }
656
616
  finishActive(driverId, state, false);
@@ -678,7 +638,12 @@ Presentation beginInteraction(uint64_t driverId) {
678
638
  state.latest = current;
679
639
  state.hasLatest = true;
680
640
  state.ownership = Ownership::Interactive;
681
- resetVelocitySamples(state.samples, toChannels(current), nowSeconds());
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());
682
647
  applyToViews(state, current);
683
648
  emitCompletion(driverId, animationId, false);
684
649
  return current;
@@ -691,10 +656,15 @@ int32_t animateTiming(
691
656
  TimingAnimation animation) {
692
657
  if (!isOnMainThread()) return 0;
693
658
  auto iterator = registry().find(driverId);
694
- 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
+ }
695
665
  auto &state = iterator->second;
696
666
  state.destroyed = false;
697
- const int32_t animationId = allocateAnimationId(state);
667
+ const int32_t animationId = allocateAnimationId();
698
668
  const Presentation resolvedStart =
699
669
  prepareAnimation(driverId, state, start, presentation);
700
670
  if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
@@ -709,7 +679,7 @@ int32_t animateTiming(
709
679
  active.start = resolvedStart;
710
680
  active.target = presentation;
711
681
  active.durationS = animation.durationMs / 1000.0;
712
- return startAnimation(driverId, state, std::move(active));
682
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
713
683
  }
714
684
 
715
685
  int32_t animateSpring(
@@ -719,10 +689,15 @@ int32_t animateSpring(
719
689
  SpringAnimation animation) {
720
690
  if (!isOnMainThread()) return 0;
721
691
  auto iterator = registry().find(driverId);
722
- 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
+ }
723
698
  auto &state = iterator->second;
724
699
  state.destroyed = false;
725
- const int32_t animationId = allocateAnimationId(state);
700
+ const int32_t animationId = allocateAnimationId();
726
701
  // Scalar velocity along the current-to-target trajectory; each channel is
727
702
  // seeded with velocity·displacement to match the iOS CASpringAnimation
728
703
  // per-keypath behavior.
@@ -752,7 +727,7 @@ int32_t animateSpring(
752
727
  active.springVelocity[index] =
753
728
  velocity * (targetChannels[index] - startChannels[index]);
754
729
  }
755
- return startAnimation(driverId, state, std::move(active));
730
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
756
731
  }
757
732
 
758
733
  int32_t animateKeyframes(
@@ -764,10 +739,15 @@ int32_t animateKeyframes(
764
739
  int32_t reduceMotion) {
765
740
  if (!isOnMainThread()) return 0;
766
741
  auto iterator = registry().find(driverId);
767
- 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
+ }
768
748
  auto &state = iterator->second;
769
749
  state.destroyed = false;
770
- const int32_t animationId = allocateAnimationId(state);
750
+ const int32_t animationId = allocateAnimationId();
771
751
  const Presentation resolvedStart =
772
752
  prepareAnimation(driverId, state, start, presentation);
773
753
  if (shouldReduceMotion(reduceMotion) || durationMs <= 0 ||
@@ -779,18 +759,18 @@ int32_t animateKeyframes(
779
759
  ActiveAnimation active;
780
760
  active.id = animationId;
781
761
  active.kind = AnimationKind::Keyframes;
782
- active.keyframes = std::move(keyframes);
762
+ active.keyframes.reset(std::move(keyframes));
783
763
  active.start = resolvedStart;
784
764
  active.target = presentation;
785
765
  active.durationS = durationMs / 1000.0;
786
- return startAnimation(driverId, state, std::move(active));
766
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
787
767
  }
788
768
 
789
769
  int32_t rejectAnimation(uint64_t driverId) {
790
770
  if (!isOnMainThread()) return 0;
791
771
  auto iterator = registry().find(driverId);
792
772
  if (iterator == registry().end()) return 0;
793
- const int32_t animationId = allocateAnimationId(iterator->second);
773
+ const int32_t animationId = allocateAnimationId();
794
774
  emitCompletion(driverId, animationId, false);
795
775
  return animationId;
796
776
  }
@@ -1001,12 +981,28 @@ void unregisterViewAndroid(
1001
981
  }
1002
982
  }
1003
983
  if (removed && state.animation.has_value()) {
1004
- state.animation->finished = false;
1005
- if (state.views.empty() && !state.animation->started) {
1006
- // A held latch returns to its original zero-view state; the next
1007
- // displayable registration still starts it (or destroyDriver cancels
1008
- // it). Completing it here would betray the pending intent.
1009
- } 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)) {
1010
1006
  auto &animation = *state.animation;
1011
1007
  // wallStartedAtS, not startedAtS: post-anchor the latter lives on the
1012
1008
  // shifted frame axis and would overstate elapsed by the first
@@ -1022,7 +1018,8 @@ void unregisterViewAndroid(
1022
1018
  state.ownership = Ownership::Interactive;
1023
1019
  finishActive(driverId, state, false);
1024
1020
  } else {
1025
- // 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.
1026
1023
  // Completing here would leave `latest` at the target and statically
1027
1024
  // snap any re-registering host straight to it. Re-latch instead:
1028
1025
  // freeze the remaining animation at its current geometry so the
@@ -1030,14 +1027,24 @@ void unregisterViewAndroid(
1030
1027
  // destroyDriver cancels a latch that never finds a host, so the
1031
1028
  // completion cannot hang.
1032
1029
  if (animation.kind == AnimationKind::Timing) {
1033
- 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;
1034
1041
  } else if (animation.kind == AnimationKind::Keyframes) {
1035
1042
  const double progress = animation.durationS <= 0
1036
1043
  ? 1.0
1037
1044
  : clamp01(elapsedS / animation.durationS);
1038
1045
  std::vector<Keyframe> remaining;
1039
1046
  remaining.push_back({0, animation.current});
1040
- for (const Keyframe &frame : animation.keyframes) {
1047
+ for (const Keyframe &frame : animation.keyframes.frames()) {
1041
1048
  if (frame.offset <= progress) continue;
1042
1049
  remaining.push_back(
1043
1050
  {(frame.offset - progress) / (1 - progress),
@@ -1046,7 +1053,9 @@ void unregisterViewAndroid(
1046
1053
  if (remaining.size() == 1) {
1047
1054
  remaining.push_back({1, animation.target});
1048
1055
  }
1049
- 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));
1050
1059
  animation.durationS = remainingS;
1051
1060
  }
1052
1061
  animation.start = animation.current;