react-native-smooth-clip-view 0.2.1 → 0.2.4

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 (39) hide show
  1. package/README.md +51 -16
  2. package/android/CMakeLists.txt +4 -0
  3. package/android/build.gradle +1 -0
  4. package/android/src/main/cpp/SmoothClipAndroid.h +8 -0
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +11 -0
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +200 -76
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +7 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +10 -0
  9. package/cpp/SmoothClipVelocityTracker.h +106 -0
  10. package/ios/SmoothClipRegistry.mm +153 -29
  11. package/ios/SmoothClipView.mm +67 -77
  12. package/ios/SmoothClipViewRegistry.h +8 -0
  13. package/lib/module/drivers.ios.js +14 -0
  14. package/lib/module/drivers.ios.js.map +1 -1
  15. package/lib/module/drivers.js +8 -0
  16. package/lib/module/drivers.js.map +1 -1
  17. package/lib/module/easings.js +35 -0
  18. package/lib/module/easings.js.map +1 -0
  19. package/lib/module/index.js +1 -0
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js +1 -0
  22. package/lib/module/index.web.js.map +1 -1
  23. package/lib/typescript/src/driverTypes.d.ts +32 -3
  24. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  25. package/lib/typescript/src/drivers.d.ts.map +1 -1
  26. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  27. package/lib/typescript/src/easings.d.ts +33 -0
  28. package/lib/typescript/src/easings.d.ts.map +1 -0
  29. package/lib/typescript/src/index.d.ts +1 -0
  30. package/lib/typescript/src/index.d.ts.map +1 -1
  31. package/lib/typescript/src/index.web.d.ts +1 -0
  32. package/lib/typescript/src/index.web.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/driverTypes.ts +51 -19
  35. package/src/drivers.ios.ts +26 -0
  36. package/src/drivers.ts +11 -0
  37. package/src/easings.ts +32 -0
  38. package/src/index.ts +1 -0
  39. package/src/index.web.ts +1 -0
package/README.md CHANGED
@@ -141,10 +141,16 @@ const gesture = Gesture.Pan()
141
141
  // bookkeeping. Assigning driver.presentation.value also works.
142
142
  driver.ui.setScalars(clip.x, clip.y, clip.width, clip.height, clip.radius, 0, 0);
143
143
  })
144
- .onEnd(() => {
144
+ .onEnd((event) => {
145
+ // The release event is fresher than the last onUpdate (on Android,
146
+ // ACTION_UP carries a position no MOVE ever delivered). Pass the final
147
+ // geometry as `from` so the animation starts from exactly that value —
148
+ // it fuses a setScalars hot write with the handoff in one call.
149
+ const clip = geometryForDrag(dragStart.value, event.translationY);
145
150
  driver.ui.animateTo(createClipPresentation(expandedClip), {
146
151
  type: 'spring',
147
152
  initialVelocity: 'inherit',
153
+ from: createClipPresentation(clip),
148
154
  });
149
155
  });
150
156
  ```
@@ -175,35 +181,64 @@ const gesture = Gesture.Pan()
175
181
  streams such as gestures. `driver.presentation.value` is stale after hot
176
182
  writes by design; `beginInteraction()` remains the source of truth for
177
183
  visible geometry, and `animateTo()` after hot writes starts from the native
178
- registry's latest value rather than the stale SharedValue. Do not interleave
179
- `setScalars` with `presentation.value` writes on the same driver.
184
+ registry's latest value rather than the stale SharedValue. To start from a
185
+ value fresher than the last hot write (a gesture's release sample), pass it
186
+ as `animation.from` — `animateTo` then performs the hot write and the
187
+ handoff in one call. Do not interleave `setScalars` with
188
+ `presentation.value` writes on the same driver.
180
189
  - Spring `initialVelocity` is one normalized scalar along the current-to-target
181
190
  trajectory, in units of the remaining distance per second (`1` covers the
182
191
  remaining distance in one second). Every geometry channel continues with the
183
192
  same normalized rate, so grab/release preserves the felt direction and
184
193
  speed. `'inherit'` (the default) estimates the scalar from the last two
185
194
  interactive samples on iOS and Android; samples older than 100 ms — and the
186
- web fallback — fall back to zero.
195
+ web fallback — fall back to zero. Two writes landing inside the same frame
196
+ (< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
197
+ write) coalesce into one sample, and an identical re-write is ignored, so a
198
+ fused handoff can neither zero nor inflate the inherited velocity.
187
199
  - `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
188
200
  as Promises (`setScalars` is UI-worklet-only). React code never blocks
189
201
  waiting for main/UI-thread work. An immediate animation request resolves
190
202
  before its completion callback is delivered.
191
203
  - `animateTo()` transfers ownership to native animation. Timing uses
192
- cubic Bézier control points; springs accept mass, stiffness, damping, and an
204
+ cubic Bézier control points — `ClipEasings` exports exact-form presets
205
+ (`easeOutCubic` = `Easing.out(Easing.cubic)` etc.) so a parallel Reanimated
206
+ animation can run the identical curve without hand-deriving it; springs
207
+ accept mass, stiffness, damping, and an
193
208
  explicit normalized velocity or `'inherit'` (the default). Keyframes accept
194
- validated, monotonically increasing offsets from zero through one.
209
+ validated, monotonically increasing offsets from zero through one. Every
210
+ kind accepts an optional `from` presentation — a fused take-ownership hot
211
+ write issued immediately before the handoff, so the animation starts from
212
+ exactly that value (pass `frames[0].presentation` for keyframes, which
213
+ interpolate absolutely). A non-finite `from` rejects the whole call; against
214
+ a held pending-animation latch the seed is dropped — the latch is newer
215
+ intent, and nothing is displayable yet anyway. `from` behaves the same on
216
+ both platforms (it is driver-layer, not native): on iOS the seed stops any
217
+ running Core Animation and writes the model layer first; keyframes then
218
+ start exactly at `from`, while timing/spring sample their from-value off
219
+ the presentation layer — the last committed frame, at most one frame
220
+ behind `from` — identical to the explicit two-call pattern.
195
221
  - `cancel()` freezes visible presentation by default. Pass `'target'` as its
196
222
  behavior to jump to the requested endpoint.
197
- - `options.onAnimationComplete` fires once per animation with its ID and
198
- `finished` state, including cancellation, replacement, participant unmount,
199
- and native-side rejection (`animateTo` then returns `0` and one
200
- `finished: false` completion is delivered).
201
- - An `animateTo` issued before any host view has registered (for example from
202
- an effect in the same commit that mounts the host, or inside a modal route
203
- that attaches late) is held pending and starts with its full duration when
204
- the first view registers. If no view ever registers, the pending animation
205
- survives until it is replaced, cancelled, overridden by a take-ownership
206
- write, or the driver is destroyed — at which point its single
223
+ - `options.onAnimationComplete` fires exactly once per animation with its ID
224
+ and `finished` state, including cancellation, replacement, and native-side
225
+ rejection (`animateTo` then returns a fresh non-zero id whose single
226
+ `finished: false` completion follows — key completion handling by the
227
+ returned id, never by `0`). `animateTo` returns `0` — and delivers no
228
+ completion — only when the driver entry no longer exists (destroyed) or the
229
+ call ran off the main thread. Unmounting the last host mid-flight does not complete the
230
+ animation: the remainder is re-latched and the next displayable host resumes
231
+ it, so the completion arrives when it finishes there — or unfinished on
232
+ replacement, cancellation, `beginInteraction()`, or driver destruction.
233
+ - An `animateTo` issued before any host view can produce a visible frame (for
234
+ example from an effect in the same commit that mounts the host, or inside a
235
+ modal route whose subtree attaches to its window late) is held pending and
236
+ starts with its full duration at the first displayable registration or
237
+ window attach. A pending animation owns the driver: take-ownership writes
238
+ (`set`, `setScalars`, the hook's seed) are dropped while it is held —
239
+ replace it with another `animateTo`, or cancel it via `beginInteraction()`
240
+ or `cancel()`. If no view ever becomes displayable, it survives until it is
241
+ replaced, cancelled, or the driver is destroyed — at which point its single
207
242
  `finished: false` completion is delivered.
208
243
 
209
244
  Do not call `driver.ui` from React code — it throws on the React runtime; use
@@ -16,6 +16,10 @@ file(GLOB smoothclip_SOURCES CONFIGURE_DEPENDS
16
16
 
17
17
  add_library(smoothclipview SHARED ${smoothclip_SOURCES})
18
18
 
19
+ # 16 KB page-size support for NDKs where ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES
20
+ # is unavailable (< r26.3) or ignored.
21
+ target_link_options(smoothclipview PRIVATE "-Wl,-z,max-page-size=16384")
22
+
19
23
  # C++20, RTTI, exceptions and React Native macros.
20
24
  include("${REACT_NATIVE_DIR}/ReactCommon/cmake-utils/react-native-flags.cmake")
21
25
  target_compile_reactnative_options(smoothclipview PUBLIC)
@@ -86,6 +86,7 @@ android {
86
86
  cmake {
87
87
  cppFlags "-O2", "-frtti", "-fexceptions", "-Wall", "-std=c++20"
88
88
  arguments "-DANDROID_STL=c++_shared",
89
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
89
90
  "-DREACT_NATIVE_DIR=${reactNativeRootDir}"
90
91
  abiFilters(*reactNativeArchitectures())
91
92
  }
@@ -22,6 +22,7 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
22
22
  const NormalizedClip &clip,
23
23
  double contentTranslateXPx,
24
24
  double contentTranslateYPx) const;
25
+ bool isViewAttachedToWindow() const;
25
26
  };
26
27
 
27
28
  // Called from Kotlin (SmoothClipViewManager) on the UI thread when a Fabric
@@ -44,6 +45,13 @@ void setViewHostGeometryAndroid(
44
45
  void unregisterViewAndroid(
45
46
  uint64_t driverId,
46
47
  facebook::jni::alias_ref<JSmoothClipView> view);
48
+ // Called from Kotlin when a registered view attaches to a window. A latched
49
+ // animation may only start once a registered view can actually produce a
50
+ // visible frame (attached AND has host geometry); otherwise its clock would
51
+ // burn progress no one can see.
52
+ void viewBecameDisplayableAndroid(
53
+ uint64_t driverId,
54
+ facebook::jni::alias_ref<JSmoothClipView> view);
47
55
 
48
56
  // Installs `global.__SmoothClipView` (worklet-callable host functions) into the
49
57
  // JS runtime and wires native animation completion delivery through the
@@ -406,6 +406,15 @@ void nativeUnregisterView(
406
406
  smoothclip::unregisterViewAndroid(static_cast<uint64_t>(driverId), view);
407
407
  }
408
408
 
409
+ void nativeViewBecameDisplayable(
410
+ jni::alias_ref<jni::JObject>,
411
+ jdouble driverId,
412
+ jni::alias_ref<smoothclip::JSmoothClipView> view) {
413
+ if (driverId <= 0 || !std::isfinite(driverId)) return;
414
+ smoothclip::viewBecameDisplayableAndroid(
415
+ static_cast<uint64_t>(driverId), view);
416
+ }
417
+
409
418
  void nativeInvalidate(jni::alias_ref<jni::JObject>) {
410
419
  smoothclip::invalidateBindings();
411
420
  }
@@ -422,6 +431,8 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
422
431
  makeNativeMethod(
423
432
  "nativeSetViewHostGeometry", nativeSetViewHostGeometry),
424
433
  makeNativeMethod("nativeUnregisterView", nativeUnregisterView),
434
+ makeNativeMethod(
435
+ "nativeViewBecameDisplayable", nativeViewBecameDisplayable),
425
436
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
426
437
  });
427
438
  });
@@ -1,4 +1,5 @@
1
1
  #include "SmoothClipAndroid.h"
2
+ #include "SmoothClipVelocityTracker.h"
2
3
 
3
4
  #include <android/choreographer.h>
4
5
  #include <fbjni/fbjni.h>
@@ -42,6 +43,11 @@ struct ActiveAnimation {
42
43
  std::vector<Keyframe> keyframes;
43
44
  double durationS = 0;
44
45
  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;
45
51
  // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
46
52
  std::array<double, 7> springPosition{};
47
53
  std::array<double, 7> springVelocity{};
@@ -51,6 +57,10 @@ struct ActiveAnimation {
51
57
  // registered, held un-rendered (and out of animatingDrivers()) until the
52
58
  // first registerViewAndroid rebases the clock and starts it.
53
59
  bool started = false;
60
+ // False until the first advance() translates the wall-clock start stamp
61
+ // onto the choreographer frame-time axis (elapsed-preserving). Every
62
+ // startedAtS stamp clears it so a re-latched resume re-anchors too.
63
+ bool frameClockAnchored = false;
54
64
  };
55
65
 
56
66
  // Per-view fanout state. Density and host metrics are pushed from Kotlin at
@@ -75,12 +85,9 @@ struct DriverState {
75
85
  // last view leaves and revived by a take-ownership setPresentation.
76
86
  bool destroyed = false;
77
87
 
78
- bool hasPreviousSample = false;
79
- bool hasLatestSample = false;
80
- Presentation previousSample{{0, 0, 0, 0, 0}, 0, 0};
81
- Presentation latestSample{{0, 0, 0, 0, 0}, 0, 0};
82
- double previousSampleTimeS = 0;
83
- double latestSampleTimeS = 0;
88
+ // 'inherit' velocity samples; recording/coalescing/projection live in the
89
+ // shared cpp/SmoothClipVelocityTracker.h (behavior-paired with iOS).
90
+ VelocitySampleHistory samples;
84
91
  };
85
92
 
86
93
  struct CompletionSink {
@@ -221,6 +228,10 @@ constexpr double kSpringSettleVelocity = 1.0;
221
228
  // Settle-based termination is backed by a hard cap so a pathological
222
229
  // configuration cannot run the frame loop forever.
223
230
  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;
224
235
 
225
236
  // Semi-implicit Euler per channel, substepped: one frame-sized step is only
226
237
  // stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
@@ -367,46 +378,6 @@ void applyToViews(DriverState &state, const Presentation &presentation) {
367
378
  }
368
379
  }
369
380
 
370
- void recordInteractiveSample(DriverState &state, const Presentation &presentation) {
371
- state.hasPreviousSample = state.hasLatestSample;
372
- state.previousSample = state.latestSample;
373
- state.previousSampleTimeS = state.latestSampleTimeS;
374
- state.hasLatestSample = true;
375
- state.latestSample = presentation;
376
- state.latestSampleTimeS = nowSeconds();
377
- }
378
-
379
- double inheritedVelocity(DriverState &state, const Presentation &target) {
380
- if (!state.hasPreviousSample || !state.hasLatestSample) return 0;
381
- const double elapsed = state.latestSampleTimeS - state.previousSampleTimeS;
382
- if (elapsed <= 0 || nowSeconds() - state.latestSampleTimeS > 0.1) return 0;
383
- const double sample[7] = {
384
- state.latestSample.clip.x - state.previousSample.clip.x,
385
- state.latestSample.clip.y - state.previousSample.clip.y,
386
- state.latestSample.clip.width - state.previousSample.clip.width,
387
- state.latestSample.clip.height - state.previousSample.clip.height,
388
- state.latestSample.clip.radius - state.previousSample.clip.radius,
389
- state.latestSample.contentTranslateX - state.previousSample.contentTranslateX,
390
- state.latestSample.contentTranslateY - state.previousSample.contentTranslateY};
391
- const double destination[7] = {
392
- target.clip.x - state.latestSample.clip.x,
393
- target.clip.y - state.latestSample.clip.y,
394
- target.clip.width - state.latestSample.clip.width,
395
- target.clip.height - state.latestSample.clip.height,
396
- target.clip.radius - state.latestSample.clip.radius,
397
- target.contentTranslateX - state.latestSample.contentTranslateX,
398
- target.contentTranslateY - state.latestSample.contentTranslateY};
399
- double numerator = 0;
400
- double denominator = 0;
401
- for (int index = 0; index < 7; index += 1) {
402
- numerator += sample[index] * destination[index];
403
- denominator += destination[index] * destination[index];
404
- }
405
- if (denominator <= 1e-12) return 0;
406
- const double result = numerator / elapsed / denominator;
407
- return std::isfinite(result) ? result : 0;
408
- }
409
-
410
381
  void applyPresentation(DriverState &state, const Presentation &presentation) {
411
382
  state.latest = presentation;
412
383
  state.hasLatest = true;
@@ -458,24 +429,62 @@ Presentation prepareAnimation(
458
429
  return resolvedStart;
459
430
  }
460
431
 
432
+ // A view can only render the animation once it is attached to a window and
433
+ // has pushed real host geometry; a clock started before that burns progress
434
+ // no one can see (the transparentModal mount pattern).
435
+ bool entryDisplayable(const ViewEntry &entry) {
436
+ return entry.hostWidthPx > 0 && entry.hostHeightPx > 0 &&
437
+ entry.view->isViewAttachedToWindow();
438
+ }
439
+
440
+ bool anyDisplayableView(const DriverState &state) {
441
+ for (const ViewEntry &entry : state.views) {
442
+ if (entryDisplayable(entry)) return true;
443
+ }
444
+ return false;
445
+ }
446
+
447
+ // Starts a latched animation: rebases the clock so no progress was burned
448
+ // while no view could display, then joins the choreographer loop.
449
+ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
450
+ auto &animation = *state.animation;
451
+ animation.started = true;
452
+ animation.startedAtS = nowSeconds();
453
+ animation.lastFrameS = animation.startedAtS;
454
+ animation.wallStartedAtS = animation.startedAtS;
455
+ // Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
456
+ // ActiveAnimation in place, so a stale anchor would replay the fraction-0
457
+ // first frame the anchor exists to remove.
458
+ animation.frameClockAnchored = false;
459
+ auto &active = animatingDrivers();
460
+ if (std::find(active.begin(), active.end(), driverId) == active.end()) {
461
+ active.push_back(driverId);
462
+ }
463
+ applyToViews(state, animation.start);
464
+ scheduleFrame();
465
+ }
466
+
461
467
  int32_t startAnimation(
462
468
  uint64_t driverId,
463
469
  DriverState &state,
464
470
  ActiveAnimation animation) {
465
471
  animation.startedAtS = nowSeconds();
466
472
  animation.lastFrameS = animation.startedAtS;
473
+ animation.wallStartedAtS = animation.startedAtS;
474
+ animation.frameClockAnchored = false;
467
475
  // current = start even while latched is load-bearing: cancelAnimation,
468
476
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
469
477
  // visible all read animation->current, giving a never-rendered latch
470
478
  // freeze-at-start / replace-from-start semantics with no extra branches.
471
479
  animation.current = animation.start;
472
- animation.started = !state.views.empty();
480
+ animation.started = anyDisplayableView(state);
473
481
  state.animation = std::move(animation);
474
482
  if (!state.animation->started) {
475
- // Latch: no host view yet (animateTo raced the mount). The first
476
- // registerViewAndroid rebases the clock, joins animatingDrivers() and
477
- // schedules the frame loop. Non-zero id is still returned so the JS
478
- // side does not treat this as rejection.
483
+ // Latch: no host view can display yet (animateTo raced the mount, or
484
+ // every host is detached/unsized). The first displayable registration,
485
+ // host-geometry push, or window attach rebases the clock, joins
486
+ // animatingDrivers() and schedules the frame loop. Non-zero id is still
487
+ // returned so the JS side does not treat this as rejection.
479
488
  return state.animation->id;
480
489
  }
481
490
  auto &active = animatingDrivers();
@@ -490,6 +499,38 @@ int32_t startAnimation(
490
499
  void advance(uint64_t driverId, DriverState &state, double now) {
491
500
  ActiveAnimation &animation = *state.animation;
492
501
 
502
+ 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;
514
+ animation.lastFrameS = animation.startedAtS;
515
+ 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;
532
+ }
533
+
493
534
  bool done = false;
494
535
  if (animation.kind == AnimationKind::Spring) {
495
536
  done = advanceSpring(animation, now);
@@ -603,12 +644,21 @@ void setPresentation(
603
644
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
604
645
  if (takeOwnership) {
605
646
  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) {
654
+ return;
655
+ }
606
656
  finishActive(driverId, state, false);
607
657
  }
608
658
  state.latest = presentation;
609
659
  state.hasLatest = true;
610
660
  state.ownership = Ownership::Interactive;
611
- recordInteractiveSample(state, presentation);
661
+ recordVelocitySample(state.samples, toChannels(presentation), nowSeconds());
612
662
  applyToViews(state, presentation);
613
663
  }
614
664
 
@@ -628,10 +678,7 @@ Presentation beginInteraction(uint64_t driverId) {
628
678
  state.latest = current;
629
679
  state.hasLatest = true;
630
680
  state.ownership = Ownership::Interactive;
631
- state.hasPreviousSample = false;
632
- state.hasLatestSample = true;
633
- state.latestSample = current;
634
- state.latestSampleTimeS = nowSeconds();
681
+ resetVelocitySamples(state.samples, toChannels(current), nowSeconds());
635
682
  applyToViews(state, current);
636
683
  emitCompletion(driverId, animationId, false);
637
684
  return current;
@@ -680,7 +727,7 @@ int32_t animateSpring(
680
727
  // seeded with velocity·displacement to match the iOS CASpringAnimation
681
728
  // per-keypath behavior.
682
729
  const double velocity = animation.inheritVelocity
683
- ? inheritedVelocity(state, presentation)
730
+ ? inheritedVelocity(state.samples, toChannels(presentation), nowSeconds())
684
731
  : animation.initialVelocity;
685
732
  const Presentation resolvedStart =
686
733
  prepareAnimation(driverId, state, start, presentation);
@@ -795,6 +842,12 @@ void destroyDriver(uint64_t driverId) {
795
842
 
796
843
  // --- Android view lifecycle ----------------------------------------------
797
844
 
845
+ bool JSmoothClipView::isViewAttachedToWindow() const {
846
+ static const auto method =
847
+ javaClassStatic()->getMethod<jboolean()>("isAttachedToWindow");
848
+ return method(self());
849
+ }
850
+
798
851
  void JSmoothClipView::applyClip(const Presentation &presentation) const {
799
852
  static const auto method =
800
853
  javaClassStatic()
@@ -849,15 +902,16 @@ void registerViewAndroid(
849
902
  ? state.animation->current
850
903
  : state.latest;
851
904
  JNIEnv *env = facebook::jni::Environment::current();
852
- // The existing-view branch below is unreachable while an animation is
853
- // latched (a latch implies views was empty at animate time), so only the
854
- // new-view path needs to start latches.
855
905
  for (auto &existing : state.views) {
856
906
  if (env->IsSameObject(existing.view.get(), view.get())) {
857
907
  existing.density = density;
858
908
  existing.hostWidthPx = hostWidthPx;
859
909
  existing.hostHeightPx = hostHeightPx;
860
910
  deliverToView(existing, visible);
911
+ if (state.animation.has_value() && !state.animation->started &&
912
+ entryDisplayable(existing)) {
913
+ startLatchedAnimation(driverId, state);
914
+ }
861
915
  return;
862
916
  }
863
917
  }
@@ -867,22 +921,17 @@ void registerViewAndroid(
867
921
  // registering view — the correct first frame for a latched animation.
868
922
  deliverToView(entry, visible);
869
923
  state.views.push_back(std::move(entry));
870
- if (state.animation.has_value() && !state.animation->started) {
924
+ if (state.animation.has_value() && !state.animation->started &&
925
+ entryDisplayable(state.views.back())) {
871
926
  // Start the latched animation: rebase the clock so the first frame
872
- // integrates from now, not from the pre-mount animateTo call.
927
+ // integrates from now, not from the pre-mount animateTo call. Gated on
928
+ // displayability — a mount-time registration from a detached subtree
929
+ // holds the latch until window attach / first host geometry.
873
930
  // Note this function has no isOnMainThread() guard (Kotlin calls it from
874
931
  // the view's main-thread attach path); scheduleFrame() is itself
875
932
  // main-thread-gated, so an off-main register cannot bind the
876
933
  // choreographer to the wrong thread.
877
- auto &animation = *state.animation;
878
- animation.started = true;
879
- animation.startedAtS = nowSeconds();
880
- animation.lastFrameS = animation.startedAtS;
881
- auto &active = animatingDrivers();
882
- if (std::find(active.begin(), active.end(), driverId) == active.end()) {
883
- active.push_back(driverId);
884
- }
885
- scheduleFrame();
934
+ startLatchedAnimation(driverId, state);
886
935
  }
887
936
  }
888
937
 
@@ -908,6 +957,29 @@ void setViewHostGeometryAndroid(
908
957
  state.animation.has_value() ? state.animation->current
909
958
  : state.latest);
910
959
  }
960
+ if (state.animation.has_value() && !state.animation->started &&
961
+ entryDisplayable(entry)) {
962
+ // First real host geometry made this view displayable.
963
+ startLatchedAnimation(driverId, state);
964
+ }
965
+ return;
966
+ }
967
+ }
968
+
969
+ void viewBecameDisplayableAndroid(
970
+ uint64_t driverId,
971
+ alias_ref<JSmoothClipView> view) {
972
+ if (!isOnMainThread()) return;
973
+ auto iterator = registry().find(driverId);
974
+ if (iterator == registry().end()) return;
975
+ auto &state = iterator->second;
976
+ if (!state.animation.has_value() || state.animation->started) return;
977
+ JNIEnv *env = facebook::jni::Environment::current();
978
+ for (auto &entry : state.views) {
979
+ if (!env->IsSameObject(entry.view.get(), view.get())) continue;
980
+ if (entryDisplayable(entry)) {
981
+ startLatchedAnimation(driverId, state);
982
+ }
911
983
  return;
912
984
  }
913
985
  }
@@ -930,11 +1002,63 @@ void unregisterViewAndroid(
930
1002
  }
931
1003
  if (removed && state.animation.has_value()) {
932
1004
  state.animation->finished = false;
933
- if (state.views.empty()) {
934
- // The animation ends here; release ownership so a later remount's
935
- // interactive updates are not dropped.
936
- state.ownership = Ownership::Interactive;
937
- finishActive(driverId, state, false);
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()) {
1010
+ auto &animation = *state.animation;
1011
+ // wallStartedAtS, not startedAtS: post-anchor the latter lives on the
1012
+ // shifted frame axis and would overstate elapsed by the first
1013
+ // callback's dispatch latency, shortening the resumed remainder and
1014
+ // advancing the keyframe rebase past the rendered position.
1015
+ const double elapsedS = nowSeconds() - animation.wallStartedAtS;
1016
+ const double remainingS = std::max(0.0, animation.durationS - elapsedS);
1017
+ const bool canResume = !state.destroyed &&
1018
+ (animation.kind == AnimationKind::Spring || remainingS > 0);
1019
+ if (!canResume) {
1020
+ // The animation ends here; release ownership so a later remount's
1021
+ // interactive updates are not dropped.
1022
+ state.ownership = Ownership::Interactive;
1023
+ finishActive(driverId, state, false);
1024
+ } else {
1025
+ // The last rendering host left mid-flight (remount, screen swap).
1026
+ // Completing here would leave `latest` at the target and statically
1027
+ // snap any re-registering host straight to it. Re-latch instead:
1028
+ // freeze the remaining animation at its current geometry so the
1029
+ // next displayable host resumes it with the true remaining time.
1030
+ // destroyDriver cancels a latch that never finds a host, so the
1031
+ // completion cannot hang.
1032
+ if (animation.kind == AnimationKind::Timing) {
1033
+ animation.durationS = remainingS;
1034
+ } else if (animation.kind == AnimationKind::Keyframes) {
1035
+ const double progress = animation.durationS <= 0
1036
+ ? 1.0
1037
+ : clamp01(elapsedS / animation.durationS);
1038
+ std::vector<Keyframe> remaining;
1039
+ remaining.push_back({0, animation.current});
1040
+ for (const Keyframe &frame : animation.keyframes) {
1041
+ if (frame.offset <= progress) continue;
1042
+ remaining.push_back(
1043
+ {(frame.offset - progress) / (1 - progress),
1044
+ frame.presentation});
1045
+ }
1046
+ if (remaining.size() == 1) {
1047
+ remaining.push_back({1, animation.target});
1048
+ }
1049
+ animation.keyframes = std::move(remaining);
1050
+ animation.durationS = remainingS;
1051
+ }
1052
+ animation.start = animation.current;
1053
+ animation.started = false;
1054
+ // A held latch must not tick: leave animatingDrivers() without
1055
+ // resetting the animation (the eager-removal rule the choreographer
1056
+ // loop depends on).
1057
+ auto &active = animatingDrivers();
1058
+ active.erase(
1059
+ std::remove(active.begin(), active.end(), driverId),
1060
+ active.end());
1061
+ }
938
1062
  }
939
1063
  }
940
1064
  if (state.destroyed && state.views.empty()) {
@@ -47,6 +47,13 @@ internal object SmoothClipBindings {
47
47
 
48
48
  external fun nativeUnregisterView(driverId: Double, view: SmoothClipView)
49
49
 
50
+ /**
51
+ * Notifies the registry that a registered view attached to a window; a
52
+ * latched animation may only start once a view can produce a visible
53
+ * frame (attached and with real host geometry).
54
+ */
55
+ external fun nativeViewBecameDisplayable(driverId: Double, view: SmoothClipView)
56
+
50
57
  /**
51
58
  * Releases runtime-bound listener state during host teardown; must run
52
59
  * while the JS runtime is still alive (module invalidate).
@@ -232,6 +232,16 @@ class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
232
232
  }
233
233
  }
234
234
 
235
+ override fun onAttachedToWindow() {
236
+ super.onAttachedToWindow()
237
+ if (boundDriverId != 0.0) {
238
+ // A latched animation may only start once this view can produce a
239
+ // visible frame; window attach is that signal for views that
240
+ // registered from a detached subtree.
241
+ SmoothClipBindings.nativeViewBecameDisplayable(boundDriverId, this)
242
+ }
243
+ }
244
+
235
245
  override fun onConfigurationChanged(newConfig: Configuration) {
236
246
  super.onConfigurationChanged(newConfig)
237
247
  if (boundDriverId != 0.0) {