react-native-smooth-clip-view 0.4.5 → 0.5.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 (65) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +52 -0
  3. package/android/src/main/cpp/SmoothClipAndroid.h +22 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +37 -3
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +85 -2
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +209 -10
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBackdropView.kt +59 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBackdropViewManager.kt +63 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +15 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +316 -34
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +19 -0
  12. package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +1 -1
  13. package/cpp/SmoothClipAnimationCurve.h +36 -11
  14. package/cpp/SmoothClipRegistry.h +11 -4
  15. package/ios/SmoothClipBackdropView.h +17 -0
  16. package/ios/SmoothClipBackdropView.mm +267 -0
  17. package/ios/SmoothClipGeometry.h +8 -7
  18. package/ios/SmoothClipRegistry.mm +192 -9
  19. package/ios/SmoothClipTurboModule.cpp +33 -11
  20. package/ios/SmoothClipView.mm +713 -22
  21. package/ios/SmoothClipViewRegistry.h +14 -0
  22. package/lib/module/SmoothClipBackdropView.js +40 -0
  23. package/lib/module/SmoothClipBackdropView.js.map +1 -0
  24. package/lib/module/SmoothClipBackdropViewNativeComponent.ts +15 -0
  25. package/lib/module/SmoothClipView.ios.js +17 -1
  26. package/lib/module/SmoothClipView.ios.js.map +1 -1
  27. package/lib/module/SmoothClipViewNativeComponent.ts +4 -0
  28. package/lib/module/geometry.js +44 -6
  29. package/lib/module/geometry.js.map +1 -1
  30. package/lib/module/groups.native.js +10 -3
  31. package/lib/module/groups.native.js.map +1 -1
  32. package/lib/module/index.js +1 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/presentationCodec.js +6 -2
  35. package/lib/module/presentationCodec.js.map +1 -1
  36. package/lib/typescript/src/SmoothClipBackdropView.d.ts +155 -0
  37. package/lib/typescript/src/SmoothClipBackdropView.d.ts.map +1 -0
  38. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts +8 -0
  39. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts.map +1 -0
  40. package/lib/typescript/src/SmoothClipView.android.d.ts +1 -1
  41. package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -1
  42. package/lib/typescript/src/SmoothClipView.d.ts +1 -1
  43. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  44. package/lib/typescript/src/SmoothClipView.ios.d.ts +15 -1
  45. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  46. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
  47. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  48. package/lib/typescript/src/geometry.d.ts +19 -0
  49. package/lib/typescript/src/geometry.d.ts.map +1 -1
  50. package/lib/typescript/src/groups.native.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +4 -2
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  54. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  55. package/package.json +4 -1
  56. package/src/SmoothClipBackdropView.tsx +53 -0
  57. package/src/SmoothClipBackdropViewNativeComponent.ts +15 -0
  58. package/src/SmoothClipView.android.tsx +4 -1
  59. package/src/SmoothClipView.ios.tsx +24 -1
  60. package/src/SmoothClipView.tsx +4 -1
  61. package/src/SmoothClipViewNativeComponent.ts +4 -0
  62. package/src/geometry.ts +59 -4
  63. package/src/groups.native.ts +9 -3
  64. package/src/index.ts +7 -1
  65. package/src/presentationCodec.ts +8 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.5.0) — 2026-09-28
4
+
5
+ - **Backdrop channel.** A presentation gains `backdrop: { translateX,
6
+ translateY }`, and the new `SmoothClipBackdropView` (`controller={clip}`)
7
+ is translated by it: every `setFrame` writes the channel in the same native
8
+ call as the clip, and every run animates it on the clip's own epoch (a
9
+ translation group on the shared Core Animation `beginTime` on iOS, the same
10
+ Choreographer advance on Android). Content that must stay locked to the
11
+ aperture no longer needs a Reanimated mapper, which can land a frame after
12
+ the clip. The native packet grows by two values (stride 23 → 25) and the
13
+ host takes `initialBackdropTranslateX/Y`, so a native rebuild is required;
14
+ a JavaScript-only update cannot upgrade an older native build.
15
+ - iOS anchors a native run to the frame stamp JS passes (Reanimated's
16
+ `__frameTimestamp`, the display link's target vsync) as the shared Core
17
+ Animation `beginTime`, the way the Android frame loop already did. A run and
18
+ a `withTiming` begun in the same UI frame now trace one epoch; before, the
19
+ run began at its install time and led the Reanimated model by the rest of
20
+ that frame (about 14 ms at 60 Hz). Animation groups carry backwards fill so a
21
+ stamp up to one frame ahead of the commit shows the run's first frame, not
22
+ the target. JS now stamps runs on every platform.
23
+ - `shadowRendering="baked"` on `SmoothClipView` renders the box shadow from one
24
+ pre-blurred tile per 4 pt corner-radius step that the compositor stretches
25
+ (`contentsCenter` on iOS, nine bitmap pieces on Android), so an animated or
26
+ dragged aperture costs no blur per frame. iOS bakes the tile through Core
27
+ Animation's own shadow path so it matches a `shadowPath` layer; Android
28
+ bakes it with the same `BlurMaskFilter` as the blur path. Runs animate the
29
+ tile layer's frame and opacity and swap tiles in steps where the radius,
30
+ blur or colour changes. Uniform radii only, and a shape at least
31
+ 2 × (1.5 × blur + radius) on a side; unequal radii or a smaller shape keep
32
+ the blur path. Default stays `"blur"`.
33
+ - Android builds a uniform circular corner with `addRoundRect` in the shared
34
+ path builder, so the shadow path (not only the clip) stays an rrect the
35
+ renderer can clip and blur analytically. Paths are `rewind()` instead of
36
+ `reset()` between frames, keeping their storage.
37
+ - Baked tiles no longer bake inside the frame. A shadow's first tile bakes on
38
+ the calling thread (at mount, when nothing else can be shown) and a run's
39
+ resting target bakes at install, so the run ends exact; every other radius
40
+ step bakes off the frame (one per main-queue turn on iOS, a background
41
+ thread on Android) while the nearest cached step stands in, and the model
42
+ takes the exact tile when it lands. A run whose intermediate steps are not
43
+ cached yet swaps to the next cached, rounder tile at the missing step's key
44
+ time, so the shown corner is never tighter than the clip's.
45
+ - iOS discrete tile swaps carry the closing key time Core Animation's discrete
46
+ mode requires (`keyTimes` has one more entry than `values`, ending at 1), so
47
+ each swap lands where the radius crosses its step rather than on an even
48
+ grid.
49
+ - iOS trusts a frame stamp only within the same ±1 s window as Android. A
50
+ group held pending longer (a host that could not display, the app inactive)
51
+ or a stamp from a rescaled clock starts now instead of beginning fully
52
+ elapsed.
53
+ - `canonicalizeClipPresentation` validates and canonicalizes in one pass: the
54
+ shadow colour, the geometry and the rotation were each parsed twice per
55
+ `setFrame`. The iOS packet reader reads the array length once per
56
+ presentation instead of once per value.
57
+
58
+ ## [0.4.6](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.6) — 2026-09-20
59
+
60
+ - Draw `continuous` corners on Android as a Figma smoothed corner (smoothing
61
+ 0.6) instead of a single cubic pulled towards the corner point. The apex now
62
+ matches a circular corner of the same radius and the shoulders ease in from
63
+ 1.6 × the radius, so a radius reads the same as on iOS and as
64
+ `react-native-fast-squircle` at `cornerSmoothing={0.6}`. Clip, shadow, and hit
65
+ testing share the path. The per-frame rebuild still allocates nothing and
66
+ does no trigonometry unless a shoulder has to be shortened to fit.
67
+ Existing Android `continuous` clips become visibly rounder at the same radius.
68
+
3
69
  ## [0.4.5](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.5) — 2026-09-20
4
70
 
5
71
  - Run native `animateTo` for uniform `continuous` clips. The autonomous gate now
package/README.md CHANGED
@@ -214,11 +214,47 @@ APIs share validation, run ownership, and completion behavior.
214
214
  reject the complete presentation. Spring opacity is clamped for rendering
215
215
  without clamping its internal spring state.
216
216
  - Circular and continuous curves and independent corner radii are supported.
217
+ - `continuous` is the platform squircle: `CALayer.cornerCurve` on iOS for
218
+ uniform radii, and a Figma smoothed corner at smoothing 0.6 on Android (the
219
+ shape `react-native-fast-squircle` draws at `cornerSmoothing={0.6}`). The apex
220
+ matches a circular corner of the same radius; the shoulders start 1.6 × the
221
+ radius from the corner. The two platforms are close, not pixel-identical.
217
222
  - Native `animateTo` runs need uniform corner radii and one curve for the whole
218
223
  run, so a uniform `continuous` clip animates natively. Unequal radii or a
219
224
  curve change are static-only: set them with `setFrame`; `animateTo` returns
220
225
  `null`.
221
226
  - One outset `boxShadow` is supported. It escapes the aperture but not the host.
227
+ - `shadowRendering="baked"` on the host draws that shadow from one pre-blurred
228
+ tile per corner-radius step (4 pt), stretched by the compositor, instead of
229
+ blurring a path on every frame the aperture moves: a native run or a drag of
230
+ a full-screen window then costs no per-frame blur on either platform. A tile
231
+ is baked once per (radius step, blur, colour), about a millisecond for a
232
+ 16 pt blur, and cached for the process. Only two bakes happen on the calling
233
+ thread: a shadow's first tile (at mount, when nothing else can be shown)
234
+ and a run's resting target (at install, so the run ends exact). Every other
235
+ step bakes off the frame while the nearest cached step stands in, so a drag
236
+ that crosses radius steps never blocks a frame. It needs uniform corner radii
237
+ and a shape at least 2 × (1.5 × blur + radius) on each side, room for two
238
+ corner slices (unequal radii or a smaller shape keep the blur path for that
239
+ frame or run); the tile corner
240
+ rounds up to the next step, so a shadow corner may be up to 4 pt rounder
241
+ than its clip, which no blur the tile was made for resolves; a run that
242
+ changes radius, blur or colour swaps tiles in steps along the way. The baked
243
+ shadow is drawn under the aperture on both platforms (the Android blur path
244
+ cuts it out), so content over it should be opaque.
245
+ - `backdrop: { translateX, translateY }` on a presentation translates every
246
+ `SmoothClipBackdropView` bound to the same controller (`controller={clip}`,
247
+ anywhere in the tree, any number of them). The channel is part of the
248
+ presentation, so a `setFrame` writes it in the same native call as the clip
249
+ and a run animates it on the clip's own epoch: content that must stay
250
+ locked to the aperture (a screen-sized canvas centred in the window) is
251
+ sampled by the same clock as the clip, with no Reanimated mapper in
252
+ between. The translation goes on an inner content layer; the view's own
253
+ `transform` style stays React Native's. A backdrop that binds while a run
254
+ is in flight adopts the driver's current value and follows from the next
255
+ run. While no backdrop is bound the channel is not interpolated: a snapshot
256
+ or freeze taken mid-run reports the run's target translation, which is what
257
+ a backdrop binding at that moment adopts.
222
258
  - A fully off-host aperture is not touchable, even when only its shadow overlaps.
223
259
  - Descendant accessibility is hidden during autonomous native motion and restored
224
260
  from aperture/host intersection at the endpoint.
@@ -276,6 +312,22 @@ See the [changelog](./CHANGELOG.md) for release details.
276
312
 
277
313
  - One worklet-to-native call per `ui.setFrame`, or per group batch.
278
314
  - Native timing and springs run without JS work between start and completion.
315
+ - A run started inside a UI frame is anchored to that frame's Reanimated stamp
316
+ (`__frameTimestamp`) on both platforms, so it shares one epoch with a
317
+ `withTiming` begun in the same frame.
318
+ - A host with no run in flight and no `setFrame` stream costs nothing per
319
+ frame: iOS keeps no display link, and Android posts its frame callback only
320
+ while a run animates. Static content (a list of cards, say) can be
321
+ decorated with a host at no per-frame cost, and hand off to an animated
322
+ host through the same path builder and shadow renderer.
323
+ - Content driven by Reanimated inside the host (an `Animated.View` with a
324
+ `useAnimatedStyle`) can land a frame after the clip on iOS: Reanimated
325
+ applies mapper props in its own display-link pass, separate from the
326
+ worklet frame that computed them, while a native run is sampled by Core
327
+ Animation for the same vsync. A main-thread overrun widens that to a frame
328
+ either way. Anything that must stay locked to the aperture belongs on the
329
+ run itself: put it in a `SmoothClipBackdropView` and drive it through the
330
+ presentation's `backdrop` channel.
279
331
  - The shadow-disabled rendering path keeps no shadow drawing resources.
280
332
  - The fixed host is the maximum rendering viewport; consumers should size it to
281
333
  the region in which content and shadow may appear.
@@ -26,6 +26,28 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
26
26
  void setAutonomousMotion(bool active) const;
27
27
  };
28
28
 
29
+ // fbjni wrapper around com.smoothclipview.SmoothClipBackdropView: a view the
30
+ // driver's backdrop channel translates on every delivery, in the same
31
+ // Choreographer pass as the clip.
32
+ struct JSmoothClipBackdropView
33
+ : facebook::jni::JavaClass<JSmoothClipBackdropView> {
34
+ static constexpr auto kJavaDescriptor =
35
+ "Lcom/smoothclipview/SmoothClipBackdropView;";
36
+
37
+ void applyBackdropPx(double translateXPx, double translateYPx) const;
38
+ };
39
+
40
+ // Binds a backdrop view to a driver on the UI thread. A view already bound
41
+ // only updates its density. The view is delivered the driver's current
42
+ // backdrop translation at once when the driver has a presentation.
43
+ void registerBackdropViewAndroid(
44
+ uint64_t driverId,
45
+ facebook::jni::alias_ref<JSmoothClipBackdropView> view,
46
+ double density);
47
+ void unregisterBackdropViewAndroid(
48
+ uint64_t driverId,
49
+ facebook::jni::alias_ref<JSmoothClipBackdropView> view);
50
+
29
51
  // Called from Kotlin (SmoothClipViewManager) on the UI thread when a Fabric
30
52
  // view mounts/unmounts. The registry retains a global ref for the view.
31
53
  void registerViewAndroid(
@@ -27,7 +27,7 @@ namespace smoothclip {
27
27
  namespace {
28
28
 
29
29
  constexpr double kMaxSafeJavaScriptInteger = 9007199254740991.0;
30
- constexpr size_t kPresentationStride = 23;
30
+ constexpr size_t kPresentationStride = 25;
31
31
  constexpr size_t kSnapshotStride = kPresentationStride + 1;
32
32
  constexpr size_t kMotionEntryStride = kPresentationStride * 2 + 2;
33
33
 
@@ -155,7 +155,8 @@ bool presentationAt(
155
155
  Shadow{
156
156
  packet[12] == 1.0,
157
157
  packet[13], packet[14], packet[15], packet[16],
158
- packet[17], packet[18], packet[19], packet[20]}, packet[21], packet[22]};
158
+ packet[17], packet[18], packet[19], packet[20]}, packet[21], packet[22],
159
+ packet[23], packet[24]};
159
160
  return finitePresentation(result);
160
161
  }
161
162
 
@@ -193,6 +194,8 @@ void appendPresentation(
193
194
  result.setValueAtIndex(runtime, offset++, presentation.shadow.spreadDistance);
194
195
  result.setValueAtIndex(runtime, offset++, presentation.rotation);
195
196
  result.setValueAtIndex(runtime, offset++, presentation.opacity);
197
+ result.setValueAtIndex(runtime, offset++, presentation.backdropTranslateX);
198
+ result.setValueAtIndex(runtime, offset++, presentation.backdropTranslateY);
196
199
  }
197
200
 
198
201
  Array presentationArray(Runtime &runtime, const Presentation &presentation) {
@@ -835,11 +838,15 @@ void nativeRegisterView(
835
838
  jdouble shadowSpreadDistance,
836
839
  jdouble rotation,
837
840
  jdouble opacity,
841
+ jdouble backdropTranslateX,
842
+ jdouble backdropTranslateY,
838
843
  jdouble density,
839
844
  jdouble widthPx,
840
845
  jdouble heightPx,
841
846
  jboolean lifecycleVisible) {
842
847
  if (driverId <= 0 || !std::isfinite(driverId) || !std::isfinite(x) ||
848
+ !std::isfinite(backdropTranslateX) ||
849
+ !std::isfinite(backdropTranslateY) ||
843
850
  !std::isfinite(y) || !std::isfinite(width) || !std::isfinite(height) ||
844
851
  !std::isfinite(topLeftRadius) || !std::isfinite(topRightRadius) ||
845
852
  !std::isfinite(bottomRightRadius) || !std::isfinite(bottomLeftRadius) ||
@@ -882,13 +889,36 @@ void nativeRegisterView(
882
889
  static_cast<uint64_t>(driverId),
883
890
  view,
884
891
  smoothclip::Presentation{
885
- geometry, contentTranslateX, contentTranslateY, contentScale, shadow, rotation, opacity},
892
+ geometry, contentTranslateX, contentTranslateY, contentScale, shadow,
893
+ rotation, opacity, backdropTranslateX, backdropTranslateY},
886
894
  density,
887
895
  widthPx,
888
896
  heightPx,
889
897
  lifecycleVisible != 0);
890
898
  }
891
899
 
900
+ void nativeRegisterBackdropView(
901
+ jni::alias_ref<jni::JObject>,
902
+ jdouble driverId,
903
+ jni::alias_ref<smoothclip::JSmoothClipBackdropView> view,
904
+ jdouble density) {
905
+ if (driverId <= 0 || !std::isfinite(driverId) || !std::isfinite(density) ||
906
+ density <= 0) {
907
+ return;
908
+ }
909
+ smoothclip::registerBackdropViewAndroid(
910
+ static_cast<uint64_t>(driverId), view, density);
911
+ }
912
+
913
+ void nativeUnregisterBackdropView(
914
+ jni::alias_ref<jni::JObject>,
915
+ jdouble driverId,
916
+ jni::alias_ref<smoothclip::JSmoothClipBackdropView> view) {
917
+ if (driverId <= 0 || !std::isfinite(driverId)) return;
918
+ smoothclip::unregisterBackdropViewAndroid(
919
+ static_cast<uint64_t>(driverId), view);
920
+ }
921
+
892
922
  void nativeSetViewHostGeometry(
893
923
  jni::alias_ref<jni::JObject>,
894
924
  jdouble driverId,
@@ -952,6 +982,10 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
952
982
  makeNativeMethod(
953
983
  "nativeSetViewHostGeometry", nativeSetViewHostGeometry),
954
984
  makeNativeMethod("nativeUnregisterView", nativeUnregisterView),
985
+ makeNativeMethod(
986
+ "nativeRegisterBackdropView", nativeRegisterBackdropView),
987
+ makeNativeMethod(
988
+ "nativeUnregisterBackdropView", nativeUnregisterBackdropView),
955
989
  makeNativeMethod("nativeDestroyDriver", nativeDestroyDriver),
956
990
  makeNativeMethod(
957
991
  "nativeSetViewLifecycleVisibility",
@@ -90,11 +90,20 @@ struct ViewEntry {
90
90
  ViewParticipation participation = ViewParticipation::Deferred;
91
91
  };
92
92
 
93
+ // A backdrop view bound to the driver. It is translated by the presentation's
94
+ // backdrop channel on every delivery and has no displayability of its own: the
95
+ // clip host decides when a run may start.
96
+ struct BackdropEntry {
97
+ global_ref<JSmoothClipBackdropView> view;
98
+ double density = 1;
99
+ };
100
+
93
101
  struct DriverState {
94
102
  Presentation latest{{0, 0, 0, 0, 0}, 0, 0};
95
103
  bool hasLatest = false;
96
104
  Ownership ownership = Ownership::Interactive;
97
105
  std::vector<ViewEntry> views;
106
+ std::vector<BackdropEntry> backdrops;
98
107
  std::optional<ActiveAnimation> animation;
99
108
  // Set by destroyDriver while views are still registered (StrictMode effect
100
109
  // replay, hosts mounted in another subtree). The entry is erased when the
@@ -461,10 +470,28 @@ void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
461
470
  shadowPx, presentation.rotation, presentation.opacity);
462
471
  }
463
472
 
473
+ void deliverToBackdrop(
474
+ const BackdropEntry &entry,
475
+ const Presentation &presentation) {
476
+ const double x = presentation.backdropTranslateX * entry.density;
477
+ const double y = presentation.backdropTranslateY * entry.density;
478
+ if (!std::isfinite(x) || !std::isfinite(y)) return;
479
+ entry.view->applyBackdropPx(x, y);
480
+ }
481
+
482
+ void deliverToBackdrops(
483
+ const DriverState &state,
484
+ const Presentation &presentation) {
485
+ for (const BackdropEntry &entry : state.backdrops) {
486
+ deliverToBackdrop(entry, presentation);
487
+ }
488
+ }
489
+
464
490
  void applyToViews(DriverState &state, const Presentation &presentation) {
465
491
  for (const auto &entry : state.views) {
466
492
  deliverToView(entry, presentation);
467
493
  }
494
+ deliverToBackdrops(state, presentation);
468
495
  }
469
496
 
470
497
  void setAutonomousMotion(DriverState &state, bool active) {
@@ -481,6 +508,7 @@ void applyAnimationFrameToViews(
481
508
  deliverToView(entry, presentation);
482
509
  }
483
510
  }
511
+ deliverToBackdrops(state, presentation);
484
512
  }
485
513
 
486
514
  void applyPresentation(DriverState &state, const Presentation &presentation) {
@@ -1667,7 +1695,7 @@ void destroyDriver(uint64_t driverId) {
1667
1695
  // refresh the staleness clock with motion no finger produced. (iOS clears
1668
1696
  // its per-view histories at the same point.)
1669
1697
  state.samples = VelocitySampleHistory{};
1670
- if (state.views.empty()) {
1698
+ if (state.views.empty() && state.backdrops.empty()) {
1671
1699
  registry().erase(iterator);
1672
1700
  } else {
1673
1701
  // Views can outlive the hook briefly (StrictMode effect replay, hosts in
@@ -1795,6 +1823,61 @@ void JSmoothClipView::setAutonomousMotion(bool active) const {
1795
1823
  method(self(), active);
1796
1824
  }
1797
1825
 
1826
+ void JSmoothClipBackdropView::applyBackdropPx(
1827
+ double translateXPx,
1828
+ double translateYPx) const {
1829
+ static const auto method =
1830
+ javaClassStatic()->getMethod<void(jfloat, jfloat)>(
1831
+ "setBackdropTranslationPx");
1832
+ method(
1833
+ self(),
1834
+ static_cast<jfloat>(translateXPx),
1835
+ static_cast<jfloat>(translateYPx));
1836
+ }
1837
+
1838
+ void registerBackdropViewAndroid(
1839
+ uint64_t driverId,
1840
+ alias_ref<JSmoothClipBackdropView> view,
1841
+ double density) {
1842
+ if (!isOnMainThread()) return;
1843
+ auto &state = registry()[driverId];
1844
+ JNIEnv *env = facebook::jni::Environment::current();
1845
+ for (auto &entry : state.backdrops) {
1846
+ if (!env->IsSameObject(entry.view.get(), view.get())) continue;
1847
+ entry.density = density;
1848
+ if (state.hasLatest) {
1849
+ deliverToBackdrop(entry, visiblePresentation(driverId, state));
1850
+ }
1851
+ return;
1852
+ }
1853
+ BackdropEntry entry{facebook::jni::make_global(view), density};
1854
+ // A backdrop that binds mid-run adopts the driver's current value and
1855
+ // follows from the next delivery; a pending run's start is what shows.
1856
+ if (state.hasLatest) {
1857
+ deliverToBackdrop(entry, visiblePresentation(driverId, state));
1858
+ }
1859
+ state.backdrops.push_back(std::move(entry));
1860
+ }
1861
+
1862
+ void unregisterBackdropViewAndroid(
1863
+ uint64_t driverId,
1864
+ alias_ref<JSmoothClipBackdropView> view) {
1865
+ auto iterator = registry().find(driverId);
1866
+ if (iterator == registry().end()) return;
1867
+ auto &state = iterator->second;
1868
+ JNIEnv *env = facebook::jni::Environment::current();
1869
+ for (auto entry = state.backdrops.begin(); entry != state.backdrops.end();) {
1870
+ if (env->IsSameObject(entry->view.get(), view.get())) {
1871
+ entry = state.backdrops.erase(entry);
1872
+ } else {
1873
+ ++entry;
1874
+ }
1875
+ }
1876
+ if (state.destroyed && state.views.empty() && state.backdrops.empty()) {
1877
+ registry().erase(iterator);
1878
+ }
1879
+ }
1880
+
1798
1881
  void registerViewAndroid(
1799
1882
  uint64_t driverId,
1800
1883
  alias_ref<JSmoothClipView> view,
@@ -1921,7 +2004,7 @@ void unregisterViewAndroid(
1921
2004
  if (removed && state.groupId != 0 && isOnMainThread()) {
1922
2005
  reconcileGroupReadiness(driverId);
1923
2006
  }
1924
- if (state.destroyed && state.views.empty()) {
2007
+ if (state.destroyed && state.views.empty() && state.backdrops.empty()) {
1925
2008
  registry().erase(iterator);
1926
2009
  }
1927
2010
  }
@@ -2,8 +2,11 @@ package com.smoothclipview
2
2
 
3
3
  import android.graphics.Path
4
4
  import android.view.View
5
+ import kotlin.math.cos
5
6
  import kotlin.math.max
6
7
  import kotlin.math.min
8
+ import kotlin.math.sin
9
+ import kotlin.math.tan
7
10
 
8
11
  internal const val CLIP_CURVE_CIRCULAR = 0
9
12
  internal const val CLIP_CURVE_CONTINUOUS = 1
@@ -119,12 +122,40 @@ internal inline fun canonicalizeClipGeometryPx(
119
122
  }
120
123
 
121
124
  /**
122
- * Emits one fixed-topology rounded rectangle for both curve families.
123
- * Circular uses the standard quarter-circle cubic coefficient; continuous
124
- * keeps both control points at the rectangle corner (coefficient 1), yielding
125
- * zero endpoint curvature and a visibly smoother shoulder. "Continuous" is a
126
- * library-defined cross-platform curve, not a claim of pixel identity with
127
- * Apple's private continuous-corner implementation.
125
+ * Figma corner smoothing used by the continuous curve. 0.6 is the value Figma
126
+ * tuned against iOS continuous corners, and it is fixed so that every corner
127
+ * parameter below is a compile-time constant.
128
+ */
129
+ private const val CONTINUOUS_SMOOTHING = 0.6f
130
+
131
+ // Unit-radius Figma parameters at CONTINUOUS_SMOOTHING. They are linear in the
132
+ // radius while the shoulder fits its budget, so the per-frame rebuild does no
133
+ // trigonometry. Derivation, with arc = 90deg * (1 - s) and beta = 45deg * s:
134
+ // ARC_CHORD = sqrt(2) * sin(arc / 2) axis distance covered by the arc
135
+ // C = tan(beta / 2) * cos(beta), D = tan(beta / 2) * sin(beta)
136
+ // B = (1 + s - ARC_CHORD - C - D) / 3, A = 2 * B
137
+ // ARC_HANDLE = 4/3 * tan(arc / 4) cubic handle of the circular arc
138
+ private const val CONTINUOUS_A = 0.5600525f
139
+ private const val CONTINUOUS_B = 0.28002625f
140
+ private const val CONTINUOUS_C = 0.21391174f
141
+ private const val CONTINUOUS_D = 0.10899348f
142
+ private const val CONTINUOUS_ARC_CHORD = 0.43701602f
143
+ private const val CONTINUOUS_ARC_HANDLE = 0.21117925f
144
+ private const val CONTINUOUS_COS_BETA = 0.8910065f
145
+ private const val CONTINUOUS_SIN_BETA = 0.4539905f
146
+ private const val QUARTER_PI = 0.7853982f
147
+ private const val SQRT_2 = 1.4142135f
148
+
149
+ /**
150
+ * Emits one rounded rectangle. Circular uses the standard quarter-circle
151
+ * cubic per corner. Continuous is the Figma smoothed corner at smoothing 0.6:
152
+ * a cubic shoulder that leaves the edge at 1.6 * radius, the real radius-R
153
+ * circle through the apex, and a mirrored shoulder. The apex therefore sits
154
+ * where a circular corner's does; only the shoulders are longer and eased. It
155
+ * is the same family as Apple's continuous corner, not pixel-identical to it.
156
+ *
157
+ * This runs every animation frame: it allocates nothing, and a continuous
158
+ * corner is always three cubics (the arc included), whatever its radius.
128
159
  */
129
160
  internal fun appendRoundedRectPath(
130
161
  path: Path,
@@ -138,12 +169,57 @@ internal fun appendRoundedRectPath(
138
169
  bottomLeftRadius: Float,
139
170
  curveCode: Int,
140
171
  ) {
141
- val coefficient = if (curveCode == CLIP_CURVE_CONTINUOUS) {
142
- 1f
143
- } else {
144
- 0.5522848f
172
+ if (curveCode == CLIP_CURVE_CONTINUOUS) {
173
+ val width = right - left
174
+ val height = bottom - top
175
+ appendContinuousCorner(
176
+ path, true, right, top, 1f, 0f, 0f, 1f, topRightRadius,
177
+ continuousBudget(
178
+ topRightRadius, topLeftRadius, width, bottomRightRadius, height,
179
+ ),
180
+ )
181
+ appendContinuousCorner(
182
+ path, false, right, bottom, 0f, 1f, -1f, 0f, bottomRightRadius,
183
+ continuousBudget(
184
+ bottomRightRadius, topRightRadius, height, bottomLeftRadius, width,
185
+ ),
186
+ )
187
+ appendContinuousCorner(
188
+ path, false, left, bottom, -1f, 0f, 0f, -1f, bottomLeftRadius,
189
+ continuousBudget(
190
+ bottomLeftRadius, bottomRightRadius, width, topLeftRadius, height,
191
+ ),
192
+ )
193
+ appendContinuousCorner(
194
+ path, false, left, top, 0f, -1f, 1f, 0f, topLeftRadius,
195
+ continuousBudget(
196
+ topLeftRadius, bottomLeftRadius, height, topRightRadius, width,
197
+ ),
198
+ )
199
+ path.close()
200
+ return
145
201
  }
146
202
 
203
+ if (topLeftRadius == topRightRadius && topLeftRadius == bottomRightRadius &&
204
+ topLeftRadius == bottomLeftRadius
205
+ ) {
206
+ // A uniform circular corner stays an rrect-tagged path: the renderer
207
+ // keeps its analytic clip and blur fast paths for it, which the cubic
208
+ // outline below, though the same shape, would lose.
209
+ path.addRoundRect(
210
+ left,
211
+ top,
212
+ right,
213
+ bottom,
214
+ topLeftRadius,
215
+ topLeftRadius,
216
+ Path.Direction.CW,
217
+ )
218
+ return
219
+ }
220
+
221
+ val coefficient = 0.5522848f
222
+
147
223
  path.moveTo(left + topLeftRadius, top)
148
224
  path.lineTo(right - topRightRadius, top)
149
225
  path.cubicTo(
@@ -184,6 +260,129 @@ internal fun appendRoundedRectPath(
184
260
  path.close()
185
261
  }
186
262
 
263
+ /**
264
+ * Edge length one corner's shoulder may use. Each side is shared with the
265
+ * neighbouring corner in proportion to the radii, so two shoulders never
266
+ * overlap. Uniform radii give min(width, height) / 2, Figma's own budget.
267
+ */
268
+ private fun continuousBudget(
269
+ radius: Float,
270
+ incomingNeighbourRadius: Float,
271
+ incomingSide: Float,
272
+ outgoingNeighbourRadius: Float,
273
+ outgoingSide: Float,
274
+ ): Float {
275
+ val incoming = if (incomingNeighbourRadius > 0f) {
276
+ incomingSide * radius / (radius + incomingNeighbourRadius)
277
+ } else {
278
+ incomingSide
279
+ }
280
+ val outgoing = if (outgoingNeighbourRadius > 0f) {
281
+ outgoingSide * radius / (radius + outgoingNeighbourRadius)
282
+ } else {
283
+ outgoingSide
284
+ }
285
+ return min(incoming, outgoing)
286
+ }
287
+
288
+ /**
289
+ * Appends one continuous corner, travelling clockwise. (ux, uy) is the unit
290
+ * direction of the edge running into the corner point and (vx, vy) of the edge
291
+ * leaving it, so one body serves all four orientations.
292
+ */
293
+ private fun appendContinuousCorner(
294
+ path: Path,
295
+ isFirst: Boolean,
296
+ cornerX: Float,
297
+ cornerY: Float,
298
+ ux: Float,
299
+ uy: Float,
300
+ vx: Float,
301
+ vy: Float,
302
+ radius: Float,
303
+ budget: Float,
304
+ ) {
305
+ if (radius <= 0f) {
306
+ // A plain vertex: an all-square clip stays a rectangle the renderer
307
+ // can recognise, instead of a path of degenerate curves.
308
+ if (isFirst) path.moveTo(cornerX, cornerY) else path.lineTo(cornerX, cornerY)
309
+ return
310
+ }
311
+
312
+ val shoulder: Float
313
+ val a: Float
314
+ val b: Float
315
+ val c: Float
316
+ val d: Float
317
+ val arcChord: Float
318
+ val arcHandle: Float
319
+ val cosBeta: Float
320
+ val sinBeta: Float
321
+ if ((1f + CONTINUOUS_SMOOTHING) * radius <= budget) {
322
+ shoulder = (1f + CONTINUOUS_SMOOTHING) * radius
323
+ a = CONTINUOUS_A * radius
324
+ b = CONTINUOUS_B * radius
325
+ c = CONTINUOUS_C * radius
326
+ d = CONTINUOUS_D * radius
327
+ arcChord = CONTINUOUS_ARC_CHORD * radius
328
+ arcHandle = CONTINUOUS_ARC_HANDLE * radius
329
+ cosBeta = CONTINUOUS_COS_BETA
330
+ sinBeta = CONTINUOUS_SIN_BETA
331
+ } else {
332
+ // The shoulder does not fit (near-pill shapes). Figma reduces the
333
+ // smoothing until it does; at zero this is an exact circular corner.
334
+ shoulder = max(budget, radius)
335
+ val smoothing = (shoulder / radius - 1f)
336
+ .coerceIn(0f, CONTINUOUS_SMOOTHING)
337
+ val beta = QUARTER_PI * smoothing
338
+ val halfArc = QUARTER_PI * (1f - smoothing)
339
+ val tanHalfBeta = tan(beta / 2f)
340
+ cosBeta = cos(beta)
341
+ sinBeta = sin(beta)
342
+ c = radius * tanHalfBeta * cosBeta
343
+ d = radius * tanHalfBeta * sinBeta
344
+ arcChord = radius * SQRT_2 * sin(halfArc)
345
+ arcHandle = radius * (4f / 3f) * tan(halfArc / 2f)
346
+ b = (shoulder - arcChord - c - d) / 3f
347
+ a = 2f * b
348
+ }
349
+
350
+ val startX = cornerX - ux * shoulder
351
+ val startY = cornerY - uy * shoulder
352
+ if (isFirst) path.moveTo(startX, startY) else path.lineTo(startX, startY)
353
+
354
+ val arcStartX = startX + ux * (a + b + c) + vx * d
355
+ val arcStartY = startY + uy * (a + b + c) + vy * d
356
+ path.cubicTo(
357
+ startX + ux * a,
358
+ startY + uy * a,
359
+ startX + ux * (a + b),
360
+ startY + uy * (a + b),
361
+ arcStartX,
362
+ arcStartY,
363
+ )
364
+
365
+ val arcEndX = arcStartX + (ux + vx) * arcChord
366
+ val arcEndY = arcStartY + (uy + vy) * arcChord
367
+ path.cubicTo(
368
+ arcStartX + (ux * cosBeta + vx * sinBeta) * arcHandle,
369
+ arcStartY + (uy * cosBeta + vy * sinBeta) * arcHandle,
370
+ arcEndX - (ux * sinBeta + vx * cosBeta) * arcHandle,
371
+ arcEndY - (uy * sinBeta + vy * cosBeta) * arcHandle,
372
+ arcEndX,
373
+ arcEndY,
374
+ )
375
+
376
+ path.cubicTo(
377
+ arcEndX + ux * d + vx * c,
378
+ arcEndY + uy * d + vy * c,
379
+ arcEndX + ux * d + vx * (b + c),
380
+ arcEndY + uy * d + vy * (b + c),
381
+ cornerX + vx * shoulder,
382
+ cornerY + vy * shoulder,
383
+ )
384
+ }
385
+
187
386
  /**
188
387
  * Hit-tests the actual rendered path. Path has no float `contains` API, so we
189
388
  * flatten that same instance to sub-pixel line segments for ACTION_DOWN. This