react-native-smooth-clip-view 0.2.5 → 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.
Files changed (41) hide show
  1. package/README.md +68 -26
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/build.gradle +15 -0
  4. package/android/src/main/cpp/SmoothClipAndroid.h +12 -8
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +39 -15
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +288 -285
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +28 -4
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +180 -42
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +1 -0
  12. package/cpp/SmoothClipAnimationCurve.h +560 -0
  13. package/cpp/SmoothClipAnimationId.h +32 -0
  14. package/cpp/SmoothClipRegistry.h +11 -1
  15. package/cpp/SmoothClipVelocityTracker.h +30 -10
  16. package/ios/SmoothClipRegistry.mm +398 -150
  17. package/ios/SmoothClipTurboModule.cpp +4 -2
  18. package/ios/SmoothClipTurboModule.h +2 -1
  19. package/ios/SmoothClipView.mm +85 -140
  20. package/ios/SmoothClipViewRegistry.h +4 -1
  21. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  22. package/lib/module/driverState.js +8 -0
  23. package/lib/module/driverState.js.map +1 -1
  24. package/lib/module/drivers.native.js +122 -32
  25. package/lib/module/drivers.native.js.map +1 -1
  26. package/lib/module/reactRequests.js +9 -3
  27. package/lib/module/reactRequests.js.map +1 -1
  28. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  29. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  30. package/lib/typescript/src/driverState.d.ts.map +1 -1
  31. package/lib/typescript/src/driverTypes.d.ts +3 -2
  32. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  34. package/lib/typescript/src/reactRequests.d.ts +3 -1
  35. package/lib/typescript/src/reactRequests.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/NativeSmoothClipModule.ts +2 -1
  38. package/src/driverState.ts +8 -0
  39. package/src/driverTypes.ts +3 -2
  40. package/src/drivers.native.ts +160 -29
  41. package/src/reactRequests.ts +14 -2
@@ -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>
@@ -25,7 +26,22 @@ using facebook::jni::global_ref;
25
26
 
26
27
  enum class Ownership { Interactive, Native };
27
28
  enum class AnimationKind { Timing, Spring, Keyframes };
28
-
29
+ enum class ViewParticipation { Deferred, Active, Suspended };
30
+
31
+ // Load-bearing invariant, not just a convenience clock: advance()'s frame-clock
32
+ // anchor compares this value ABSOLUTELY against a choreographer frame stamp
33
+ // (std::min), so the two must share an epoch. They do — bionic's steady_clock,
34
+ // System.nanoTime() behind Choreographer#frameTimeNanos, and the
35
+ // SystemClock.uptimeMillis() Reanimated stamps its own animations with are all
36
+ // CLOCK_MONOTONIC since boot. (The anchor this replaced subtracted two samples
37
+ // of this clock and was epoch-independent; the min() is not.)
38
+ //
39
+ // Breaking that invariant fails silently and asymmetrically. A frame axis
40
+ // running BEHIND the wall axis degrades quietly: min() always adopts the frame
41
+ // stamp and animations lose only their sub-frame start offset. A frame axis
42
+ // running AHEAD is fatal: min() always keeps startedAtS, the first fraction
43
+ // (frame - startedAtS)/duration clamps to 1, and every animation completes on
44
+ // its first frame.
29
45
  double nowSeconds() {
30
46
  return std::chrono::duration<double>(
31
47
  std::chrono::steady_clock::now().time_since_epoch())
@@ -40,26 +56,28 @@ struct ActiveAnimation {
40
56
  Presentation current{{0, 0, 0, 0, 0}, 0, 0};
41
57
  TimingAnimation timing{};
42
58
  SpringAnimation spring{};
43
- std::vector<Keyframe> keyframes;
59
+ // Owns the keyframes and their precomputed monotone-cubic tangents; the
60
+ // tangent solve happens on reset(), never per frame.
61
+ KeyframeCurve keyframes;
44
62
  double durationS = 0;
45
63
  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.
50
- double wallStartedAtS = 0;
51
64
  // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
52
65
  std::array<double, 7> springPosition{};
53
66
  std::array<double, 7> springVelocity{};
54
67
  double lastFrameS = 0;
55
68
  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.
69
+ // False while the animation is latched, and held out of animatingDrivers()
70
+ // so it cannot tick. Two ways in: built before any host view could display
71
+ // (pre-registration, or a detached/unsized host), or re-latched by
72
+ // unregisterViewAndroid when the last DISPLAYABLE host left mid-flight.
73
+ // Started by whichever lifecycle/geometry update first sees a displayable
74
+ // entry; each rebases the clock through startLatchedAnimation.
59
75
  bool started = false;
60
76
  // False until the first advance() translates the wall-clock start stamp
61
77
  // onto the choreographer frame-time axis (elapsed-preserving). Every
62
- // startedAtS stamp clears it so a re-latched resume re-anchors too.
78
+ // startedAtS stamp clears it so a re-latched resume re-anchors too — except
79
+ // a start that carried the JS-captured Reanimated stamp, which arrives
80
+ // pre-anchored (see startAnimation) and must not be min()'d again.
63
81
  bool frameClockAnchored = false;
64
82
  };
65
83
 
@@ -71,6 +89,8 @@ struct ViewEntry {
71
89
  double density = 0;
72
90
  double hostWidthPx = 0;
73
91
  double hostHeightPx = 0;
92
+ bool lifecycleVisible = false;
93
+ ViewParticipation participation = ViewParticipation::Deferred;
74
94
  };
75
95
 
76
96
  struct DriverState {
@@ -79,7 +99,6 @@ struct DriverState {
79
99
  Ownership ownership = Ownership::Interactive;
80
100
  std::vector<ViewEntry> views;
81
101
  std::optional<ActiveAnimation> animation;
82
- int32_t nextAnimationId = 0;
83
102
  // Set by destroyDriver while views are still registered (StrictMode effect
84
103
  // replay, hosts mounted in another subtree). The entry is erased when the
85
104
  // last view leaves and revived by a take-ownership setPresentation.
@@ -131,7 +150,7 @@ struct JLooper : facebook::jni::JavaClass<JLooper> {
131
150
  std::atomic<pid_t> gMainThreadId{0};
132
151
 
133
152
  // 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
153
+ // Choreographer.getInstance() is per-thread, and binding the loop to the JS
135
154
  // thread would race Fabric mounting and mutate views off-main. The worklets
136
155
  // UI runtime executes on the main thread, so supported usage always passes;
137
156
  // direct off-main calls fail defined instead of racing.
@@ -155,72 +174,47 @@ bool isOnMainThread() {
155
174
 
156
175
  // --- Frame loop -----------------------------------------------------------
157
176
 
177
+ // fbjni handle for the Kotlin frame-loop bridge. The loop posts through the
178
+ // Java Choreographer (CALLBACK_ANIMATION) rather than the NDK AChoreographer:
179
+ // the NDK instance is a separate frame source on the main Looper whose
180
+ // ordering against the Java Choreographer's traversal (draw) phase is
181
+ // undefined, so advances landing after the traversal presented one vsync
182
+ // late — a randomly flipping ±1-frame phase between the clip and Reanimated
183
+ // content. Posting into the same doFrame pass as Reanimated makes every
184
+ // advance precede the draw by construction. It also retires the 32-bit
185
+ // truncated-timestamp fallback: Java frameTimeNanos is a jlong everywhere.
186
+ //
187
+ // doCallbacks() extracts by wall-now rather than by frame time, so a post
188
+ // issued from an earlier phase of a frame already in flight (CALLBACK_INPUT —
189
+ // a gesture end that starts an animation) runs in that same doFrame instead
190
+ // of the next one. Harmless (the fraction is ~0 and the write dedupes in
191
+ // Kotlin) and one frame cheaper at the handoff than the NDK path allowed.
192
+ struct JSmoothClipBindings : facebook::jni::JavaClass<JSmoothClipBindings> {
193
+ static constexpr auto kJavaDescriptor =
194
+ "Lcom/smoothclipview/SmoothClipBindings;";
195
+ };
196
+
158
197
  bool gFrameScheduled = false;
159
198
  void onFrameImpl(double now);
160
- void onFrameLegacy(long frameTimeNanos, void *data);
161
199
 
162
200
  void scheduleFrame() {
163
201
  if (gFrameScheduled) return;
164
202
  // Defensive: never bind the per-thread choreographer to a non-main thread.
165
203
  if (!isOnMainThread()) return;
166
- AChoreographer *choreographer = AChoreographer_getInstance();
167
- if (choreographer == nullptr) return;
204
+ static const auto scheduleMethod =
205
+ JSmoothClipBindings::javaClassStatic()->getStaticMethod<void()>(
206
+ "scheduleFrame");
207
+ scheduleMethod(JSmoothClipBindings::javaClassStatic());
208
+ // Set only after a successful post: if the up-call ever threw with the flag
209
+ // already true, the loop would be dead until process restart.
168
210
  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
211
  }
177
212
 
178
213
  // --- 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
- }
214
+ //
215
+ // clamp01, toChannels/fromChannels, interpolate, the frame-clock anchor and the
216
+ // keyframe curve now live in cpp/SmoothClipAnimationCurve.h so a test binary can
217
+ // reach them without linking fbjni; ios/tests pins their behavior.
224
218
 
225
219
  // Springs settle below these per-channel thresholds (DIP and DIP/s).
226
220
  constexpr double kSpringSettleDisplacement = 0.05;
@@ -228,13 +222,6 @@ constexpr double kSpringSettleVelocity = 1.0;
228
222
  // Settle-based termination is backed by a hard cap so a pathological
229
223
  // configuration cannot run the frame loop forever.
230
224
  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
225
 
239
226
  // Semi-implicit Euler per channel, substepped: one frame-sized step is only
240
227
  // stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
@@ -285,37 +272,6 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
285
272
  return settled || now - animation.startedAtS >= kSpringMaxDurationS;
286
273
  }
287
274
 
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
275
  bool systemAnimatorsEnabled() {
320
276
  static const auto valueAnimatorClass =
321
277
  facebook::jni::findClassStatic("android/animation/ValueAnimator");
@@ -339,14 +295,6 @@ void emitCompletion(uint64_t driverId, int32_t animationId, bool finished) {
339
295
  }
340
296
  }
341
297
 
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
298
  // Terminal per-frame delivery: scale DIP -> px and normalize against the
351
299
  // pushed host metrics in C++, so the JNI call carries final pixel floats and
352
300
  // the Kotlin side reduces to field stores + invalidateOutline().
@@ -398,6 +346,9 @@ std::vector<uint64_t> &animatingDrivers() {
398
346
  // erase/recreate cycle would let one frame integrate the animation twice.
399
347
  void clearActiveAnimation(uint64_t driverId, DriverState &state) {
400
348
  state.animation.reset();
349
+ for (ViewEntry &entry : state.views) {
350
+ entry.participation = ViewParticipation::Deferred;
351
+ }
401
352
  auto &active = animatingDrivers();
402
353
  active.erase(
403
354
  std::remove(active.begin(), active.end(), driverId), active.end());
@@ -437,7 +388,7 @@ Presentation prepareAnimation(
437
388
  // no one can see (the transparentModal mount pattern).
438
389
  bool entryDisplayable(const ViewEntry &entry) {
439
390
  return entry.hostWidthPx > 0 && entry.hostHeightPx > 0 &&
440
- entry.view->isViewAttachedToWindow();
391
+ entry.lifecycleVisible;
441
392
  }
442
393
 
443
394
  bool anyDisplayableView(const DriverState &state) {
@@ -454,7 +405,6 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
454
405
  animation.started = true;
455
406
  animation.startedAtS = nowSeconds();
456
407
  animation.lastFrameS = animation.startedAtS;
457
- animation.wallStartedAtS = animation.startedAtS;
458
408
  // Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
459
409
  // ActiveAnimation in place, so a stale anchor would replay the fraction-0
460
410
  // first frame the anchor exists to remove.
@@ -463,6 +413,11 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
463
413
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
464
414
  active.push_back(driverId);
465
415
  }
416
+ for (ViewEntry &entry : state.views) {
417
+ if (entryDisplayable(entry)) {
418
+ entry.participation = ViewParticipation::Active;
419
+ }
420
+ }
466
421
  applyToViews(state, animation.start);
467
422
  scheduleFrame();
468
423
  }
@@ -470,11 +425,21 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
470
425
  int32_t startAnimation(
471
426
  uint64_t driverId,
472
427
  DriverState &state,
473
- ActiveAnimation animation) {
474
- animation.startedAtS = nowSeconds();
475
- animation.lastFrameS = animation.startedAtS;
476
- animation.wallStartedAtS = animation.startedAtS;
477
- animation.frameClockAnchored = false;
428
+ ActiveAnimation animation,
429
+ double startedAtHintS) {
430
+ // With a JS-captured hint the start stamp is Reanimated's own
431
+ // (`__frameTimestamp || _getAnimationTimestamp()`, read in the worklet that
432
+ // issued this call), already on the axis min() only approximates — so the
433
+ // animation arrives pre-anchored and advance() must not min() it against
434
+ // the dispatching frame, which for a CALLBACK_INPUT start is EARLIER than
435
+ // the call and would re-open the intra-frame lead the hint closes. Without
436
+ // a hint (NaN: latch-less legacy callers, tests, iOS ignoring the field)
437
+ // this reduces exactly to the old nowSeconds() + min() anchor path.
438
+ const double wallNow = nowSeconds();
439
+ const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
440
+ animation.startedAtS = stamp.startedAtS;
441
+ animation.lastFrameS = stamp.startedAtS;
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
@@ -482,6 +447,11 @@ int32_t startAnimation(
482
447
  animation.current = animation.start;
483
448
  animation.started = anyDisplayableView(state);
484
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
+ }
485
455
  if (!state.animation->started) {
486
456
  // Latch: no host view can display yet (animateTo raced the mount, or
487
457
  // every host is detached/unsized). The first displayable registration,
@@ -499,51 +469,117 @@ int32_t startAnimation(
499
469
  return state.animation->id;
500
470
  }
501
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
+
502
544
  void advance(uint64_t driverId, DriverState &state, double now) {
503
545
  ActiveAnimation &animation = *state.animation;
504
546
 
505
547
  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;
548
+ // This is the stamp-less/latch fallback. Worklet-issued animations carry
549
+ // Reanimated's exact `__frameTimestamp || _getAnimationTimestamp()` value
550
+ // and arrive pre-anchored, so they skip this approximation — including the
551
+ // CALLBACK_INPUT case where the current frame stamp predates the call.
552
+ // Here startedAtS/lastFrameS hold nowSeconds() sampled at native latch
553
+ // attach or by an older caller, while `now` is the frame's vsync stamp on
554
+ // the same CLOCK_MONOTONIC timebase. Taking the earlier stamp prevents a
555
+ // mid-frame native start from duplicating fraction zero.
556
+ //
557
+ // Deliberately NOT a wall-elapsed rebase (`now - (nowSeconds() -
558
+ // startedAtS)`): that bakes THIS frame's dispatch latency into the curve
559
+ // for the animation's whole duration, de-phasing the clip from parallel
560
+ // Reanimated content by however much main-thread work happened to run
561
+ // before us on frame one — worst on the first frame of a heavy
562
+ // transition, which is exactly when it is sampled. Catching up after a
563
+ // stall needs no help here either: Choreographer#doFrame snaps a late
564
+ // frameTimeNanos forward to within one frame interval of now (jitter
565
+ // correction), for every CALLBACK_ANIMATION client at once, so the clip
566
+ // and the content stall and catch up together.
567
+ animation.startedAtS = anchorStartTime(animation.startedAtS, now);
517
568
  animation.lastFrameS = animation.startedAtS;
518
569
  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;
535
570
  }
536
571
 
537
572
  bool done = false;
538
573
  if (animation.kind == AnimationKind::Spring) {
539
574
  done = advanceSpring(animation, now);
540
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;
541
579
  const double fraction =
542
- animation.durationS <= 0
543
- ? 1.0
544
- : clamp01((now - animation.startedAtS) / animation.durationS);
580
+ timingFraction(now, animation.startedAtS, animation.durationS);
545
581
  if (animation.kind == AnimationKind::Keyframes) {
546
- animation.current = interpolateKeyframes(animation.keyframes, fraction);
582
+ animation.current = animation.keyframes.evaluate(fraction);
547
583
  } else {
548
584
  const double eased = cubicBezier(
549
585
  animation.timing.controlPoint1X,
@@ -561,7 +597,13 @@ void advance(uint64_t driverId, DriverState &state, double now) {
561
597
 
562
598
  if (done) {
563
599
  const int32_t animationId = animation.id;
564
- 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
+ }
565
607
  const Presentation target = animation.target;
566
608
  clearActiveAnimation(driverId, state);
567
609
  state.latest = target;
@@ -593,26 +635,27 @@ void onFrameImpl(double now) {
593
635
  if (!animatingDrivers().empty()) scheduleFrame();
594
636
  }
595
637
 
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.
638
+ } // namespace
639
+
640
+ // Advances the frame loop; reached from SmoothClipBindings.nativeOnFrame
641
+ // inside Choreographer#doFrame. Nothing may unwind back into doFrame — fbjni
642
+ // would rethrow it as a Java exception and take down the main thread. Clear
643
+ // any pending JNI exception, keep the loop scheduled, and let the next frame
644
+ // re-scan.
645
+ void onFrameAndroid(double frameTimeS) {
599
646
  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
647
+ onFrameImpl(frameTimeS);
607
648
  } catch (...) {
608
649
  JNIEnv *env = facebook::jni::Environment::current();
609
650
  if (env != nullptr && env->ExceptionCheck()) env->ExceptionClear();
610
- scheduleFrame();
651
+ try {
652
+ scheduleFrame();
653
+ } catch (...) {
654
+ // gFrameScheduled stays false; the next animation start re-schedules.
655
+ }
611
656
  }
612
657
  }
613
658
 
614
- } // namespace
615
-
616
659
  // --- Public smoothclip:: interface (shared with iOS via SmoothClipRegistry.h)
617
660
 
618
661
  void setCompletionCallback(const void *owner, CompletionCallback callback) {
@@ -631,7 +674,8 @@ void clearCompletionCallback(const void *owner) {
631
674
  void setPresentation(
632
675
  uint64_t driverId,
633
676
  Presentation presentation,
634
- bool takeOwnership) {
677
+ bool takeOwnership,
678
+ bool overridePendingAnimation) {
635
679
  if (!isOnMainThread()) return;
636
680
  auto iterator = registry().find(driverId);
637
681
  if (!takeOwnership) {
@@ -647,13 +691,11 @@ void setPresentation(
647
691
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
648
692
  if (takeOwnership) {
649
693
  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) {
694
+ // Passive seeds and public setters must not displace newer pending intent.
695
+ // The fused animation.from write is different: it is the caller's newest,
696
+ // authoritative start and explicitly opts in to cancelling the old latch.
697
+ if (state.animation.has_value() && !state.animation->started &&
698
+ !overridePendingAnimation) {
657
699
  return;
658
700
  }
659
701
  finishActive(driverId, state, false);
@@ -681,12 +723,8 @@ Presentation beginInteraction(uint64_t driverId) {
681
723
  state.latest = current;
682
724
  state.hasLatest = true;
683
725
  state.ownership = Ownership::Interactive;
684
- // Plain record, not a reset — iOS parity (smoothClipFreezePresentation
685
- // records the frozen value). Pairing the frozen mid-flight presentation
686
- // with the last real sample lets a grab-and-instant-refling inherit
687
- // bounded recent motion instead of launching dead; an unchanged value
688
- // dedupes to a no-op and ages out through the staleness guard.
689
- 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.
690
728
  applyToViews(state, current);
691
729
  emitCompletion(driverId, animationId, false);
692
730
  return current;
@@ -699,10 +737,15 @@ int32_t animateTiming(
699
737
  TimingAnimation animation) {
700
738
  if (!isOnMainThread()) return 0;
701
739
  auto iterator = registry().find(driverId);
702
- if (iterator == registry().end()) return 0;
740
+ if (iterator == registry().end()) {
741
+ if (!start.hasInteractiveStart) return 0;
742
+ iterator = registry().try_emplace(driverId).first;
743
+ iterator->second.latest = start.interactiveStart;
744
+ iterator->second.hasLatest = true;
745
+ }
703
746
  auto &state = iterator->second;
704
747
  state.destroyed = false;
705
- const int32_t animationId = allocateAnimationId(state);
748
+ const int32_t animationId = allocateAnimationId();
706
749
  const Presentation resolvedStart =
707
750
  prepareAnimation(driverId, state, start, presentation);
708
751
  if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
@@ -717,7 +760,7 @@ int32_t animateTiming(
717
760
  active.start = resolvedStart;
718
761
  active.target = presentation;
719
762
  active.durationS = animation.durationMs / 1000.0;
720
- return startAnimation(driverId, state, std::move(active));
763
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
721
764
  }
722
765
 
723
766
  int32_t animateSpring(
@@ -727,10 +770,15 @@ int32_t animateSpring(
727
770
  SpringAnimation animation) {
728
771
  if (!isOnMainThread()) return 0;
729
772
  auto iterator = registry().find(driverId);
730
- if (iterator == registry().end()) return 0;
773
+ if (iterator == registry().end()) {
774
+ if (!start.hasInteractiveStart) return 0;
775
+ iterator = registry().try_emplace(driverId).first;
776
+ iterator->second.latest = start.interactiveStart;
777
+ iterator->second.hasLatest = true;
778
+ }
731
779
  auto &state = iterator->second;
732
780
  state.destroyed = false;
733
- const int32_t animationId = allocateAnimationId(state);
781
+ const int32_t animationId = allocateAnimationId();
734
782
  // Scalar velocity along the current-to-target trajectory; each channel is
735
783
  // seeded with velocity·displacement to match the iOS CASpringAnimation
736
784
  // per-keypath behavior.
@@ -760,7 +808,7 @@ int32_t animateSpring(
760
808
  active.springVelocity[index] =
761
809
  velocity * (targetChannels[index] - startChannels[index]);
762
810
  }
763
- return startAnimation(driverId, state, std::move(active));
811
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
764
812
  }
765
813
 
766
814
  int32_t animateKeyframes(
@@ -772,10 +820,15 @@ int32_t animateKeyframes(
772
820
  int32_t reduceMotion) {
773
821
  if (!isOnMainThread()) return 0;
774
822
  auto iterator = registry().find(driverId);
775
- if (iterator == registry().end()) return 0;
823
+ if (iterator == registry().end()) {
824
+ if (!start.hasInteractiveStart) return 0;
825
+ iterator = registry().try_emplace(driverId).first;
826
+ iterator->second.latest = start.interactiveStart;
827
+ iterator->second.hasLatest = true;
828
+ }
776
829
  auto &state = iterator->second;
777
830
  state.destroyed = false;
778
- const int32_t animationId = allocateAnimationId(state);
831
+ const int32_t animationId = allocateAnimationId();
779
832
  const Presentation resolvedStart =
780
833
  prepareAnimation(driverId, state, start, presentation);
781
834
  if (shouldReduceMotion(reduceMotion) || durationMs <= 0 ||
@@ -787,18 +840,18 @@ int32_t animateKeyframes(
787
840
  ActiveAnimation active;
788
841
  active.id = animationId;
789
842
  active.kind = AnimationKind::Keyframes;
790
- active.keyframes = std::move(keyframes);
843
+ active.keyframes.reset(std::move(keyframes));
791
844
  active.start = resolvedStart;
792
845
  active.target = presentation;
793
846
  active.durationS = durationMs / 1000.0;
794
- return startAnimation(driverId, state, std::move(active));
847
+ return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
795
848
  }
796
849
 
797
850
  int32_t rejectAnimation(uint64_t driverId) {
798
851
  if (!isOnMainThread()) return 0;
799
852
  auto iterator = registry().find(driverId);
800
853
  if (iterator == registry().end()) return 0;
801
- const int32_t animationId = allocateAnimationId(iterator->second);
854
+ const int32_t animationId = allocateAnimationId();
802
855
  emitCompletion(driverId, animationId, false);
803
856
  return animationId;
804
857
  }
@@ -837,6 +890,11 @@ void destroyDriver(uint64_t driverId) {
837
890
  auto &state = iterator->second;
838
891
  state.ownership = Ownership::Interactive;
839
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{};
840
898
  if (state.views.empty()) {
841
899
  registry().erase(iterator);
842
900
  } else {
@@ -850,12 +908,6 @@ void destroyDriver(uint64_t driverId) {
850
908
 
851
909
  // --- Android view lifecycle ----------------------------------------------
852
910
 
853
- bool JSmoothClipView::isViewAttachedToWindow() const {
854
- static const auto method =
855
- javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
856
- return method(self());
857
- }
858
-
859
911
  void JSmoothClipView::applyClip(const Presentation &presentation) const {
860
912
  static const auto method =
861
913
  javaClassStatic()
@@ -899,7 +951,8 @@ void registerViewAndroid(
899
951
  Presentation initialPresentation,
900
952
  double density,
901
953
  double hostWidthPx,
902
- double hostHeightPx) {
954
+ double hostHeightPx,
955
+ bool lifecycleVisible) {
903
956
  auto &state = registry()[driverId];
904
957
  state.destroyed = false;
905
958
  if (!state.hasLatest) {
@@ -915,32 +968,24 @@ void registerViewAndroid(
915
968
  existing.density = density;
916
969
  existing.hostWidthPx = hostWidthPx;
917
970
  existing.hostHeightPx = hostHeightPx;
971
+ existing.lifecycleVisible = lifecycleVisible;
918
972
  deliverToView(existing, visible);
919
- if (state.animation.has_value() && !state.animation->started &&
920
- entryDisplayable(existing)) {
921
- startLatchedAnimation(driverId, state);
922
- }
973
+ reconcileViewDisplayability(driverId, state, existing);
923
974
  return;
924
975
  }
925
976
  }
926
977
  ViewEntry entry{
927
- 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};
928
984
  // `visible` above already delivered animation->current (= start) to the
929
985
  // registering view — the correct first frame for a latched animation.
930
986
  deliverToView(entry, visible);
931
987
  state.views.push_back(std::move(entry));
932
- if (state.animation.has_value() && !state.animation->started &&
933
- entryDisplayable(state.views.back())) {
934
- // Start the latched animation: rebase the clock so the first frame
935
- // integrates from now, not from the pre-mount animateTo call. Gated on
936
- // displayability — a mount-time registration from a detached subtree
937
- // holds the latch until window attach / first host geometry.
938
- // Note this function has no isOnMainThread() guard (Kotlin calls it from
939
- // the view's main-thread attach path); scheduleFrame() is itself
940
- // main-thread-gated, so an off-main register cannot bind the
941
- // choreographer to the wrong thread.
942
- startLatchedAnimation(driverId, state);
943
- }
988
+ reconcileViewDisplayability(driverId, state, state.views.back());
944
989
  }
945
990
 
946
991
  void setViewHostGeometryAndroid(
@@ -965,29 +1010,24 @@ void setViewHostGeometryAndroid(
965
1010
  state.animation.has_value() ? state.animation->current
966
1011
  : state.latest);
967
1012
  }
968
- if (state.animation.has_value() && !state.animation->started &&
969
- entryDisplayable(entry)) {
970
- // First real host geometry made this view displayable.
971
- startLatchedAnimation(driverId, state);
972
- }
1013
+ reconcileViewDisplayability(driverId, state, entry);
973
1014
  return;
974
1015
  }
975
1016
  }
976
1017
 
977
- void viewBecameDisplayableAndroid(
1018
+ void setViewLifecycleVisibilityAndroid(
978
1019
  uint64_t driverId,
979
- alias_ref<JSmoothClipView> view) {
1020
+ alias_ref<JSmoothClipView> view,
1021
+ bool lifecycleVisible) {
980
1022
  if (!isOnMainThread()) return;
981
1023
  auto iterator = registry().find(driverId);
982
1024
  if (iterator == registry().end()) return;
983
1025
  auto &state = iterator->second;
984
- if (!state.animation.has_value() || state.animation->started) return;
985
1026
  JNIEnv *env = facebook::jni::Environment::current();
986
1027
  for (auto &entry : state.views) {
987
1028
  if (!env->IsSameObject(entry.view.get(), view.get())) continue;
988
- if (entryDisplayable(entry)) {
989
- startLatchedAnimation(driverId, state);
990
- }
1029
+ entry.lifecycleVisible = lifecycleVisible;
1030
+ reconcileViewDisplayability(driverId, state, entry);
991
1031
  return;
992
1032
  }
993
1033
  }
@@ -999,9 +1039,12 @@ void unregisterViewAndroid(
999
1039
  if (iterator == registry().end()) return;
1000
1040
  auto &state = iterator->second;
1001
1041
  bool removed = false;
1042
+ bool removedParticipant = false;
1002
1043
  JNIEnv *env = facebook::jni::Environment::current();
1003
1044
  for (auto view_it = state.views.begin(); view_it != state.views.end();) {
1004
1045
  if (env->IsSameObject(view_it->view.get(), view.get())) {
1046
+ removedParticipant = removedParticipant ||
1047
+ view_it->participation != ViewParticipation::Deferred;
1005
1048
  view_it = state.views.erase(view_it);
1006
1049
  removed = true;
1007
1050
  } else {
@@ -1009,64 +1052,24 @@ void unregisterViewAndroid(
1009
1052
  }
1010
1053
  }
1011
1054
  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()) {
1018
- auto &animation = *state.animation;
1019
- // wallStartedAtS, not startedAtS: post-anchor the latter lives on the
1020
- // shifted frame axis and would overstate elapsed by the first
1021
- // callback's dispatch latency, shortening the resumed remainder and
1022
- // advancing the keyframe rebase past the rendered position.
1023
- const double elapsedS = nowSeconds() - animation.wallStartedAtS;
1024
- const double remainingS = std::max(0.0, animation.durationS - elapsedS);
1025
- const bool canResume = !state.destroyed &&
1026
- (animation.kind == AnimationKind::Spring || remainingS > 0);
1027
- if (!canResume) {
1028
- // The animation ends here; release ownership so a later remount's
1029
- // interactive updates are not dropped.
1030
- state.ownership = Ownership::Interactive;
1031
- finishActive(driverId, state, false);
1032
- } else {
1033
- // The last rendering host left mid-flight (remount, screen swap).
1034
- // Completing here would leave `latest` at the target and statically
1035
- // snap any re-registering host straight to it. Re-latch instead:
1036
- // freeze the remaining animation at its current geometry so the
1037
- // next displayable host resumes it with the true remaining time.
1038
- // destroyDriver cancels a latch that never finds a host, so the
1039
- // completion cannot hang.
1040
- if (animation.kind == AnimationKind::Timing) {
1041
- animation.durationS = remainingS;
1042
- } else if (animation.kind == AnimationKind::Keyframes) {
1043
- const double progress = animation.durationS <= 0
1044
- ? 1.0
1045
- : clamp01(elapsedS / animation.durationS);
1046
- std::vector<Keyframe> remaining;
1047
- remaining.push_back({0, animation.current});
1048
- for (const Keyframe &frame : animation.keyframes) {
1049
- if (frame.offset <= progress) continue;
1050
- remaining.push_back(
1051
- {(frame.offset - progress) / (1 - progress),
1052
- frame.presentation});
1053
- }
1054
- if (remaining.size() == 1) {
1055
- remaining.push_back({1, animation.target});
1056
- }
1057
- animation.keyframes = std::move(remaining);
1058
- animation.durationS = remainingS;
1059
- }
1060
- animation.start = animation.current;
1061
- animation.started = false;
1062
- // A held latch must not tick: leave animatingDrivers() without
1063
- // resetting the animation (the eager-removal rule the choreographer
1064
- // loop depends on).
1065
- auto &active = animatingDrivers();
1066
- active.erase(
1067
- std::remove(active.begin(), active.end(), driverId),
1068
- active.end());
1069
- }
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;
1059
+ if (!isOnMainThread()) {
1060
+ // Blast-radius reduction, NOT a synchronization fix — be clear about
1061
+ // which. Both call sites (SmoothClipViewManager.onAfterUpdateTransaction
1062
+ // and onDropViewInstance) are Fabric mount operations, so this branch is
1063
+ // unreachable in supported usage; the erase above and participant-based
1064
+ // `finished` write are still unsynchronized if that ever stops holding. The erase
1065
+ // cannot be skipped — dropping it would leak this view's global_ref for
1066
+ // the process lifetime — but everything below can be, and should be: it
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);
1070
1073
  }
1071
1074
  }
1072
1075
  if (state.destroyed && state.views.empty()) {