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

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 (44) hide show
  1. package/README.md +52 -14
  2. package/android/CMakeLists.txt +4 -0
  3. package/android/build.gradle +1 -0
  4. package/android/src/main/cpp/SmoothClipRegistry.cpp +72 -53
  5. package/cpp/SmoothClipVelocityTracker.h +101 -0
  6. package/ios/SmoothClipView.mm +32 -74
  7. package/lib/module/drivers.android.js +3 -1
  8. package/lib/module/drivers.android.js.map +1 -1
  9. package/lib/module/drivers.ios.js +3 -377
  10. package/lib/module/drivers.ios.js.map +1 -1
  11. package/lib/module/drivers.js +8 -0
  12. package/lib/module/drivers.js.map +1 -1
  13. package/lib/module/drivers.native.js +401 -0
  14. package/lib/module/drivers.native.js.map +1 -0
  15. package/lib/module/easings.js +35 -0
  16. package/lib/module/easings.js.map +1 -0
  17. package/lib/module/index.js +1 -0
  18. package/lib/module/index.js.map +1 -1
  19. package/lib/module/index.web.js +1 -0
  20. package/lib/module/index.web.js.map +1 -1
  21. package/lib/typescript/src/driverTypes.d.ts +34 -3
  22. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  23. package/lib/typescript/src/drivers.android.d.ts +2 -2
  24. package/lib/typescript/src/drivers.android.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 +2 -4
  27. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  28. package/lib/typescript/src/drivers.native.d.ts +5 -0
  29. package/lib/typescript/src/drivers.native.d.ts.map +1 -0
  30. package/lib/typescript/src/easings.d.ts +33 -0
  31. package/lib/typescript/src/easings.d.ts.map +1 -0
  32. package/lib/typescript/src/index.d.ts +1 -0
  33. package/lib/typescript/src/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.web.d.ts +1 -0
  35. package/lib/typescript/src/index.web.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/driverTypes.ts +53 -19
  38. package/src/drivers.android.ts +4 -2
  39. package/src/drivers.ios.ts +4 -661
  40. package/src/drivers.native.ts +698 -0
  41. package/src/drivers.ts +11 -0
  42. package/src/easings.ts +32 -0
  43. package/src/index.ts +1 -0
  44. package/src/index.web.ts +1 -0
package/README.md CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/react-native-smooth-clip-view.svg)](https://www.npmjs.com/package/react-native-smooth-clip-view)
4
4
 
5
- Layout-free animated rounded clipping for React Native Fabric.
5
+ ## High-performance geometry animations for React Native
6
6
 
7
- `SmoothClipView` keeps a fixed maximum Yoga footprint while a reusable driver
8
- updates only the native clipping layer. It is
9
- useful for expanding cards, sheets, maps, media, zoom transitions, and other reveals where
10
- animating layout width and height cause lag because of yoga/fabric calculating layout every frame.
7
+ `react-native-smooth-clip-view` lets you animate `x`, `y`, `width`, `height`, and `borderRadius` with Reanimated without triggering expensive layout work on every frame.
11
8
 
12
- In other words, it makes animating width height on Reanimated a cheap operation.
9
+ Instead of resizing the Yoga layout, `SmoothClipView` keeps a fixed footprint and updates only the native clipping layer. This makes geometry-heavy animations smooth and inexpensive—even for shared-element transitions, zoom transitions, expanding cards, reveals, sheets, maps, and media.
10
+
11
+ Use it for transitions that previously struggled with performance when animating layout dimensions directly.
13
12
 
14
13
  ## Requirements
15
14
 
@@ -141,10 +140,16 @@ const gesture = Gesture.Pan()
141
140
  // bookkeeping. Assigning driver.presentation.value also works.
142
141
  driver.ui.setScalars(clip.x, clip.y, clip.width, clip.height, clip.radius, 0, 0);
143
142
  })
144
- .onEnd(() => {
143
+ .onEnd((event) => {
144
+ // The release event is fresher than the last onUpdate (on Android,
145
+ // ACTION_UP carries a position no MOVE ever delivered). Pass the final
146
+ // geometry as `from` so the animation starts from exactly that value —
147
+ // it fuses a setScalars hot write with the handoff in one call.
148
+ const clip = geometryForDrag(dragStart.value, event.translationY);
145
149
  driver.ui.animateTo(createClipPresentation(expandedClip), {
146
150
  type: 'spring',
147
151
  initialVelocity: 'inherit',
152
+ from: createClipPresentation(clip),
148
153
  });
149
154
  });
150
155
  ```
@@ -175,32 +180,65 @@ const gesture = Gesture.Pan()
175
180
  streams such as gestures. `driver.presentation.value` is stale after hot
176
181
  writes by design; `beginInteraction()` remains the source of truth for
177
182
  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.
183
+ registry's latest value rather than the stale SharedValue. To start from a
184
+ value fresher than the last hot write (a gesture's release sample), pass it
185
+ as `animation.from` — `animateTo` then performs the hot write and the
186
+ handoff in one call. Do not interleave `setScalars` with
187
+ `presentation.value` writes on the same driver.
180
188
  - Spring `initialVelocity` is one normalized scalar along the current-to-target
181
189
  trajectory, in units of the remaining distance per second (`1` covers the
182
190
  remaining distance in one second). Every geometry channel continues with the
183
191
  same normalized rate, so grab/release preserves the felt direction and
184
192
  speed. `'inherit'` (the default) estimates the scalar from the last two
185
193
  interactive samples on iOS and Android; samples older than 100 ms — and the
186
- web fallback — fall back to zero.
194
+ web fallback — fall back to zero. Two writes landing inside the same frame
195
+ (< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
196
+ write) coalesce into one sample, and an identical re-write is ignored, so a
197
+ fused handoff can neither zero nor inflate the inherited velocity.
198
+ `beginInteraction()` records the frozen presentation as a plain sample on
199
+ both platforms, so a grab-and-instant-refling inherits bounded recent
200
+ motion (the staleness guard still zeroes a stale pair) instead of
201
+ launching dead.
187
202
  - `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
188
203
  as Promises (`setScalars` is UI-worklet-only). React code never blocks
189
204
  waiting for main/UI-thread work. An immediate animation request resolves
190
205
  before its completion callback is delivered.
191
206
  - `animateTo()` transfers ownership to native animation. Timing uses
192
- cubic Bézier control points; springs accept mass, stiffness, damping, and an
207
+ cubic Bézier control points — `ClipEasings` exports exact-form presets
208
+ (`easeOutCubic` = `Easing.out(Easing.cubic)` etc.) so a parallel Reanimated
209
+ animation can run the identical curve without hand-deriving it; springs
210
+ accept mass, stiffness, damping, and an
193
211
  explicit normalized velocity or `'inherit'` (the default). Keyframes accept
194
- validated, monotonically increasing offsets from zero through one.
212
+ validated, monotonically increasing offsets from zero through one; there is
213
+ deliberately no keyframe easing field — playback is linear between offsets
214
+ and the frames encode the curve, which also expresses per-channel-nonlinear
215
+ paths that no single time-warp could reproduce. Every
216
+ kind accepts an optional `from` presentation — a fused take-ownership hot
217
+ write issued immediately before the handoff, so the animation starts from
218
+ exactly that value (pass `frames[0].presentation` for keyframes, which
219
+ interpolate absolutely). A non-finite `from` rejects the whole call; against
220
+ a held pending-animation latch the seed is dropped — the latch is newer
221
+ intent, and nothing is displayable yet anyway. `from` behaves the same on
222
+ both platforms (it is driver-layer, not native): on iOS the seed stops any
223
+ running Core Animation and writes the model layer first; keyframes then
224
+ start exactly at `from`, while timing/spring sample their from-value off
225
+ the presentation layer — the last committed frame, at most one frame
226
+ behind `from` — identical to the explicit two-call pattern.
195
227
  - `cancel()` freezes visible presentation by default. Pass `'target'` as its
196
228
  behavior to jump to the requested endpoint.
197
229
  - `options.onAnimationComplete` fires exactly once per animation with its ID
198
230
  and `finished` state, including cancellation, replacement, and native-side
199
- rejection (`animateTo` then returns `0` and one `finished: false` completion
200
- is delivered). Unmounting the last host mid-flight does not complete the
231
+ rejection (`animateTo` then returns a fresh non-zero id whose single
232
+ `finished: false` completion follows — key completion handling by the
233
+ returned id, never by `0`). `animateTo` returns `0` — and delivers no
234
+ completion — only when the driver entry no longer exists (destroyed) or the
235
+ call ran off the main thread. Unmounting the last host mid-flight does not complete the
201
236
  animation: the remainder is re-latched and the next displayable host resumes
202
237
  it, so the completion arrives when it finishes there — or unfinished on
203
238
  replacement, cancellation, `beginInteraction()`, or driver destruction.
239
+ With multiple hosts on one driver, unregistering any participant mid-flight
240
+ makes the eventual completion `finished: false` — `finished: true` means
241
+ every participant ran the animation to its end.
204
242
  - An `animateTo` issued before any host view can produce a visible frame (for
205
243
  example from an effect in the same commit that mounts the host, or inside a
206
244
  modal route whose subtree attaches to its window late) is held pending and
@@ -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
  }
@@ -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,13 @@ 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
+ // 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;
224
238
 
225
239
  // Semi-implicit Euler per channel, substepped: one frame-sized step is only
226
240
  // stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
@@ -367,46 +381,6 @@ void applyToViews(DriverState &state, const Presentation &presentation) {
367
381
  }
368
382
  }
369
383
 
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
384
  void applyPresentation(DriverState &state, const Presentation &presentation) {
411
385
  state.latest = presentation;
412
386
  state.hasLatest = true;
@@ -480,6 +454,11 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
480
454
  animation.started = true;
481
455
  animation.startedAtS = nowSeconds();
482
456
  animation.lastFrameS = animation.startedAtS;
457
+ animation.wallStartedAtS = animation.startedAtS;
458
+ // Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
459
+ // ActiveAnimation in place, so a stale anchor would replay the fraction-0
460
+ // first frame the anchor exists to remove.
461
+ animation.frameClockAnchored = false;
483
462
  auto &active = animatingDrivers();
484
463
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
485
464
  active.push_back(driverId);
@@ -494,6 +473,8 @@ int32_t startAnimation(
494
473
  ActiveAnimation animation) {
495
474
  animation.startedAtS = nowSeconds();
496
475
  animation.lastFrameS = animation.startedAtS;
476
+ animation.wallStartedAtS = animation.startedAtS;
477
+ animation.frameClockAnchored = false;
497
478
  // current = start even while latched is load-bearing: cancelAnimation,
498
479
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
499
480
  // visible all read animation->current, giving a never-rendered latch
@@ -521,6 +502,38 @@ int32_t startAnimation(
521
502
  void advance(uint64_t driverId, DriverState &state, double now) {
522
503
  ActiveAnimation &animation = *state.animation;
523
504
 
505
+ 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;
517
+ animation.lastFrameS = animation.startedAtS;
518
+ 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
+ }
536
+
524
537
  bool done = false;
525
538
  if (animation.kind == AnimationKind::Spring) {
526
539
  done = advanceSpring(animation, now);
@@ -648,7 +661,7 @@ void setPresentation(
648
661
  state.latest = presentation;
649
662
  state.hasLatest = true;
650
663
  state.ownership = Ownership::Interactive;
651
- recordInteractiveSample(state, presentation);
664
+ recordVelocitySample(state.samples, toChannels(presentation), nowSeconds());
652
665
  applyToViews(state, presentation);
653
666
  }
654
667
 
@@ -668,10 +681,12 @@ Presentation beginInteraction(uint64_t driverId) {
668
681
  state.latest = current;
669
682
  state.hasLatest = true;
670
683
  state.ownership = Ownership::Interactive;
671
- state.hasPreviousSample = false;
672
- state.hasLatestSample = true;
673
- state.latestSample = current;
674
- state.latestSampleTimeS = nowSeconds();
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());
675
690
  applyToViews(state, current);
676
691
  emitCompletion(driverId, animationId, false);
677
692
  return current;
@@ -720,7 +735,7 @@ int32_t animateSpring(
720
735
  // seeded with velocity·displacement to match the iOS CASpringAnimation
721
736
  // per-keypath behavior.
722
737
  const double velocity = animation.inheritVelocity
723
- ? inheritedVelocity(state, presentation)
738
+ ? inheritedVelocity(state.samples, toChannels(presentation), nowSeconds())
724
739
  : animation.initialVelocity;
725
740
  const Presentation resolvedStart =
726
741
  prepareAnimation(driverId, state, start, presentation);
@@ -1001,7 +1016,11 @@ void unregisterViewAndroid(
1001
1016
  // it). Completing it here would betray the pending intent.
1002
1017
  } else if (state.views.empty()) {
1003
1018
  auto &animation = *state.animation;
1004
- const double elapsedS = nowSeconds() - animation.startedAtS;
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;
1005
1024
  const double remainingS = std::max(0.0, animation.durationS - elapsedS);
1006
1025
  const bool canResume = !state.destroyed &&
1007
1026
  (animation.kind == AnimationKind::Spring || remainingS > 0);
@@ -0,0 +1,101 @@
1
+ #pragma once
2
+
3
+ #include <array>
4
+ #include <cmath>
5
+
6
+ namespace smoothclip {
7
+
8
+ // The two most recent interactive samples used by `initialVelocity: 'inherit'`.
9
+ // Channels are the seven presentation scalars (x, y, width, height, radius,
10
+ // contentTranslateX, contentTranslateY) but the tracker is value-agnostic:
11
+ // iOS records normalized (host-clamped) geometry, Android raw DIP — the
12
+ // projection only needs both samples and the target in the same space.
13
+ // Shared by ios/SmoothClipView.mm and android/.../SmoothClipRegistry.cpp; a
14
+ // behavior change here changes both platforms.
15
+ struct VelocitySampleHistory {
16
+ bool hasPrevious = false;
17
+ bool hasLatest = false;
18
+ std::array<double, 7> previous{};
19
+ std::array<double, 7> latest{};
20
+ double previousTimeS = 0;
21
+ double latestTimeS = 0;
22
+ };
23
+
24
+ // Two records closer than this are one observation of the same frame (an
25
+ // ACTION_UP adoption or an animateTo `from` seed lands in the same input
26
+ // batch as the last drag write, ~1 ms apart). The window sits well above
27
+ // batch spacing and below the 8.33 ms 120 Hz frame, so legitimate
28
+ // consecutive-frame samples never coalesce.
29
+ inline constexpr double kSampleCoalesceWindowS = 0.004;
30
+ // A latest sample older than this no longer describes the finger; 'inherit'
31
+ // yields zero instead of replaying stale motion.
32
+ inline constexpr double kVelocityStalenessS = 0.1;
33
+
34
+ inline void recordVelocitySample(
35
+ VelocitySampleHistory &history,
36
+ const std::array<double, 7> &channels,
37
+ double nowS) {
38
+ if (history.hasLatest && channels == history.latest) {
39
+ // Identical re-record (a `from` seed equal to the last interactive
40
+ // write): keep the pair and its timestamps, so the last real motion
41
+ // still ages out through the staleness guard instead of zeroing the
42
+ // sample deltas.
43
+ return;
44
+ }
45
+ if (history.hasLatest &&
46
+ nowS - history.latestTimeS < kSampleCoalesceWindowS) {
47
+ // Same-frame re-record: replace the latest sample in place instead of
48
+ // rotating, so the surviving pair spans a real frame interval. Rotating
49
+ // here would divide the extra sub-frame displacement by microseconds and
50
+ // explode the inherited velocity.
51
+ history.latest = channels;
52
+ history.latestTimeS = nowS;
53
+ return;
54
+ }
55
+ history.hasPrevious = history.hasLatest;
56
+ history.previous = history.latest;
57
+ history.previousTimeS = history.latestTimeS;
58
+ history.hasLatest = true;
59
+ history.latest = channels;
60
+ history.latestTimeS = nowS;
61
+ }
62
+
63
+ // beginInteraction on both platforms records the frozen presentation as a
64
+ // plain sample: a grab mid-animation pairs the frozen value with the last
65
+ // real sample, so an instant refling inherits bounded recent motion instead
66
+ // of launching dead, and a grab at an unchanged value dedupes to a no-op
67
+ // whose staleness keeps aging. There is deliberately no reset primitive —
68
+ // the 100 ms staleness guard is the forgetting mechanism.
69
+
70
+ inline void clearVelocitySamples(VelocitySampleHistory &history) {
71
+ history = VelocitySampleHistory{};
72
+ }
73
+
74
+ // Normalized velocity of the recorded motion projected onto the
75
+ // latest→target trajectory, in units of remaining-distance per second
76
+ // (1 covers the remaining distance in one second) — the CASpringAnimation
77
+ // initialVelocity convention. iOS passes the scalar unchanged per keypath;
78
+ // Android seeds each integrator channel with scalar·displacement.
79
+ inline double inheritedVelocity(
80
+ const VelocitySampleHistory &history,
81
+ const std::array<double, 7> &target,
82
+ double nowS) {
83
+ if (!history.hasPrevious || !history.hasLatest) return 0;
84
+ const double elapsed = history.latestTimeS - history.previousTimeS;
85
+ if (elapsed <= 0 || nowS - history.latestTimeS > kVelocityStalenessS) {
86
+ return 0;
87
+ }
88
+ double numerator = 0;
89
+ double denominator = 0;
90
+ for (int index = 0; index < 7; index += 1) {
91
+ const double sampleDelta = history.latest[index] - history.previous[index];
92
+ const double destinationDelta = target[index] - history.latest[index];
93
+ numerator += sampleDelta * destinationDelta;
94
+ denominator += destinationDelta * destinationDelta;
95
+ }
96
+ if (denominator <= 1e-12) return 0;
97
+ const double result = numerator / elapsed / denominator;
98
+ return std::isfinite(result) ? result : 0;
99
+ }
100
+
101
+ } // namespace smoothclip
@@ -3,6 +3,8 @@
3
3
  #import "SmoothClipGeometry.h"
4
4
  #import "SmoothClipViewRegistry.h"
5
5
 
6
+ #include "SmoothClipVelocityTracker.h"
7
+
6
8
  #import <QuartzCore/QuartzCore.h>
7
9
  #import <React/RCTConversions.h>
8
10
  #import <React/RCTFabricComponentsPlugins.h>
@@ -48,6 +50,17 @@ using namespace facebook::react;
48
50
  finished:(BOOL)finished;
49
51
  @end
50
52
 
53
+ static std::array<double, 7> SmoothClipVelocityChannels(
54
+ CGRect rect, CGFloat radius, CGPoint contentTranslation) {
55
+ return {CGRectGetMinX(rect),
56
+ CGRectGetMinY(rect),
57
+ CGRectGetWidth(rect),
58
+ CGRectGetHeight(rect),
59
+ radius,
60
+ contentTranslation.x,
61
+ contentTranslation.y};
62
+ }
63
+
51
64
  @implementation SmoothClipView {
52
65
  SmoothClipContainerView *_clipContainer;
53
66
  SmoothClipContainerView *_contentContainer;
@@ -82,16 +95,10 @@ using namespace facebook::react;
82
95
  CGPoint _pausedContentTranslation;
83
96
  CFTimeInterval _pausedRemaining;
84
97
 
85
- BOOL _hasPreviousInteractiveSample;
86
- BOOL _hasInteractiveSample;
87
- CGRect _previousInteractiveRect;
88
- CGRect _interactiveRect;
89
- CGFloat _previousInteractiveRadius;
90
- CGFloat _interactiveRadius;
91
- CGPoint _previousInteractiveContentTranslation;
92
- CGPoint _interactiveContentTranslation;
93
- CFTimeInterval _previousInteractiveTime;
94
- CFTimeInterval _interactiveTime;
98
+ // 'inherit' velocity samples (normalized geometry, per view); recording/
99
+ // coalescing/projection live in the shared cpp/SmoothClipVelocityTracker.h
100
+ // (behavior-paired with Android's per-driver history).
101
+ smoothclip::VelocitySampleHistory _velocitySamples;
95
102
  }
96
103
 
97
104
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -144,8 +151,7 @@ using namespace facebook::react;
144
151
  _activeAnimationKind = 0;
145
152
  _pendingAnimationInstall = NO;
146
153
  _animationPaused = NO;
147
- _hasPreviousInteractiveSample = NO;
148
- _hasInteractiveSample = NO;
154
+ smoothclip::clearVelocitySamples(_velocitySamples);
149
155
  [[NSNotificationCenter defaultCenter]
150
156
  addObserver:self
151
157
  selector:@selector(applicationWillResignActive)
@@ -305,71 +311,24 @@ using namespace facebook::react;
305
311
  - (void)recordInteractiveRect:(CGRect)rect
306
312
  radius:(CGFloat)radius
307
313
  contentTranslation:(CGPoint)contentTranslation {
308
- if (_hasInteractiveSample &&
309
- CGRectEqualToRect(_interactiveRect, rect) &&
310
- _interactiveRadius == radius &&
311
- CGPointEqualToPoint(
312
- _interactiveContentTranslation, contentTranslation)) {
313
- return;
314
- }
315
- if (_hasInteractiveSample) {
316
- _hasPreviousInteractiveSample = YES;
317
- _previousInteractiveRect = _interactiveRect;
318
- _previousInteractiveRadius = _interactiveRadius;
319
- _previousInteractiveContentTranslation =
320
- _interactiveContentTranslation;
321
- _previousInteractiveTime = _interactiveTime;
322
- }
323
- _hasInteractiveSample = YES;
324
- _interactiveRect = rect;
325
- _interactiveRadius = radius;
326
- _interactiveContentTranslation = contentTranslation;
327
- _interactiveTime = CACurrentMediaTime();
314
+ // Identical-value dedupe and same-frame coalescing live in the shared
315
+ // tracker; coalescing keeps a distinct re-record issued sub-frame after
316
+ // the last one (e.g. a fused animateTo `from` seed at gesture release)
317
+ // from exploding the inherited velocity.
318
+ smoothclip::recordVelocitySample(
319
+ _velocitySamples,
320
+ SmoothClipVelocityChannels(rect, radius, contentTranslation),
321
+ CACurrentMediaTime());
328
322
  }
329
323
 
330
324
  - (CGFloat)inheritedVelocityToRect:(CGRect)target
331
325
  radius:(CGFloat)targetRadius
332
326
  contentTranslation:(CGPoint)targetContentTranslation {
333
- if (!_hasPreviousInteractiveSample || !_hasInteractiveSample) return 0;
334
- const CFTimeInterval elapsed =
335
- _interactiveTime - _previousInteractiveTime;
336
- if (elapsed <= 0 || CACurrentMediaTime() - _interactiveTime > 0.1) return 0;
337
-
338
- const CGFloat current[] = {
339
- CGRectGetMinX(_interactiveRect),
340
- CGRectGetMinY(_interactiveRect),
341
- CGRectGetWidth(_interactiveRect),
342
- CGRectGetHeight(_interactiveRect),
343
- _interactiveRadius,
344
- _interactiveContentTranslation.x,
345
- _interactiveContentTranslation.y};
346
- const CGFloat previous[] = {
347
- CGRectGetMinX(_previousInteractiveRect),
348
- CGRectGetMinY(_previousInteractiveRect),
349
- CGRectGetWidth(_previousInteractiveRect),
350
- CGRectGetHeight(_previousInteractiveRect),
351
- _previousInteractiveRadius,
352
- _previousInteractiveContentTranslation.x,
353
- _previousInteractiveContentTranslation.y};
354
- const CGFloat destination[] = {
355
- CGRectGetMinX(target),
356
- CGRectGetMinY(target),
357
- CGRectGetWidth(target),
358
- CGRectGetHeight(target),
359
- targetRadius,
360
- targetContentTranslation.x,
361
- targetContentTranslation.y};
362
- double numerator = 0;
363
- double denominator = 0;
364
- for (NSUInteger index = 0; index < 7; index += 1) {
365
- const double velocity = (current[index] - previous[index]) / elapsed;
366
- const double displacement = destination[index] - current[index];
367
- numerator += velocity * displacement;
368
- denominator += displacement * displacement;
369
- }
370
- if (denominator <= DBL_EPSILON) return 0;
371
- const double result = numerator / denominator;
372
- return isfinite(result) ? result : 0;
327
+ return smoothclip::inheritedVelocity(
328
+ _velocitySamples,
329
+ SmoothClipVelocityChannels(
330
+ target, targetRadius, targetContentTranslation),
331
+ CACurrentMediaTime());
373
332
  }
374
333
 
375
334
  - (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation {
@@ -1113,8 +1072,7 @@ using namespace facebook::react;
1113
1072
  [self stopLayerAnimationWithoutCallback];
1114
1073
  _pendingAnimationInstall = NO;
1115
1074
  _animationPaused = NO;
1116
- _hasPreviousInteractiveSample = NO;
1117
- _hasInteractiveSample = NO;
1075
+ smoothclip::clearVelocitySamples(_velocitySamples);
1118
1076
  [super prepareForRecycle];
1119
1077
  _requestedClip = CGRectZero;
1120
1078
  _normalizedClip = CGRectZero;
@@ -1,4 +1,6 @@
1
1
  "use strict";
2
2
 
3
- export { useSmoothClipDriver } from "./drivers.ios.js";
3
+ // Platform re-export only — the implementation is shared with iOS.
4
+ // Edit drivers.native.ts, not this file.
5
+ export { useSmoothClipDriver } from "./drivers.native.js";
4
6
  //# sourceMappingURL=drivers.android.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["useSmoothClipDriver"],"sourceRoot":"../../src","sources":["drivers.android.ts"],"mappings":";;AAAA,SAASA,mBAAmB,QAAQ,kBAAe","ignoreList":[]}
1
+ {"version":3,"names":["useSmoothClipDriver"],"sourceRoot":"../../src","sources":["drivers.android.ts"],"mappings":";;AAAA;AACA;AACA,SAASA,mBAAmB,QAAQ,qBAAkB","ignoreList":[]}