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

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 (40) hide show
  1. package/README.md +62 -25
  2. package/android/CMakeLists.txt +0 -1
  3. package/android/src/main/cpp/SmoothClipAndroid.h +5 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +28 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +198 -189
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +61 -11
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +20 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +1 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +105 -34
  10. package/cpp/SmoothClipAnimationCurve.h +468 -0
  11. package/cpp/SmoothClipAnimationId.h +32 -0
  12. package/cpp/SmoothClipRegistry.h +11 -1
  13. package/cpp/SmoothClipVelocityTracker.h +32 -15
  14. package/ios/SmoothClipRegistry.mm +246 -97
  15. package/ios/SmoothClipTurboModule.cpp +4 -2
  16. package/ios/SmoothClipTurboModule.h +2 -1
  17. package/ios/SmoothClipView.mm +73 -16
  18. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  19. package/lib/module/drivers.android.js +3 -1
  20. package/lib/module/drivers.android.js.map +1 -1
  21. package/lib/module/drivers.ios.js +3 -391
  22. package/lib/module/drivers.ios.js.map +1 -1
  23. package/lib/module/drivers.native.js +470 -0
  24. package/lib/module/drivers.native.js.map +1 -0
  25. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  26. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  27. package/lib/typescript/src/driverTypes.d.ts +6 -3
  28. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  29. package/lib/typescript/src/drivers.android.d.ts +2 -2
  30. package/lib/typescript/src/drivers.android.d.ts.map +1 -1
  31. package/lib/typescript/src/drivers.ios.d.ts +2 -4
  32. package/lib/typescript/src/drivers.ios.d.ts.map +1 -1
  33. package/lib/typescript/src/drivers.native.d.ts +5 -0
  34. package/lib/typescript/src/drivers.native.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/NativeSmoothClipModule.ts +2 -1
  37. package/src/driverTypes.ts +6 -3
  38. package/src/drivers.android.ts +4 -2
  39. package/src/drivers.ios.ts +4 -687
  40. package/src/drivers.native.ts +805 -0
package/README.md CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/react-native-smooth-clip-view.svg)](https://www.npmjs.com/package/react-native-smooth-clip-view)
4
4
 
5
- Layout-free animated rounded clipping for React Native Fabric.
5
+ ## High-performance geometry animations for React Native
6
6
 
7
- `SmoothClipView` keeps a fixed maximum Yoga footprint while a reusable driver
8
- updates only the native clipping layer. It is
9
- useful for expanding cards, sheets, maps, media, zoom transitions, and other reveals where
10
- animating layout width and height cause lag because of yoga/fabric calculating layout every frame.
7
+ `react-native-smooth-clip-view` lets you animate `x`, `y`, `width`, `height`, and `borderRadius` with Reanimated without triggering expensive layout work on every frame.
11
8
 
12
- In other words, it makes animating width height on Reanimated a cheap operation.
9
+ Instead of resizing the Yoga layout, `SmoothClipView` keeps a fixed footprint and updates only the native clipping layer. This makes geometry-heavy animations smooth and inexpensive—even for shared-element transitions, zoom transitions, expanding cards, reveals, sheets, maps, and media.
10
+
11
+ Use it for transitions that previously struggled with performance when animating layout dimensions directly.
13
12
 
14
13
  ## Requirements
15
14
 
@@ -166,6 +165,15 @@ const gesture = Gesture.Pan()
166
165
  | `driver` | `SmoothClipDriver` | Reusable hybrid clip driver. |
167
166
  | `children` | `ReactNode` | Content rendered inside the fixed host. |
168
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
+
169
177
  ### Driver
170
178
 
171
179
  - `useSmoothClipDriver(initialPresentation, options)` returns one hybrid driver
@@ -191,11 +199,19 @@ const gesture = Gesture.Pan()
191
199
  remaining distance in one second). Every geometry channel continues with the
192
200
  same normalized rate, so grab/release preserves the felt direction and
193
201
  speed. `'inherit'` (the default) estimates the scalar from the last two
194
- interactive samples on iOS and Android; samples older than 100 ms — and the
195
- 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
196
208
  (< 4 ms apart, e.g. a release-sample `from` seed right after the last drag
197
209
  write) coalesce into one sample, and an identical re-write is ignored, so a
198
210
  fused handoff can neither zero nor inflate the inherited velocity.
211
+ `beginInteraction()` records the frozen presentation as a plain sample on
212
+ both platforms, so a grab-and-instant-refling inherits bounded recent
213
+ motion (the staleness guard still zeroes a stale pair) instead of
214
+ launching dead.
199
215
  - `driver.react` exposes `beginInteraction`, `set`, `animateTo`, and `cancel`
200
216
  as Promises (`setScalars` is UI-worklet-only). React code never blocks
201
217
  waiting for main/UI-thread work. An immediate animation request resolves
@@ -206,37 +222,58 @@ const gesture = Gesture.Pan()
206
222
  animation can run the identical curve without hand-deriving it; springs
207
223
  accept mass, stiffness, damping, and an
208
224
  explicit normalized velocity or `'inherit'` (the default). Keyframes accept
209
- validated, monotonically increasing offsets from zero through one. Every
225
+ validated, monotonically increasing offsets from zero through one; there is
226
+ deliberately no keyframe easing field — playback is linear between offsets
227
+ and the frames encode the curve, which also expresses per-channel-nonlinear
228
+ paths that no single time-warp could reproduce. Every
210
229
  kind accepts an optional `from` presentation — a fused take-ownership hot
211
230
  write issued immediately before the handoff, so the animation starts from
212
231
  exactly that value (pass `frames[0].presentation` for keyframes, which
213
232
  interpolate absolutely). A non-finite `from` rejects the whole call; against
214
- a held pending-animation latch the seed is dropped — the latch is newer
215
- intent, and nothing is displayable yet anyway. `from` behaves the same on
216
- both platforms (it is driver-layer, not native): on iOS the seed stops any
217
- running Core Animation and writes the model layer first; keyframes then
218
- start exactly at `from`, while timing/spring sample their from-value off
219
- the presentation layer — the last committed frame, at most one frame
220
- behind `from` — identical to the explicit two-call pattern.
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.
221
242
  - `cancel()` freezes visible presentation by default. Pass `'target'` as its
222
243
  behavior to jump to the requested endpoint.
223
244
  - `options.onAnimationComplete` fires exactly once per animation with its ID
224
245
  and `finished` state, including cancellation, replacement, and native-side
225
246
  rejection (`animateTo` then returns a fresh non-zero id whose single
226
247
  `finished: false` completion follows — key completion handling by the
227
- returned id, never by `0`). `animateTo` returns `0` — and delivers no
228
- completion — only when the driver entry no longer exists (destroyed) or the
229
- call ran off the main thread. Unmounting the last host mid-flight does not complete the
230
- animation: the remainder is re-latched and the next displayable host resumes
231
- it, so the completion arrives when it finishes there — or unfinished on
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, and any `driver.ui.animateTo` issued after the
253
+ driver's hook has unmounted. (The second case is decided on the UI runtime,
254
+ because a destroyed driver and a not-yet-seeded one are the same missing
255
+ registry entry to native — accepting it would build a latch nothing can start
256
+ and nothing can cancel.) Removing the last displayable
257
+ host mid-flight does not complete the animation: the remainder is re-latched
258
+ even if detached or unsized peers remain registered, and the next displayable
259
+ host resumes it, so the completion arrives when it finishes there — or unfinished on
232
260
  replacement, cancellation, `beginInteraction()`, or driver destruction.
261
+ With multiple hosts on one driver, unregistering any host that installed the
262
+ running animation makes the eventual completion `finished: false` — a
263
+ detached or un-laid-out host does not become a completion participant until
264
+ its native animation is actually installed. `finished: true` means every
265
+ installed participant ran the animation to its end.
233
266
  - An `animateTo` issued before any host view can produce a visible frame (for
234
267
  example from an effect in the same commit that mounts the host, or inside a
235
268
  modal route whose subtree attaches to its window late) is held pending and
236
- starts with its full duration at the first displayable registration or
237
- window attach. A pending animation owns the driver: take-ownership writes
238
- (`set`, `setScalars`, the hook's seed) are dropped while it is held —
239
- replace it with another `animateTo`, or cancel it via `beginInteraction()`
269
+ starts with its full duration at the first moment a registered host can
270
+ produce a frame — registration, first layout, or window attach, whichever
271
+ comes last. This also covers an animation worklet that runs before the
272
+ hook's seed worklet: the animation creates the state and the later passive
273
+ seed cannot reset its ownership or active id. A pending animation owns the
274
+ driver: ordinary take-ownership writes (`set`, `setScalars`, the hook's seed)
275
+ are dropped while it is held. Replace it with another `animateTo`, override
276
+ it with an explicit `animation.from`, or cancel it via `beginInteraction()`
240
277
  or `cancel()`. If no view ever becomes displayable, it survives until it is
241
278
  replaced, cancelled, or the driver is destroyed — at which point its single
242
279
  `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
@@ -53,6 +53,11 @@ void viewBecameDisplayableAndroid(
53
53
  uint64_t driverId,
54
54
  facebook::jni::alias_ref<JSmoothClipView> view);
55
55
 
56
+ // Advances the registry frame loop. Called from Kotlin (SmoothClipBindings)
57
+ // inside Choreographer#doFrame with the frame's vsync timestamp converted to
58
+ // seconds; never lets a failure unwind back into doFrame.
59
+ void onFrameAndroid(double frameTimeS);
60
+
56
61
  // Installs `global.__SmoothClipView` (worklet-callable host functions) into the
57
62
  // JS runtime and wires native animation completion delivery through the
58
63
  // CallInvoker. Invoked by the TurboModule BindingsInstaller.
@@ -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),
@@ -419,6 +434,10 @@ void nativeInvalidate(jni::alias_ref<jni::JObject>) {
419
434
  smoothclip::invalidateBindings();
420
435
  }
421
436
 
437
+ void nativeOnFrame(jni::alias_ref<jni::JObject>, jlong frameTimeNanos) {
438
+ smoothclip::onFrameAndroid(static_cast<double>(frameTimeNanos) / 1e9);
439
+ }
440
+
422
441
  } // namespace
423
442
 
424
443
  JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
@@ -434,6 +453,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
434
453
  makeNativeMethod(
435
454
  "nativeViewBecameDisplayable", nativeViewBecameDisplayable),
436
455
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
456
+ makeNativeMethod("nativeOnFrame", nativeOnFrame),
437
457
  });
438
458
  });
439
459
  }