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

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