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

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 (113) hide show
  1. package/README.md +171 -43
  2. package/SmoothClipView.podspec +8 -2
  3. package/android/CMakeLists.txt +39 -0
  4. package/android/build.gradle +69 -2
  5. package/android/proguard-rules.pro +6 -0
  6. package/android/src/main/cpp/SmoothClipAndroid.h +59 -0
  7. package/android/src/main/cpp/SmoothClipBindings.cpp +428 -0
  8. package/android/src/main/cpp/SmoothClipRegistry.cpp +945 -0
  9. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +25 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +55 -0
  11. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +128 -0
  12. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +164 -30
  13. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +107 -10
  14. package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +18 -2
  15. package/cpp/SmoothClipRegistry.h +93 -0
  16. package/cpp/SmoothClipSharedGeometry.h +50 -0
  17. package/ios/SmoothClipModuleProvider.h +9 -0
  18. package/ios/SmoothClipModuleProvider.mm +16 -0
  19. package/ios/SmoothClipRegistry.mm +754 -0
  20. package/ios/SmoothClipTurboModule.cpp +290 -0
  21. package/ios/SmoothClipTurboModule.h +132 -0
  22. package/ios/SmoothClipView.mm +976 -85
  23. package/ios/SmoothClipViewRegistry.h +27 -0
  24. package/lib/module/NativeSmoothClipModule.js +5 -0
  25. package/lib/module/NativeSmoothClipModule.js.map +1 -0
  26. package/lib/module/SmoothClipView.android.js +4 -0
  27. package/lib/module/SmoothClipView.android.js.map +1 -0
  28. package/lib/module/SmoothClipView.ios.js +34 -0
  29. package/lib/module/SmoothClipView.ios.js.map +1 -0
  30. package/lib/module/SmoothClipView.js +26 -12
  31. package/lib/module/SmoothClipView.js.map +1 -1
  32. package/lib/module/SmoothClipView.web.js +28 -12
  33. package/lib/module/SmoothClipView.web.js.map +1 -1
  34. package/lib/module/SmoothClipViewNativeComponent.ts +14 -1
  35. package/lib/module/driverState.js +102 -0
  36. package/lib/module/driverState.js.map +1 -0
  37. package/lib/module/driverTypes.js +4 -0
  38. package/lib/module/driverTypes.js.map +1 -0
  39. package/lib/module/drivers.android.js +4 -0
  40. package/lib/module/drivers.android.js.map +1 -0
  41. package/lib/module/drivers.ios.js +380 -0
  42. package/lib/module/drivers.ios.js.map +1 -0
  43. package/lib/module/drivers.js +276 -0
  44. package/lib/module/drivers.js.map +1 -0
  45. package/lib/module/geometry.js +19 -0
  46. package/lib/module/geometry.js.map +1 -1
  47. package/lib/module/index.js +2 -0
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/index.web.js +2 -0
  50. package/lib/module/index.web.js.map +1 -1
  51. package/lib/module/nativeCompletion.js +21 -0
  52. package/lib/module/nativeCompletion.js.map +1 -0
  53. package/lib/module/reactRequests.js +50 -0
  54. package/lib/module/reactRequests.js.map +1 -0
  55. package/lib/module/smoothClipNative.android.js +15 -0
  56. package/lib/module/smoothClipNative.android.js.map +1 -0
  57. package/lib/module/smoothClipNative.js +8 -0
  58. package/lib/module/smoothClipNative.js.map +1 -0
  59. package/lib/typescript/src/NativeSmoothClipModule.d.ts +19 -0
  60. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -0
  61. package/lib/typescript/src/SmoothClipView.android.d.ts +3 -0
  62. package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -0
  63. package/lib/typescript/src/SmoothClipView.d.ts +5 -6
  64. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  65. package/lib/typescript/src/SmoothClipView.ios.d.ts +9 -0
  66. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -0
  67. package/lib/typescript/src/SmoothClipView.web.d.ts +5 -7
  68. package/lib/typescript/src/SmoothClipView.web.d.ts.map +1 -1
  69. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
  70. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  71. package/lib/typescript/src/driverState.d.ts +31 -0
  72. package/lib/typescript/src/driverState.d.ts.map +1 -0
  73. package/lib/typescript/src/driverTypes.d.ts +61 -0
  74. package/lib/typescript/src/driverTypes.d.ts.map +1 -0
  75. package/lib/typescript/src/drivers.android.d.ts +3 -0
  76. package/lib/typescript/src/drivers.android.d.ts.map +1 -0
  77. package/lib/typescript/src/drivers.d.ts +5 -0
  78. package/lib/typescript/src/drivers.d.ts.map +1 -0
  79. package/lib/typescript/src/drivers.ios.d.ts +5 -0
  80. package/lib/typescript/src/drivers.ios.d.ts.map +1 -0
  81. package/lib/typescript/src/geometry.d.ts +8 -0
  82. package/lib/typescript/src/geometry.d.ts.map +1 -1
  83. package/lib/typescript/src/index.d.ts +4 -1
  84. package/lib/typescript/src/index.d.ts.map +1 -1
  85. package/lib/typescript/src/index.web.d.ts +4 -1
  86. package/lib/typescript/src/index.web.d.ts.map +1 -1
  87. package/lib/typescript/src/nativeCompletion.d.ts +7 -0
  88. package/lib/typescript/src/nativeCompletion.d.ts.map +1 -0
  89. package/lib/typescript/src/reactRequests.d.ts +7 -0
  90. package/lib/typescript/src/reactRequests.d.ts.map +1 -0
  91. package/lib/typescript/src/smoothClipNative.android.d.ts +4 -0
  92. package/lib/typescript/src/smoothClipNative.android.d.ts.map +1 -0
  93. package/lib/typescript/src/smoothClipNative.d.ts +3 -0
  94. package/lib/typescript/src/smoothClipNative.d.ts.map +1 -0
  95. package/package.json +12 -7
  96. package/src/NativeSmoothClipModule.ts +104 -0
  97. package/src/SmoothClipView.android.tsx +2 -0
  98. package/src/SmoothClipView.ios.tsx +36 -0
  99. package/src/SmoothClipView.tsx +31 -27
  100. package/src/SmoothClipView.web.tsx +26 -22
  101. package/src/SmoothClipViewNativeComponent.ts +14 -1
  102. package/src/driverState.ts +150 -0
  103. package/src/driverTypes.ts +91 -0
  104. package/src/drivers.android.ts +6 -0
  105. package/src/drivers.ios.ts +665 -0
  106. package/src/drivers.ts +416 -0
  107. package/src/geometry.ts +39 -0
  108. package/src/index.ts +19 -1
  109. package/src/index.web.ts +19 -1
  110. package/src/nativeCompletion.ts +37 -0
  111. package/src/reactRequests.ts +64 -0
  112. package/src/smoothClipNative.android.ts +20 -0
  113. package/src/smoothClipNative.ts +5 -0
@@ -0,0 +1,945 @@
1
+ #include "SmoothClipAndroid.h"
2
+
3
+ #include <android/choreographer.h>
4
+ #include <fbjni/fbjni.h>
5
+
6
+ #include <algorithm>
7
+ #include <array>
8
+ #include <atomic>
9
+ #include <chrono>
10
+ #include <cmath>
11
+ #include <limits>
12
+ #include <mutex>
13
+ #include <optional>
14
+ #include <unordered_map>
15
+ #include <vector>
16
+
17
+ #include <unistd.h>
18
+
19
+ namespace smoothclip {
20
+ namespace {
21
+
22
+ using facebook::jni::alias_ref;
23
+ using facebook::jni::global_ref;
24
+
25
+ enum class Ownership { Interactive, Native };
26
+ enum class AnimationKind { Timing, Spring, Keyframes };
27
+
28
+ double nowSeconds() {
29
+ return std::chrono::duration<double>(
30
+ std::chrono::steady_clock::now().time_since_epoch())
31
+ .count();
32
+ }
33
+
34
+ struct ActiveAnimation {
35
+ int32_t id = 0;
36
+ AnimationKind kind = AnimationKind::Timing;
37
+ Presentation start{{0, 0, 0, 0, 0}, 0, 0};
38
+ Presentation target{{0, 0, 0, 0, 0}, 0, 0};
39
+ Presentation current{{0, 0, 0, 0, 0}, 0, 0};
40
+ TimingAnimation timing{};
41
+ SpringAnimation spring{};
42
+ std::vector<Keyframe> keyframes;
43
+ double durationS = 0;
44
+ double startedAtS = 0;
45
+ // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
46
+ std::array<double, 7> springPosition{};
47
+ std::array<double, 7> springVelocity{};
48
+ double lastFrameS = 0;
49
+ bool finished = true;
50
+ // False while the animation is latched: built before any host view
51
+ // registered, held un-rendered (and out of animatingDrivers()) until the
52
+ // first registerViewAndroid rebases the clock and starts it.
53
+ bool started = false;
54
+ };
55
+
56
+ // Per-view fanout state. Density and host metrics are pushed from Kotlin at
57
+ // register time and on size/density changes; density 0 (not pushed yet) falls
58
+ // back to DIP delivery through setClipPresentationDip.
59
+ struct ViewEntry {
60
+ global_ref<JSmoothClipView> view;
61
+ double density = 0;
62
+ double hostWidthPx = 0;
63
+ double hostHeightPx = 0;
64
+ };
65
+
66
+ struct DriverState {
67
+ Presentation latest{{0, 0, 0, 0, 0}, 0, 0};
68
+ bool hasLatest = false;
69
+ Ownership ownership = Ownership::Interactive;
70
+ std::vector<ViewEntry> views;
71
+ std::optional<ActiveAnimation> animation;
72
+ int32_t nextAnimationId = 0;
73
+ // Set by destroyDriver while views are still registered (StrictMode effect
74
+ // replay, hosts mounted in another subtree). The entry is erased when the
75
+ // last view leaves and revived by a take-ownership setPresentation.
76
+ bool destroyed = false;
77
+
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;
84
+ };
85
+
86
+ struct CompletionSink {
87
+ const void *owner = nullptr;
88
+ CompletionCallback callback;
89
+ };
90
+
91
+ std::unordered_map<uint64_t, DriverState> &registry() {
92
+ static std::unordered_map<uint64_t, DriverState> value;
93
+ return value;
94
+ }
95
+
96
+ CompletionSink &completionSink() {
97
+ static CompletionSink sink;
98
+ return sink;
99
+ }
100
+
101
+ // The sink is written from the JS thread (installBindings / invalidate) and
102
+ // invoked on main. Invoking under the lock also keeps the owning bindings
103
+ // state alive for the duration of the callback.
104
+ std::mutex &completionSinkMutex() {
105
+ static std::mutex value;
106
+ return value;
107
+ }
108
+
109
+ Presentation unavailablePresentation() {
110
+ // Non-finite geometry makes the JS side fall back to its current value
111
+ // instead of applying zeros.
112
+ const double nan = std::numeric_limits<double>::quiet_NaN();
113
+ return {{nan, nan, nan, nan, nan}, nan, nan};
114
+ }
115
+
116
+ // --- Main-thread confinement ----------------------------------------------
117
+
118
+ // fbjni handle used once to identify the Android main thread; later checks
119
+ // compare thread ids.
120
+ struct JLooper : facebook::jni::JavaClass<JLooper> {
121
+ static constexpr auto kJavaDescriptor = "Landroid/os/Looper;";
122
+ };
123
+
124
+ std::atomic<pid_t> gMainThreadId{0};
125
+
126
+ // The registry, the frame loop, and every view mutation are main-thread only:
127
+ // AChoreographer_getInstance is per-thread, and binding the loop to the JS
128
+ // thread would race Fabric mounting and mutate views off-main. The worklets
129
+ // UI runtime executes on the main thread, so supported usage always passes;
130
+ // direct off-main calls fail defined instead of racing.
131
+ bool isOnMainThread() {
132
+ const pid_t tid = gettid();
133
+ const pid_t cached = gMainThreadId.load(std::memory_order_relaxed);
134
+ if (cached != 0) return tid == cached;
135
+ const auto looperClass = JLooper::javaClassStatic();
136
+ static const auto getMainLooper =
137
+ looperClass->getStaticMethod<JLooper()>("getMainLooper");
138
+ static const auto myLooper =
139
+ looperClass->getStaticMethod<JLooper()>("myLooper");
140
+ const auto main = getMainLooper(looperClass);
141
+ const auto mine = myLooper(looperClass);
142
+ const bool isMain = mine &&
143
+ facebook::jni::Environment::current()->IsSameObject(
144
+ main.get(), mine.get());
145
+ if (isMain) gMainThreadId.store(tid, std::memory_order_relaxed);
146
+ return isMain;
147
+ }
148
+
149
+ // --- Frame loop -----------------------------------------------------------
150
+
151
+ bool gFrameScheduled = false;
152
+ void onFrameImpl(double now);
153
+ void onFrameLegacy(long frameTimeNanos, void *data);
154
+
155
+ void scheduleFrame() {
156
+ if (gFrameScheduled) return;
157
+ // Defensive: never bind the per-thread choreographer to a non-main thread.
158
+ if (!isOnMainThread()) return;
159
+ AChoreographer *choreographer = AChoreographer_getInstance();
160
+ if (choreographer == nullptr) return;
161
+ gFrameScheduled = true;
162
+ // AChoreographer_postFrameCallback64 (API 29+) only matters on 32-bit ABIs,
163
+ // where `long` truncates the timestamp; onFrameLegacy falls back to the
164
+ // clock there, so the legacy entry point covers every case.
165
+ #pragma clang diagnostic push
166
+ #pragma clang diagnostic ignored "-Wdeprecated-declarations"
167
+ AChoreographer_postFrameCallback(choreographer, &onFrameLegacy, nullptr);
168
+ #pragma clang diagnostic pop
169
+ }
170
+
171
+ // --- Math (ported from SmoothClipRegistry.kt so both platforms match) -----
172
+
173
+ double clamp01(double value) { return std::min(1.0, std::max(0.0, value)); }
174
+
175
+ double cubicBezier(
176
+ double x1,
177
+ double y1,
178
+ double x2,
179
+ double y2,
180
+ double input) {
181
+ double low = 0;
182
+ double high = 1;
183
+ for (int iteration = 0; iteration < 14; iteration += 1) {
184
+ const double t = (low + high) / 2;
185
+ const double inverse = 1 - t;
186
+ const double x = 3 * inverse * inverse * t * x1 +
187
+ 3 * inverse * t * t * x2 + t * t * t;
188
+ if (x < input) {
189
+ low = t;
190
+ } else {
191
+ high = t;
192
+ }
193
+ }
194
+ const double t = (low + high) / 2;
195
+ const double inverse = 1 - t;
196
+ return 3 * inverse * inverse * t * y1 + 3 * inverse * t * t * y2 +
197
+ t * t * t;
198
+ }
199
+
200
+ // Presentation <-> per-channel array (x, y, width, height, radius, tx, ty).
201
+ std::array<double, 7> toChannels(const Presentation &presentation) {
202
+ return {presentation.clip.x,
203
+ presentation.clip.y,
204
+ presentation.clip.width,
205
+ presentation.clip.height,
206
+ presentation.clip.radius,
207
+ presentation.contentTranslateX,
208
+ presentation.contentTranslateY};
209
+ }
210
+
211
+ Presentation fromChannels(const std::array<double, 7> &channels) {
212
+ return Presentation{
213
+ {channels[0], channels[1], channels[2], channels[3], channels[4]},
214
+ channels[5],
215
+ channels[6]};
216
+ }
217
+
218
+ // Springs settle below these per-channel thresholds (DIP and DIP/s).
219
+ constexpr double kSpringSettleDisplacement = 0.05;
220
+ constexpr double kSpringSettleVelocity = 1.0;
221
+ // Settle-based termination is backed by a hard cap so a pathological
222
+ // configuration cannot run the frame loop forever.
223
+ constexpr double kSpringMaxDurationS = 10.0;
224
+
225
+ // Semi-implicit Euler per channel, substepped: one frame-sized step is only
226
+ // stable for omega*dt < 2, and accepted stiffness/mass ratios exceed that at
227
+ // the 64 ms clamp. Fixed <= 1/240 s substeps stay stable beyond
228
+ // stiffness/mass = 200k. Termination is settle-based: every channel must be
229
+ // near its target and nearly stationary at the same time.
230
+ bool advanceSpring(ActiveAnimation &animation, double now) {
231
+ const auto target = toChannels(animation.target);
232
+ double dt = now - animation.lastFrameS;
233
+ animation.lastFrameS = now;
234
+ if (dt < 0) dt = 0;
235
+ if (dt > 0.064) dt = 0.064;
236
+ const double mass = animation.spring.mass;
237
+ const double stiffness = animation.spring.stiffness;
238
+ const double damping = animation.spring.damping;
239
+ constexpr double kMaxStepS = 1.0 / 240.0;
240
+ while (dt > 0) {
241
+ const double step = std::min(dt, kMaxStepS);
242
+ dt -= step;
243
+ for (int index = 0; index < 7; index += 1) {
244
+ const double displacement =
245
+ animation.springPosition[index] - target[index];
246
+ const double acceleration =
247
+ (-stiffness * displacement -
248
+ damping * animation.springVelocity[index]) /
249
+ mass;
250
+ animation.springVelocity[index] += acceleration * step;
251
+ animation.springPosition[index] += animation.springVelocity[index] * step;
252
+ }
253
+ }
254
+ bool settled = true;
255
+ for (int index = 0; index < 7; index += 1) {
256
+ if (!std::isfinite(animation.springPosition[index]) ||
257
+ !std::isfinite(animation.springVelocity[index])) {
258
+ // A divergent configuration must never freeze the clip: end at target.
259
+ animation.springPosition = target;
260
+ animation.springVelocity = {};
261
+ animation.current = animation.target;
262
+ return true;
263
+ }
264
+ if (std::abs(animation.springPosition[index] - target[index]) >
265
+ kSpringSettleDisplacement ||
266
+ std::abs(animation.springVelocity[index]) > kSpringSettleVelocity) {
267
+ settled = false;
268
+ }
269
+ }
270
+ animation.current = fromChannels(animation.springPosition);
271
+ return settled || now - animation.startedAtS >= kSpringMaxDurationS;
272
+ }
273
+
274
+ Presentation interpolate(
275
+ const Presentation &from,
276
+ const Presentation &to,
277
+ double progress) {
278
+ const auto mix = [progress](double start, double end) {
279
+ return start + (end - start) * progress;
280
+ };
281
+ return Presentation{
282
+ {mix(from.clip.x, to.clip.x),
283
+ mix(from.clip.y, to.clip.y),
284
+ mix(from.clip.width, to.clip.width),
285
+ mix(from.clip.height, to.clip.height),
286
+ mix(from.clip.radius, to.clip.radius)},
287
+ mix(from.contentTranslateX, to.contentTranslateX),
288
+ mix(from.contentTranslateY, to.contentTranslateY)};
289
+ }
290
+
291
+ Presentation interpolateKeyframes(
292
+ const std::vector<Keyframe> &frames,
293
+ double progress) {
294
+ size_t upper = 1;
295
+ while (upper < frames.size() - 1 && progress > frames[upper].offset) {
296
+ upper += 1;
297
+ }
298
+ const Keyframe &lower = frames[upper - 1];
299
+ const Keyframe &higher = frames[upper];
300
+ const double span = higher.offset - lower.offset;
301
+ const double local = span <= 0 ? 1.0 : (progress - lower.offset) / span;
302
+ return interpolate(lower.presentation, higher.presentation, clamp01(local));
303
+ }
304
+
305
+ bool systemAnimatorsEnabled() {
306
+ static const auto valueAnimatorClass =
307
+ facebook::jni::findClassStatic("android/animation/ValueAnimator");
308
+ static const auto method =
309
+ valueAnimatorClass->getStaticMethod<jboolean()>("areAnimatorsEnabled");
310
+ return method(valueAnimatorClass) != 0;
311
+ }
312
+
313
+ bool shouldReduceMotion(int32_t setting) {
314
+ if (setting == 1) return true;
315
+ if (setting == 2) return false;
316
+ return !systemAnimatorsEnabled();
317
+ }
318
+
319
+ // --- State helpers --------------------------------------------------------
320
+
321
+ void emitCompletion(uint64_t driverId, int32_t animationId, bool finished) {
322
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
323
+ if (completionSink().callback) {
324
+ completionSink().callback(driverId, animationId, finished);
325
+ }
326
+ }
327
+
328
+ int32_t allocateAnimationId(DriverState &state) {
329
+ state.nextAnimationId =
330
+ state.nextAnimationId == std::numeric_limits<int32_t>::max()
331
+ ? 1
332
+ : state.nextAnimationId + 1;
333
+ return state.nextAnimationId;
334
+ }
335
+
336
+ // Terminal per-frame delivery: scale DIP -> px and normalize against the
337
+ // pushed host metrics in C++, so the JNI call carries final pixel floats and
338
+ // the Kotlin side reduces to field stores + invalidateOutline().
339
+ void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
340
+ if (entry.density <= 0) {
341
+ entry.view->applyClip(presentation);
342
+ return;
343
+ }
344
+ const double density = entry.density;
345
+ NormalizedClip clip;
346
+ if (!SmoothClipNormalize(
347
+ presentation.clip.x * density,
348
+ presentation.clip.y * density,
349
+ presentation.clip.width * density,
350
+ presentation.clip.height * density,
351
+ presentation.clip.radius * density,
352
+ entry.hostWidthPx,
353
+ entry.hostHeightPx,
354
+ clip)) {
355
+ return;
356
+ }
357
+ const double translateX = presentation.contentTranslateX * density;
358
+ const double translateY = presentation.contentTranslateY * density;
359
+ // Atomic reject, mirroring the Kotlin DIP path's all-or-nothing gate.
360
+ if (!std::isfinite(translateX) || !std::isfinite(translateY)) return;
361
+ entry.view->applyClipPx(clip, translateX, translateY);
362
+ }
363
+
364
+ void applyToViews(DriverState &state, const Presentation &presentation) {
365
+ for (const auto &entry : state.views) {
366
+ deliverToView(entry, presentation);
367
+ }
368
+ }
369
+
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
+ void applyPresentation(DriverState &state, const Presentation &presentation) {
411
+ state.latest = presentation;
412
+ state.hasLatest = true;
413
+ state.ownership = Ownership::Interactive;
414
+ applyToViews(state, presentation);
415
+ }
416
+
417
+ std::vector<uint64_t> &animatingDrivers() {
418
+ static std::vector<uint64_t> value;
419
+ return value;
420
+ }
421
+
422
+ // Every state.animation.reset() routes through here so the choreographer only
423
+ // visits animating drivers. Removal is eager: a stale id surviving a driver
424
+ // erase/recreate cycle would let one frame integrate the animation twice.
425
+ void clearActiveAnimation(uint64_t driverId, DriverState &state) {
426
+ state.animation.reset();
427
+ auto &active = animatingDrivers();
428
+ active.erase(
429
+ std::remove(active.begin(), active.end(), driverId), active.end());
430
+ }
431
+
432
+ void finishActive(uint64_t driverId, DriverState &state, bool finished) {
433
+ if (!state.animation.has_value()) return;
434
+ const int32_t animationId = state.animation->id;
435
+ clearActiveAnimation(driverId, state);
436
+ emitCompletion(driverId, animationId, finished);
437
+ }
438
+
439
+ Presentation prepareAnimation(
440
+ uint64_t driverId,
441
+ DriverState &state,
442
+ AnimationStart start,
443
+ Presentation target) {
444
+ const Presentation visibleBefore =
445
+ state.animation.has_value() ? state.animation->current : state.latest;
446
+ const bool acceptsInteractiveStart =
447
+ start.hasInteractiveStart && state.ownership == Ownership::Interactive;
448
+ finishActive(driverId, state, false);
449
+ const Presentation resolvedStart =
450
+ acceptsInteractiveStart ? start.interactiveStart : visibleBefore;
451
+ if (acceptsInteractiveStart) {
452
+ state.latest = resolvedStart;
453
+ applyToViews(state, resolvedStart);
454
+ }
455
+ state.latest = target;
456
+ state.hasLatest = true;
457
+ state.ownership = Ownership::Native;
458
+ return resolvedStart;
459
+ }
460
+
461
+ int32_t startAnimation(
462
+ uint64_t driverId,
463
+ DriverState &state,
464
+ ActiveAnimation animation) {
465
+ animation.startedAtS = nowSeconds();
466
+ animation.lastFrameS = animation.startedAtS;
467
+ // current = start even while latched is load-bearing: cancelAnimation,
468
+ // beginInteraction, prepareAnimation's visibleBefore and registerView's
469
+ // visible all read animation->current, giving a never-rendered latch
470
+ // freeze-at-start / replace-from-start semantics with no extra branches.
471
+ animation.current = animation.start;
472
+ animation.started = !state.views.empty();
473
+ state.animation = std::move(animation);
474
+ 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.
479
+ return state.animation->id;
480
+ }
481
+ auto &active = animatingDrivers();
482
+ if (std::find(active.begin(), active.end(), driverId) == active.end()) {
483
+ active.push_back(driverId);
484
+ }
485
+ applyToViews(state, state.animation->start);
486
+ scheduleFrame();
487
+ return state.animation->id;
488
+ }
489
+
490
+ void advance(uint64_t driverId, DriverState &state, double now) {
491
+ ActiveAnimation &animation = *state.animation;
492
+
493
+ bool done = false;
494
+ if (animation.kind == AnimationKind::Spring) {
495
+ done = advanceSpring(animation, now);
496
+ } else {
497
+ const double fraction =
498
+ animation.durationS <= 0
499
+ ? 1.0
500
+ : clamp01((now - animation.startedAtS) / animation.durationS);
501
+ if (animation.kind == AnimationKind::Keyframes) {
502
+ animation.current = interpolateKeyframes(animation.keyframes, fraction);
503
+ } else {
504
+ const double eased = cubicBezier(
505
+ animation.timing.controlPoint1X,
506
+ animation.timing.controlPoint1Y,
507
+ animation.timing.controlPoint2X,
508
+ animation.timing.controlPoint2Y,
509
+ fraction);
510
+ animation.current = interpolate(animation.start, animation.target, eased);
511
+ }
512
+ done = fraction >= 1.0;
513
+ }
514
+ // The completion branch below fans out the exact target; applying the
515
+ // integrated value too would double every JNI crossing on the final frame.
516
+ if (!done) applyToViews(state, animation.current);
517
+
518
+ if (done) {
519
+ const int32_t animationId = animation.id;
520
+ const bool finished = animation.finished;
521
+ const Presentation target = animation.target;
522
+ clearActiveAnimation(driverId, state);
523
+ state.latest = target;
524
+ state.hasLatest = true;
525
+ state.ownership = Ownership::Interactive;
526
+ applyToViews(state, target);
527
+ emitCompletion(driverId, animationId, finished);
528
+ }
529
+ }
530
+
531
+ void onFrameImpl(double now) {
532
+ gFrameScheduled = false;
533
+ // Snapshot: advance() clears live-list entries on completion. advance()
534
+ // never erases registry entries and never starts animations (completion
535
+ // delivery hops through CallInvoker::invokeAsync), so the find() guard
536
+ // below covers every way a copied id can go stale within this frame. The
537
+ // scratch vector is main-thread confined and reused so the steady-state
538
+ // frame loop stays allocation-free.
539
+ static std::vector<uint64_t> scratch;
540
+ scratch = animatingDrivers();
541
+ for (const uint64_t driverId : scratch) {
542
+ auto iterator = registry().find(driverId);
543
+ if (iterator == registry().end() ||
544
+ !iterator->second.animation.has_value()) {
545
+ continue;
546
+ }
547
+ advance(driverId, iterator->second, now);
548
+ }
549
+ if (!animatingDrivers().empty()) scheduleFrame();
550
+ }
551
+
552
+ void onFrameLegacy(long frameTimeNanos, void * /*data*/) {
553
+ // An fbjni exception must not unwind out of a C callback (std::terminate).
554
+ // Clear it, keep the loop scheduled, and let the next frame re-scan.
555
+ try {
556
+ #if defined(__LP64__) || (defined(__SIZEOF_POINTER__) && __SIZEOF_POINTER__ == 8)
557
+ onFrameImpl(static_cast<double>(frameTimeNanos) / 1e9);
558
+ #else
559
+ // 32-bit `long` truncates the nanosecond timestamp; fall back to the
560
+ // clock.
561
+ onFrameImpl(nowSeconds());
562
+ #endif
563
+ } catch (...) {
564
+ JNIEnv *env = facebook::jni::Environment::current();
565
+ if (env != nullptr && env->ExceptionCheck()) env->ExceptionClear();
566
+ scheduleFrame();
567
+ }
568
+ }
569
+
570
+ } // namespace
571
+
572
+ // --- Public smoothclip:: interface (shared with iOS via SmoothClipRegistry.h)
573
+
574
+ void setCompletionCallback(const void *owner, CompletionCallback callback) {
575
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
576
+ completionSink().owner = owner;
577
+ completionSink().callback = std::move(callback);
578
+ }
579
+
580
+ void clearCompletionCallback(const void *owner) {
581
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
582
+ if (completionSink().owner != owner) return;
583
+ completionSink().owner = nullptr;
584
+ completionSink().callback = nullptr;
585
+ }
586
+
587
+ void setPresentation(
588
+ uint64_t driverId,
589
+ Presentation presentation,
590
+ bool takeOwnership) {
591
+ if (!isOnMainThread()) return;
592
+ auto iterator = registry().find(driverId);
593
+ if (!takeOwnership) {
594
+ if (iterator == registry().end() || iterator->second.destroyed) {
595
+ // A stale interactive delivery must not resurrect a destroyed driver.
596
+ return;
597
+ }
598
+ } else if (iterator == registry().end()) {
599
+ // Same create-or-revive semantics operator[] had; a single hash lookup.
600
+ iterator = registry().try_emplace(driverId).first;
601
+ }
602
+ auto &state = iterator->second;
603
+ if (!takeOwnership && state.ownership != Ownership::Interactive) return;
604
+ if (takeOwnership) {
605
+ state.destroyed = false;
606
+ finishActive(driverId, state, false);
607
+ }
608
+ state.latest = presentation;
609
+ state.hasLatest = true;
610
+ state.ownership = Ownership::Interactive;
611
+ recordInteractiveSample(state, presentation);
612
+ applyToViews(state, presentation);
613
+ }
614
+
615
+ Presentation beginInteraction(uint64_t driverId) {
616
+ if (!isOnMainThread()) return unavailablePresentation();
617
+ auto iterator = registry().find(driverId);
618
+ if (iterator == registry().end()) return unavailablePresentation();
619
+ auto &state = iterator->second;
620
+ state.destroyed = false;
621
+ if (!state.animation.has_value()) {
622
+ state.ownership = Ownership::Interactive;
623
+ return state.latest;
624
+ }
625
+ const int32_t animationId = state.animation->id;
626
+ const Presentation current = state.animation->current;
627
+ clearActiveAnimation(driverId, state);
628
+ state.latest = current;
629
+ state.hasLatest = true;
630
+ state.ownership = Ownership::Interactive;
631
+ state.hasPreviousSample = false;
632
+ state.hasLatestSample = true;
633
+ state.latestSample = current;
634
+ state.latestSampleTimeS = nowSeconds();
635
+ applyToViews(state, current);
636
+ emitCompletion(driverId, animationId, false);
637
+ return current;
638
+ }
639
+
640
+ int32_t animateTiming(
641
+ uint64_t driverId,
642
+ AnimationStart start,
643
+ Presentation presentation,
644
+ TimingAnimation animation) {
645
+ if (!isOnMainThread()) return 0;
646
+ auto iterator = registry().find(driverId);
647
+ if (iterator == registry().end()) return 0;
648
+ auto &state = iterator->second;
649
+ state.destroyed = false;
650
+ const int32_t animationId = allocateAnimationId(state);
651
+ const Presentation resolvedStart =
652
+ prepareAnimation(driverId, state, start, presentation);
653
+ if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
654
+ applyPresentation(state, presentation);
655
+ emitCompletion(driverId, animationId, true);
656
+ return animationId;
657
+ }
658
+ ActiveAnimation active;
659
+ active.id = animationId;
660
+ active.kind = AnimationKind::Timing;
661
+ active.timing = animation;
662
+ active.start = resolvedStart;
663
+ active.target = presentation;
664
+ active.durationS = animation.durationMs / 1000.0;
665
+ return startAnimation(driverId, state, std::move(active));
666
+ }
667
+
668
+ int32_t animateSpring(
669
+ uint64_t driverId,
670
+ AnimationStart start,
671
+ Presentation presentation,
672
+ SpringAnimation animation) {
673
+ if (!isOnMainThread()) return 0;
674
+ auto iterator = registry().find(driverId);
675
+ if (iterator == registry().end()) return 0;
676
+ auto &state = iterator->second;
677
+ state.destroyed = false;
678
+ const int32_t animationId = allocateAnimationId(state);
679
+ // Scalar velocity along the current-to-target trajectory; each channel is
680
+ // seeded with velocity·displacement to match the iOS CASpringAnimation
681
+ // per-keypath behavior.
682
+ const double velocity = animation.inheritVelocity
683
+ ? inheritedVelocity(state, presentation)
684
+ : animation.initialVelocity;
685
+ const Presentation resolvedStart =
686
+ prepareAnimation(driverId, state, start, presentation);
687
+ if (shouldReduceMotion(animation.reduceMotion)) {
688
+ applyPresentation(state, presentation);
689
+ emitCompletion(driverId, animationId, true);
690
+ return animationId;
691
+ }
692
+ ActiveAnimation active;
693
+ active.id = animationId;
694
+ active.kind = AnimationKind::Spring;
695
+ active.spring = animation;
696
+ active.spring.initialVelocity = velocity;
697
+ active.spring.inheritVelocity = false;
698
+ active.start = resolvedStart;
699
+ active.target = presentation;
700
+ active.durationS = kSpringMaxDurationS;
701
+ const auto startChannels = toChannels(resolvedStart);
702
+ const auto targetChannels = toChannels(presentation);
703
+ for (int index = 0; index < 7; index += 1) {
704
+ active.springPosition[index] = startChannels[index];
705
+ active.springVelocity[index] =
706
+ velocity * (targetChannels[index] - startChannels[index]);
707
+ }
708
+ return startAnimation(driverId, state, std::move(active));
709
+ }
710
+
711
+ int32_t animateKeyframes(
712
+ uint64_t driverId,
713
+ AnimationStart start,
714
+ Presentation presentation,
715
+ double durationMs,
716
+ std::vector<Keyframe> keyframes,
717
+ int32_t reduceMotion) {
718
+ if (!isOnMainThread()) return 0;
719
+ auto iterator = registry().find(driverId);
720
+ if (iterator == registry().end()) return 0;
721
+ auto &state = iterator->second;
722
+ state.destroyed = false;
723
+ const int32_t animationId = allocateAnimationId(state);
724
+ const Presentation resolvedStart =
725
+ prepareAnimation(driverId, state, start, presentation);
726
+ if (shouldReduceMotion(reduceMotion) || durationMs <= 0 ||
727
+ keyframes.size() < 2) {
728
+ applyPresentation(state, presentation);
729
+ emitCompletion(driverId, animationId, true);
730
+ return animationId;
731
+ }
732
+ ActiveAnimation active;
733
+ active.id = animationId;
734
+ active.kind = AnimationKind::Keyframes;
735
+ active.keyframes = std::move(keyframes);
736
+ active.start = resolvedStart;
737
+ active.target = presentation;
738
+ active.durationS = durationMs / 1000.0;
739
+ return startAnimation(driverId, state, std::move(active));
740
+ }
741
+
742
+ int32_t rejectAnimation(uint64_t driverId) {
743
+ if (!isOnMainThread()) return 0;
744
+ auto iterator = registry().find(driverId);
745
+ if (iterator == registry().end()) return 0;
746
+ const int32_t animationId = allocateAnimationId(iterator->second);
747
+ emitCompletion(driverId, animationId, false);
748
+ return animationId;
749
+ }
750
+
751
+ CancelResult cancelAnimation(
752
+ uint64_t driverId,
753
+ int32_t animationId,
754
+ bool useTarget) {
755
+ if (!isOnMainThread()) return {false, unavailablePresentation()};
756
+ auto iterator = registry().find(driverId);
757
+ if (iterator == registry().end()) {
758
+ return {false, {{0, 0, 0, 0, 0}, 0, 0}};
759
+ }
760
+ auto &state = iterator->second;
761
+ if (!state.animation.has_value() ||
762
+ (animationId > 0 && animationId != state.animation->id)) {
763
+ return {false, state.animation.has_value() ? state.animation->current
764
+ : state.latest};
765
+ }
766
+ const Presentation result =
767
+ useTarget ? state.animation->target : state.animation->current;
768
+ const int32_t activeId = state.animation->id;
769
+ clearActiveAnimation(driverId, state);
770
+ state.latest = result;
771
+ state.hasLatest = true;
772
+ state.ownership = Ownership::Interactive;
773
+ applyToViews(state, result);
774
+ emitCompletion(driverId, activeId, false);
775
+ return {true, result};
776
+ }
777
+
778
+ void destroyDriver(uint64_t driverId) {
779
+ if (!isOnMainThread()) return;
780
+ auto iterator = registry().find(driverId);
781
+ if (iterator == registry().end()) return;
782
+ auto &state = iterator->second;
783
+ state.ownership = Ownership::Interactive;
784
+ finishActive(driverId, state, false);
785
+ if (state.views.empty()) {
786
+ registry().erase(iterator);
787
+ } else {
788
+ // Views can outlive the hook briefly (StrictMode effect replay, hosts in
789
+ // another subtree). Keep a tombstone so their registration stays intact;
790
+ // it is erased when the last view leaves and revived by the hook's
791
+ // authoritative take-ownership re-seed.
792
+ state.destroyed = true;
793
+ }
794
+ }
795
+
796
+ // --- Android view lifecycle ----------------------------------------------
797
+
798
+ void JSmoothClipView::applyClip(const Presentation &presentation) const {
799
+ static const auto method =
800
+ javaClassStatic()
801
+ ->getMethod<void(
802
+ jdouble, jdouble, jdouble, jdouble, jdouble, jdouble, jdouble)>(
803
+ "setClipPresentationDip");
804
+ method(
805
+ self(),
806
+ presentation.clip.x,
807
+ presentation.clip.y,
808
+ presentation.clip.width,
809
+ presentation.clip.height,
810
+ presentation.clip.radius,
811
+ presentation.contentTranslateX,
812
+ presentation.contentTranslateY);
813
+ }
814
+
815
+ void JSmoothClipView::applyClipPx(
816
+ const NormalizedClip &clip,
817
+ double contentTranslateXPx,
818
+ double contentTranslateYPx) const {
819
+ static const auto method =
820
+ javaClassStatic()
821
+ ->getMethod<void(
822
+ jfloat, jfloat, jfloat, jfloat, jfloat, jfloat, jfloat)>(
823
+ "setClipPresentationPx");
824
+ method(
825
+ self(),
826
+ static_cast<jfloat>(clip.left),
827
+ static_cast<jfloat>(clip.top),
828
+ static_cast<jfloat>(clip.right),
829
+ static_cast<jfloat>(clip.bottom),
830
+ static_cast<jfloat>(clip.radius),
831
+ static_cast<jfloat>(contentTranslateXPx),
832
+ static_cast<jfloat>(contentTranslateYPx));
833
+ }
834
+
835
+ void registerViewAndroid(
836
+ uint64_t driverId,
837
+ alias_ref<JSmoothClipView> view,
838
+ Presentation initialPresentation,
839
+ double density,
840
+ double hostWidthPx,
841
+ double hostHeightPx) {
842
+ auto &state = registry()[driverId];
843
+ state.destroyed = false;
844
+ if (!state.hasLatest) {
845
+ state.latest = initialPresentation;
846
+ state.hasLatest = true;
847
+ }
848
+ const Presentation visible = state.animation.has_value()
849
+ ? state.animation->current
850
+ : state.latest;
851
+ 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
+ for (auto &existing : state.views) {
856
+ if (env->IsSameObject(existing.view.get(), view.get())) {
857
+ existing.density = density;
858
+ existing.hostWidthPx = hostWidthPx;
859
+ existing.hostHeightPx = hostHeightPx;
860
+ deliverToView(existing, visible);
861
+ return;
862
+ }
863
+ }
864
+ ViewEntry entry{
865
+ facebook::jni::make_global(view), density, hostWidthPx, hostHeightPx};
866
+ // `visible` above already delivered animation->current (= start) to the
867
+ // registering view — the correct first frame for a latched animation.
868
+ deliverToView(entry, visible);
869
+ state.views.push_back(std::move(entry));
870
+ if (state.animation.has_value() && !state.animation->started) {
871
+ // Start the latched animation: rebase the clock so the first frame
872
+ // integrates from now, not from the pre-mount animateTo call.
873
+ // Note this function has no isOnMainThread() guard (Kotlin calls it from
874
+ // the view's main-thread attach path); scheduleFrame() is itself
875
+ // main-thread-gated, so an off-main register cannot bind the
876
+ // 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();
886
+ }
887
+ }
888
+
889
+ void setViewHostGeometryAndroid(
890
+ uint64_t driverId,
891
+ alias_ref<JSmoothClipView> view,
892
+ double density,
893
+ double hostWidthPx,
894
+ double hostHeightPx) {
895
+ if (!isOnMainThread()) return;
896
+ auto iterator = registry().find(driverId);
897
+ if (iterator == registry().end()) return;
898
+ auto &state = iterator->second;
899
+ JNIEnv *env = facebook::jni::Environment::current();
900
+ for (auto &entry : state.views) {
901
+ if (!env->IsSameObject(entry.view.get(), view.get())) continue;
902
+ entry.density = density;
903
+ entry.hostWidthPx = hostWidthPx;
904
+ entry.hostHeightPx = hostHeightPx;
905
+ if (state.hasLatest) {
906
+ deliverToView(
907
+ entry,
908
+ state.animation.has_value() ? state.animation->current
909
+ : state.latest);
910
+ }
911
+ return;
912
+ }
913
+ }
914
+
915
+ void unregisterViewAndroid(
916
+ uint64_t driverId,
917
+ alias_ref<JSmoothClipView> view) {
918
+ auto iterator = registry().find(driverId);
919
+ if (iterator == registry().end()) return;
920
+ auto &state = iterator->second;
921
+ bool removed = false;
922
+ JNIEnv *env = facebook::jni::Environment::current();
923
+ for (auto view_it = state.views.begin(); view_it != state.views.end();) {
924
+ if (env->IsSameObject(view_it->view.get(), view.get())) {
925
+ view_it = state.views.erase(view_it);
926
+ removed = true;
927
+ } else {
928
+ ++view_it;
929
+ }
930
+ }
931
+ if (removed && state.animation.has_value()) {
932
+ 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);
938
+ }
939
+ }
940
+ if (state.destroyed && state.views.empty()) {
941
+ registry().erase(iterator);
942
+ }
943
+ }
944
+
945
+ } // namespace smoothclip