react-native-smooth-clip-view 0.4.6 → 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 (64) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +47 -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 +18 -0
  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/SmoothClipRegistry.mm +192 -9
  18. package/ios/SmoothClipTurboModule.cpp +33 -11
  19. package/ios/SmoothClipView.mm +713 -22
  20. package/ios/SmoothClipViewRegistry.h +14 -0
  21. package/lib/module/SmoothClipBackdropView.js +40 -0
  22. package/lib/module/SmoothClipBackdropView.js.map +1 -0
  23. package/lib/module/SmoothClipBackdropViewNativeComponent.ts +15 -0
  24. package/lib/module/SmoothClipView.ios.js +17 -1
  25. package/lib/module/SmoothClipView.ios.js.map +1 -1
  26. package/lib/module/SmoothClipViewNativeComponent.ts +4 -0
  27. package/lib/module/geometry.js +44 -6
  28. package/lib/module/geometry.js.map +1 -1
  29. package/lib/module/groups.native.js +10 -3
  30. package/lib/module/groups.native.js.map +1 -1
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/presentationCodec.js +6 -2
  34. package/lib/module/presentationCodec.js.map +1 -1
  35. package/lib/typescript/src/SmoothClipBackdropView.d.ts +155 -0
  36. package/lib/typescript/src/SmoothClipBackdropView.d.ts.map +1 -0
  37. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts +8 -0
  38. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts.map +1 -0
  39. package/lib/typescript/src/SmoothClipView.android.d.ts +1 -1
  40. package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -1
  41. package/lib/typescript/src/SmoothClipView.d.ts +1 -1
  42. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  43. package/lib/typescript/src/SmoothClipView.ios.d.ts +15 -1
  44. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  45. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
  46. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  47. package/lib/typescript/src/geometry.d.ts +19 -0
  48. package/lib/typescript/src/geometry.d.ts.map +1 -1
  49. package/lib/typescript/src/groups.native.d.ts.map +1 -1
  50. package/lib/typescript/src/index.d.ts +4 -2
  51. package/lib/typescript/src/index.d.ts.map +1 -1
  52. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  53. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  54. package/package.json +4 -1
  55. package/src/SmoothClipBackdropView.tsx +53 -0
  56. package/src/SmoothClipBackdropViewNativeComponent.ts +15 -0
  57. package/src/SmoothClipView.android.tsx +4 -1
  58. package/src/SmoothClipView.ios.tsx +24 -1
  59. package/src/SmoothClipView.tsx +4 -1
  60. package/src/SmoothClipViewNativeComponent.ts +4 -0
  61. package/src/geometry.ts +59 -4
  62. package/src/groups.native.ts +9 -3
  63. package/src/index.ts +7 -1
  64. package/src/presentationCodec.ts +8 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
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
+
3
58
  ## [0.4.6](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.6) — 2026-09-20
4
59
 
5
60
  - Draw `continuous` corners on Android as a Figma smoothed corner (smoothing
package/README.md CHANGED
@@ -224,6 +224,37 @@ APIs share validation, run ownership, and completion behavior.
224
224
  curve change are static-only: set them with `setFrame`; `animateTo` returns
225
225
  `null`.
226
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.
227
258
  - A fully off-host aperture is not touchable, even when only its shadow overlaps.
228
259
  - Descendant accessibility is hidden during autonomous native motion and restored
229
260
  from aperture/host intersection at the endpoint.
@@ -281,6 +312,22 @@ See the [changelog](./CHANGELOG.md) for release details.
281
312
 
282
313
  - One worklet-to-native call per `ui.setFrame`, or per group batch.
283
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.
284
331
  - The shadow-disabled rendering path keeps no shadow drawing resources.
285
332
  - The fixed host is the maximum rendering viewport; consumers should size it to
286
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
  }
@@ -200,6 +200,24 @@ internal fun appendRoundedRectPath(
200
200
  return
201
201
  }
202
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
+
203
221
  val coefficient = 0.5522848f
204
222
 
205
223
  path.moveTo(left + topLeftRadius, top)
@@ -0,0 +1,59 @@
1
+ package com.smoothclipview
2
+
3
+ import android.content.res.Configuration
4
+ import com.facebook.proguard.annotations.DoNotStrip
5
+ import com.facebook.react.uimanager.PixelUtil
6
+ import com.facebook.react.uimanager.ThemedReactContext
7
+ import com.facebook.react.views.view.ReactViewGroup
8
+
9
+ /**
10
+ * A view translated by its controller's backdrop channel. The registry
11
+ * delivers the channel in pixels on every clip delivery — a `setFrame`, a
12
+ * run's frame, a run's end — inside the same Choreographer pass as the clip,
13
+ * so the content never lands a frame after the aperture. The translation goes
14
+ * on an inner container, leaving this view's own transform to React Native.
15
+ */
16
+ class SmoothClipBackdropView(context: ThemedReactContext) : ReactViewGroup(context) {
17
+ internal val contentContainer = ReactViewGroup(context)
18
+
19
+ /** Driver this view is bound to in the native registry (0 = none). */
20
+ internal var boundDriverId: Double = 0.0
21
+
22
+ init {
23
+ clipChildren = false
24
+ super.addView(contentContainer)
25
+ }
26
+
27
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
28
+ super.onSizeChanged(w, h, oldw, oldh)
29
+ contentContainer.layout(0, 0, w, h)
30
+ }
31
+
32
+ /** Registry hot path: the backdrop translation, in pixels. */
33
+ @DoNotStrip
34
+ fun setBackdropTranslationPx(translateX: Float, translateY: Float) {
35
+ contentContainer.translationX = translateX
36
+ contentContainer.translationY = translateY
37
+ }
38
+
39
+ internal fun bindDriver(driverId: Double) {
40
+ if (boundDriverId != 0.0 && boundDriverId != driverId) {
41
+ SmoothClipBindings.nativeUnregisterBackdropView(boundDriverId, this)
42
+ }
43
+ boundDriverId = driverId
44
+ if (driverId != 0.0) {
45
+ SmoothClipBindings.nativeRegisterBackdropView(driverId, this, densityScale())
46
+ }
47
+ }
48
+
49
+ override fun onConfigurationChanged(newConfig: Configuration) {
50
+ super.onConfigurationChanged(newConfig)
51
+ // Density can change without a remount (display switch); the registry
52
+ // scales the channel by the pushed density.
53
+ if (boundDriverId != 0.0) {
54
+ SmoothClipBindings.nativeRegisterBackdropView(boundDriverId, this, densityScale())
55
+ }
56
+ }
57
+
58
+ private fun densityScale(): Double = PixelUtil.toPixelFromDIP(1f).toDouble()
59
+ }
@@ -0,0 +1,63 @@
1
+ package com.smoothclipview
2
+
3
+ import android.view.View
4
+ import com.facebook.react.module.annotations.ReactModule
5
+ import com.facebook.react.uimanager.ThemedReactContext
6
+ import com.facebook.react.uimanager.ViewGroupManager
7
+ import com.facebook.react.uimanager.ViewManagerDelegate
8
+ import com.facebook.react.uimanager.annotations.ReactProp
9
+ import com.facebook.react.viewmanagers.SmoothClipBackdropViewManagerDelegate
10
+ import com.facebook.react.viewmanagers.SmoothClipBackdropViewManagerInterface
11
+
12
+ @ReactModule(name = SmoothClipBackdropViewManager.NAME)
13
+ class SmoothClipBackdropViewManager : ViewGroupManager<SmoothClipBackdropView>(),
14
+ SmoothClipBackdropViewManagerInterface<SmoothClipBackdropView> {
15
+ private val delegate =
16
+ SmoothClipBackdropViewManagerDelegate<SmoothClipBackdropView, SmoothClipBackdropViewManager>(this)
17
+
18
+ // Fabric delivers the prop before the transaction ends; bind once per
19
+ // transaction, after every prop of it has landed.
20
+ private val pendingDriverIds = java.util.WeakHashMap<SmoothClipBackdropView, Double>()
21
+
22
+ override fun getName(): String = NAME
23
+
24
+ override fun getDelegate(): ViewManagerDelegate<SmoothClipBackdropView> = delegate
25
+
26
+ override fun createViewInstance(context: ThemedReactContext): SmoothClipBackdropView =
27
+ SmoothClipBackdropView(context)
28
+
29
+ @ReactProp(name = "driverId")
30
+ override fun setDriverId(view: SmoothClipBackdropView, value: Double) {
31
+ pendingDriverIds[view] = value
32
+ }
33
+
34
+ override fun addView(parent: SmoothClipBackdropView, child: View, index: Int) {
35
+ parent.contentContainer.addView(child, index)
36
+ }
37
+
38
+ override fun getChildCount(parent: SmoothClipBackdropView): Int =
39
+ parent.contentContainer.childCount
40
+
41
+ override fun getChildAt(parent: SmoothClipBackdropView, index: Int): View =
42
+ parent.contentContainer.getChildAt(index)
43
+
44
+ override fun removeViewAt(parent: SmoothClipBackdropView, index: Int) {
45
+ parent.contentContainer.removeViewAt(index)
46
+ }
47
+
48
+ override fun onAfterUpdateTransaction(view: SmoothClipBackdropView) {
49
+ super.onAfterUpdateTransaction(view)
50
+ val driverId = pendingDriverIds.remove(view) ?: return
51
+ view.bindDriver(driverId)
52
+ }
53
+
54
+ override fun onDropViewInstance(view: SmoothClipBackdropView) {
55
+ pendingDriverIds.remove(view)
56
+ view.bindDriver(0.0)
57
+ super.onDropViewInstance(view)
58
+ }
59
+
60
+ companion object {
61
+ const val NAME = "SmoothClipBackdropView"
62
+ }
63
+ }
@@ -45,12 +45,27 @@ internal object SmoothClipBindings {
45
45
  shadowSpreadDistance: Double,
46
46
  rotation: Double,
47
47
  opacity: Double,
48
+ backdropTranslateX: Double,
49
+ backdropTranslateY: Double,
48
50
  density: Double,
49
51
  widthPx: Double,
50
52
  heightPx: Double,
51
53
  lifecycleVisible: Boolean,
52
54
  )
53
55
 
56
+ /**
57
+ * Binds a backdrop view to a driver: the registry translates it by the
58
+ * presentation's backdrop channel on every delivery. Re-registering an
59
+ * already bound view only updates its density.
60
+ */
61
+ external fun nativeRegisterBackdropView(
62
+ driverId: Double,
63
+ view: SmoothClipBackdropView,
64
+ density: Double,
65
+ )
66
+
67
+ external fun nativeUnregisterBackdropView(driverId: Double, view: SmoothClipBackdropView)
68
+
54
69
  /**
55
70
  * Pushes a registered view's density and pixel host size. Density converts
56
71
  * raw DIP presentations to pixels; host size controls readiness and the