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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +68 -26
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/build.gradle +15 -0
  4. package/android/src/main/cpp/SmoothClipAndroid.h +12 -8
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +39 -15
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +288 -285
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +28 -4
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +180 -42
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +1 -0
  12. package/cpp/SmoothClipAnimationCurve.h +560 -0
  13. package/cpp/SmoothClipAnimationId.h +32 -0
  14. package/cpp/SmoothClipRegistry.h +11 -1
  15. package/cpp/SmoothClipVelocityTracker.h +30 -10
  16. package/ios/SmoothClipRegistry.mm +398 -150
  17. package/ios/SmoothClipTurboModule.cpp +4 -2
  18. package/ios/SmoothClipTurboModule.h +2 -1
  19. package/ios/SmoothClipView.mm +85 -140
  20. package/ios/SmoothClipViewRegistry.h +4 -1
  21. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  22. package/lib/module/driverState.js +8 -0
  23. package/lib/module/driverState.js.map +1 -1
  24. package/lib/module/drivers.native.js +122 -32
  25. package/lib/module/drivers.native.js.map +1 -1
  26. package/lib/module/reactRequests.js +9 -3
  27. package/lib/module/reactRequests.js.map +1 -1
  28. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  29. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  30. package/lib/typescript/src/driverState.d.ts.map +1 -1
  31. package/lib/typescript/src/driverTypes.d.ts +3 -2
  32. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  34. package/lib/typescript/src/reactRequests.d.ts +3 -1
  35. package/lib/typescript/src/reactRequests.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/NativeSmoothClipModule.ts +2 -1
  38. package/src/driverState.ts +8 -0
  39. package/src/driverTypes.ts +3 -2
  40. package/src/drivers.native.ts +160 -29
  41. package/src/reactRequests.ts +14 -2
package/README.md CHANGED
@@ -165,6 +165,15 @@ const gesture = Gesture.Pan()
165
165
  | `driver` | `SmoothClipDriver` | Reusable hybrid clip driver. |
166
166
  | `children` | `ReactNode` | Content rendered inside the fixed host. |
167
167
 
168
+ The host hides itself, drops out of the accessibility tree, and stops accepting
169
+ touches while the clip is empty. The two platforms draw the boundary where they
170
+ actually stop rendering, which differs below one pixel: Android emits an integer
171
+ `Outline`, which collapses to nothing under half a physical pixel, so an extent
172
+ in `(0, 0.5)` px counts as empty there; iOS masks in floats and treats only a
173
+ zero-or-negative extent as empty. A clip animating through that band therefore
174
+ turns non-interactive one frame earlier on Android — matching what each platform
175
+ puts on screen, which is the property worth keeping identical.
176
+
168
177
  ### Driver
169
178
 
170
179
  - `useSmoothClipDriver(initialPresentation, options)` returns one hybrid driver
@@ -190,15 +199,19 @@ const gesture = Gesture.Pan()
190
199
  remaining distance in one second). Every geometry channel continues with the
191
200
  same normalized rate, so grab/release preserves the felt direction and
192
201
  speed. `'inherit'` (the default) estimates the scalar from the last two
193
- interactive samples on iOS and Android; samples older than 100 ms — and the
194
- web fallback — fall back to zero. Two writes landing inside the same frame
202
+ interactive samples on iOS and Android; the web fallback always inherits
203
+ zero. How long the finger has been still since that last sample scales the
204
+ result: full credit for one frame (16.7 ms), then a linear decay to zero at
205
+ 100 ms. A release straight out of a drag is therefore untouched, and holding
206
+ still before releasing bleeds the momentum off smoothly instead of keeping
207
+ all of it until 99 ms and none at 101 ms. Two writes landing inside the same frame
195
208
  (< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
196
209
  write) coalesce into one sample, and an identical re-write is ignored, so a
197
210
  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.
211
+ Only interactive writes contribute samples. Internal freeze/join/resume and
212
+ static-finalization writes do not, so grabbing a native animation cannot
213
+ manufacture velocity for a later `'inherit'` spring; a subsequent drag (or
214
+ explicit `animation.from`) supplies the release samples instead.
202
215
  - `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
203
216
  as Promises (`setScalars` is UI-worklet-only). React code never blocks
204
217
  waiting for main/UI-thread work. An immediate animation request resolves
@@ -217,35 +230,64 @@ const gesture = Gesture.Pan()
217
230
  write issued immediately before the handoff, so the animation starts from
218
231
  exactly that value (pass `frames[0].presentation` for keyframes, which
219
232
  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.
233
+ a held pending-animation latch, explicit `from` is the newer intent: it
234
+ cancels that latch once with `finished: false`, records/applies `from`, then
235
+ starts the replacement from that native value. Passive hook seeds and public
236
+ `set`/`setScalars` writes still leave a held latch intact. `from` behaves the
237
+ same on both platforms (it is driver-layer, not native): on iOS the seed
238
+ stops any running Core Animation and writes the model layer first; keyframes
239
+ then start exactly at `from`, while timing/spring sample their from-value off
240
+ the presentation layer — the last committed frame, at most one frame behind
241
+ `from` — identical to the explicit two-call pattern.
227
242
  - `cancel()` freezes visible presentation by default. Pass `'target'` as its
228
243
  behavior to jump to the requested endpoint.
229
244
  - `options.onAnimationComplete` fires exactly once per animation with its ID
230
245
  and `finished` state, including cancellation, replacement, and native-side
231
246
  rejection (`animateTo` then returns a fresh non-zero id whose single
232
247
  `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
236
- animation: the remainder is re-latched and the next displayable host resumes
237
- it, so the completion arrives when it finishes there — or unfinished on
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.
248
+ returned id, never by `0`). A valid pre-registration request carries its
249
+ authoritative interactive start, creates the missing driver state and
250
+ returns a real id. `0` — with no completion — is reserved for off-main,
251
+ invalid-id, or otherwise unsupported dispatch: a missing-state native request
252
+ with no authoritative start, any `driver.ui.animateTo` issued after the
253
+ driver's hook has unmounted, and an invalid-parameter request issued before
254
+ the driver's first seed reached native (validation rejections mint their
255
+ `finished: false` completion only once the native entry exists). (The
256
+ post-unmount case is decided on the UI runtime,
257
+ because a destroyed driver and a not-yet-seeded one are the same missing
258
+ registry entry to native — accepting it would build a latch nothing can start
259
+ and nothing can cancel.) A host is displayable only while it is attached,
260
+ foreground/window-visible, laid out, and positive-sized (the window's own
261
+ `hidden`/scene state is not consulted — RN's single always-visible window
262
+ makes it moot). Losing the last
263
+ displayable host mid-flight — including temporary detach, zero-size layout,
264
+ or app background — freezes and re-latches the exact remainder instead of
265
+ consuming duration offscreen. Foreground/reattach resumes the same animation
266
+ ID from that stored timing/keyframe phase or spring state.
267
+ Parallel Reanimated clocks do **not** pause with it: a `withTiming` started
268
+ beside `animateTo` keeps its wall-clock start, so after backgrounding it
269
+ completes on its first resumed frame while the native run still animates its
270
+ preserved remainder. Key teardown and state transitions off
271
+ `onAnimationComplete` (or re-synchronize on `AppState`) rather than off a
272
+ duration-matched Reanimated callback.
273
+ With multiple hosts on one driver, a host becomes an installed participant
274
+ only after its native animation starts. Temporary loss moves it to suspended;
275
+ a rejoin restores active participation. Unregistering an installed/suspended
276
+ host, or reaching completion while it remains suspended, makes the eventual
277
+ completion `finished: false`. A host that stayed deferred because it was
278
+ detached, unlaid-out, or zero-sized never poisons completion. `finished: true`
279
+ means every installed participant either ran to the end or rejoined and did so.
242
280
  - An `animateTo` issued before any host view can produce a visible frame (for
243
281
  example from an effect in the same commit that mounts the host, or inside a
244
282
  modal route whose subtree attaches to its window late) is held pending and
245
- starts with its full duration at the first displayable registration or
246
- window attach. A pending animation owns the driver: take-ownership writes
247
- (`set`, `setScalars`, the hook's seed) are dropped while it is held —
248
- replace it with another `animateTo`, or cancel it via `beginInteraction()`
283
+ starts with its full duration at the first moment a registered host can
284
+ produce a frame — positive layout, window attach/visibility, and foreground
285
+ state must all be present. This also covers an animation worklet that runs before the
286
+ hook's seed worklet: the animation creates the state and the later passive
287
+ seed cannot reset its ownership or active id. A pending animation owns the
288
+ driver: ordinary take-ownership writes (`set`, `setScalars`, the hook's seed)
289
+ are dropped while it is held. Replace it with another `animateTo`, override
290
+ it with an explicit `animation.from`, or cancel it via `beginInteraction()`
249
291
  or `cancel()`. If no view ever becomes displayable, it survives until it is
250
292
  replaced, cancelled, or the driver is destroyed — at which point its single
251
293
  `finished: false` completion is delivered.
@@ -36,7 +36,6 @@ target_include_directories(
36
36
 
37
37
  target_link_libraries(
38
38
  smoothclipview
39
- android
40
39
  log
41
40
  fbjni::fbjni
42
41
  ReactAndroid::jsi
@@ -114,6 +114,19 @@ android {
114
114
  targetCompatibility JavaVersion.VERSION_1_8
115
115
  }
116
116
 
117
+ testOptions {
118
+ unitTests {
119
+ includeAndroidResources = true
120
+ all {
121
+ // Robolectric on Java 17 reflects into these platform packages.
122
+ jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED",
123
+ "--add-opens=java.base/java.util=ALL-UNNAMED",
124
+ "--add-opens=java.base/java.io=ALL-UNNAMED",
125
+ "--add-opens=java.base/java.net=ALL-UNNAMED"
126
+ }
127
+ }
128
+ }
129
+
117
130
  packagingOptions {
118
131
  // Provided by the host application at runtime.
119
132
  excludes += [
@@ -128,4 +141,6 @@ android {
128
141
  dependencies {
129
142
  implementation "com.facebook.react:react-android"
130
143
  testImplementation "junit:junit:4.13.2"
144
+ testImplementation "org.mockito:mockito-core:5.14.2"
145
+ testImplementation "org.robolectric:robolectric:4.16"
131
146
  }
@@ -22,7 +22,6 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
22
22
  const NormalizedClip &clip,
23
23
  double contentTranslateXPx,
24
24
  double contentTranslateYPx) const;
25
- bool isViewAttachedToWindow() const;
26
25
  };
27
26
 
28
27
  // Called from Kotlin (SmoothClipViewManager) on the UI thread when a Fabric
@@ -33,7 +32,8 @@ void registerViewAndroid(
33
32
  Presentation initialPresentation,
34
33
  double density,
35
34
  double hostWidthPx,
36
- double hostHeightPx);
35
+ double hostHeightPx,
36
+ bool lifecycleVisible);
37
37
  // Refreshes a registered view's density / host size (px) and synchronously
38
38
  // redelivers the driver's visible value pre-normalized against them.
39
39
  void setViewHostGeometryAndroid(
@@ -45,13 +45,17 @@ void setViewHostGeometryAndroid(
45
45
  void unregisterViewAndroid(
46
46
  uint64_t driverId,
47
47
  facebook::jni::alias_ref<JSmoothClipView> view);
48
- // Called from Kotlin when a registered view attaches to a window. A latched
49
- // animation may only start once a registered view can actually produce a
50
- // visible frame (attached AND has host geometry); otherwise its clock would
51
- // burn progress no one can see.
52
- void viewBecameDisplayableAndroid(
48
+ // Pushes attachment/window visibility independently of host geometry. The
49
+ // registry combines both signals into its displayability/participant state.
50
+ void setViewLifecycleVisibilityAndroid(
53
51
  uint64_t driverId,
54
- facebook::jni::alias_ref<JSmoothClipView> view);
52
+ facebook::jni::alias_ref<JSmoothClipView> view,
53
+ bool lifecycleVisible);
54
+
55
+ // Advances the registry frame loop. Called from Kotlin (SmoothClipBindings)
56
+ // inside Choreographer#doFrame with the frame's vsync timestamp converted to
57
+ // seconds; never lets a failure unwind back into doFrame.
58
+ void onFrameAndroid(double frameTimeS);
55
59
 
56
60
  // Installs `global.__SmoothClipView` (worklet-callable host functions) into the
57
61
  // JS runtime and wires native animation completion delivery through the
@@ -6,6 +6,7 @@
6
6
  #include <jsi/jsi.h>
7
7
 
8
8
  #include <cmath>
9
+ #include <limits>
9
10
  #include <memory>
10
11
  #include <unordered_map>
11
12
  #include <utility>
@@ -45,6 +46,18 @@ bool boolArg(const Value *args, size_t count, size_t index) {
45
46
  return index < count && args[index].isBool() && args[index].getBool();
46
47
  }
47
48
 
49
+ // Optional trailing start stamp (Reanimated-rule milliseconds captured in the
50
+ // issuing worklet), converted to the registry's CLOCK_MONOTONIC seconds. A
51
+ // missing or non-numeric argument — and the JS side's deliberate NaN fallback
52
+ // when the worklet globals are absent — resolves to NaN, which
53
+ // resolveStartStamp() treats as "no hint": nowSeconds() plus the min() anchor,
54
+ // exactly the pre-hint behavior.
55
+ double startStampArg(const Value *args, size_t count, size_t index) {
56
+ return index < count && args[index].isNumber()
57
+ ? args[index].asNumber() / 1000.0
58
+ : std::numeric_limits<double>::quiet_NaN();
59
+ }
60
+
48
61
  Presentation presentationFromArgs(const Value *args, size_t offset) {
49
62
  return Presentation{
50
63
  {args[offset].asNumber(),
@@ -153,15 +166,17 @@ void installBindings(
153
166
  Object bindings(runtime);
154
167
 
155
168
  setHostFunction(
156
- runtime, bindings, "setClipPresentation", 9,
169
+ runtime, bindings, "setClipPresentation", 10,
157
170
  [](Runtime &rt, const Value &, const Value *args, size_t count) -> Value {
158
171
  if (count < 9) return Value::undefined();
159
172
  const double driverId = args[0].asNumber();
160
173
  const Presentation presentation = presentationFromArgs(args, 1);
161
174
  const bool takeOwnership = boolArg(args, count, 8);
175
+ const bool overridePendingAnimation = boolArg(args, count, 9);
162
176
  if (validDriverId(driverId) && finitePresentation(presentation)) {
163
177
  setPresentation(
164
- static_cast<uint64_t>(driverId), presentation, takeOwnership);
178
+ static_cast<uint64_t>(driverId), presentation, takeOwnership,
179
+ overridePendingAnimation);
165
180
  }
166
181
  return Value::undefined();
167
182
  });
@@ -177,7 +192,7 @@ void installBindings(
177
192
  });
178
193
 
179
194
  setHostFunction(
180
- runtime, bindings, "animateTiming", 22,
195
+ runtime, bindings, "animateTiming", 23,
181
196
  [](Runtime &rt, const Value &, const Value *args, size_t count) -> Value {
182
197
  if (count < 22) return Value(0);
183
198
  const double driverId = args[0].asNumber();
@@ -194,13 +209,13 @@ void installBindings(
194
209
  }
195
210
  return Value(animateTiming(
196
211
  static_cast<uint64_t>(driverId),
197
- {boolArg(args, count, 1), start},
212
+ {boolArg(args, count, 1), start, startStampArg(args, count, 22)},
198
213
  target,
199
214
  animation));
200
215
  });
201
216
 
202
217
  setHostFunction(
203
- runtime, bindings, "animateSpring", 22,
218
+ runtime, bindings, "animateSpring", 23,
204
219
  [](Runtime &rt, const Value &, const Value *args, size_t count) -> Value {
205
220
  if (count < 22) return Value(0);
206
221
  const double driverId = args[0].asNumber();
@@ -221,13 +236,13 @@ void installBindings(
221
236
  }
222
237
  return Value(animateSpring(
223
238
  static_cast<uint64_t>(driverId),
224
- {boolArg(args, count, 1), start},
239
+ {boolArg(args, count, 1), start, startStampArg(args, count, 22)},
225
240
  target,
226
241
  animation));
227
242
  });
228
243
 
229
244
  setHostFunction(
230
- runtime, bindings, "animateKeyframes", 19,
245
+ runtime, bindings, "animateKeyframes", 20,
231
246
  [](Runtime &rt, const Value &, const Value *args, size_t count) -> Value {
232
247
  if (count < 19 || !args[17].isObject() ||
233
248
  !args[17].getObject(rt).isArray(rt)) {
@@ -270,7 +285,7 @@ void installBindings(
270
285
  }
271
286
  return Value(animateKeyframes(
272
287
  static_cast<uint64_t>(driverId),
273
- {boolArg(args, count, 1), start},
288
+ {boolArg(args, count, 1), start, startStampArg(args, count, 19)},
274
289
  target,
275
290
  durationMs,
276
291
  std::move(keyframes),
@@ -374,7 +389,8 @@ void nativeRegisterView(
374
389
  jdouble contentTranslateY,
375
390
  jdouble density,
376
391
  jdouble widthPx,
377
- jdouble heightPx) {
392
+ jdouble heightPx,
393
+ jboolean lifecycleVisible) {
378
394
  if (driverId <= 0 || !std::isfinite(driverId)) return;
379
395
  smoothclip::registerViewAndroid(
380
396
  static_cast<uint64_t>(driverId),
@@ -383,7 +399,8 @@ void nativeRegisterView(
383
399
  {x, y, width, height, radius}, contentTranslateX, contentTranslateY},
384
400
  density,
385
401
  widthPx,
386
- heightPx);
402
+ heightPx,
403
+ lifecycleVisible != 0);
387
404
  }
388
405
 
389
406
  void nativeSetViewHostGeometry(
@@ -406,19 +423,24 @@ void nativeUnregisterView(
406
423
  smoothclip::unregisterViewAndroid(static_cast<uint64_t>(driverId), view);
407
424
  }
408
425
 
409
- void nativeViewBecameDisplayable(
426
+ void nativeSetViewLifecycleVisibility(
410
427
  jni::alias_ref<jni::JObject>,
411
428
  jdouble driverId,
412
- jni::alias_ref<smoothclip::JSmoothClipView> view) {
429
+ jni::alias_ref<smoothclip::JSmoothClipView> view,
430
+ jboolean lifecycleVisible) {
413
431
  if (driverId <= 0 || !std::isfinite(driverId)) return;
414
- smoothclip::viewBecameDisplayableAndroid(
415
- static_cast<uint64_t>(driverId), view);
432
+ smoothclip::setViewLifecycleVisibilityAndroid(
433
+ static_cast<uint64_t>(driverId), view, lifecycleVisible != 0);
416
434
  }
417
435
 
418
436
  void nativeInvalidate(jni::alias_ref<jni::JObject>) {
419
437
  smoothclip::invalidateBindings();
420
438
  }
421
439
 
440
+ void nativeOnFrame(jni::alias_ref<jni::JObject>, jlong frameTimeNanos) {
441
+ smoothclip::onFrameAndroid(static_cast<double>(frameTimeNanos) / 1e9);
442
+ }
443
+
422
444
  } // namespace
423
445
 
424
446
  JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
@@ -432,8 +454,10 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
432
454
  "nativeSetViewHostGeometry", nativeSetViewHostGeometry),
433
455
  makeNativeMethod("nativeUnregisterView", nativeUnregisterView),
434
456
  makeNativeMethod(
435
- "nativeViewBecameDisplayable", nativeViewBecameDisplayable),
457
+ "nativeSetViewLifecycleVisibility",
458
+ nativeSetViewLifecycleVisibility),
436
459
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
460
+ makeNativeMethod("nativeOnFrame", nativeOnFrame),
437
461
  });
438
462
  });
439
463
  }