react-native-smooth-clip-view 0.4.1 → 0.4.3

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 (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +92 -5
  3. package/android/src/main/cpp/SmoothClipAndroid.h +2 -1
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +22 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +85 -10
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +23 -0
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +8 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +39 -1
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +124 -32
  10. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +16 -0
  11. package/cpp/SmoothClipAnimationCurve.h +21 -10
  12. package/cpp/SmoothClipRegistry.h +2 -0
  13. package/cpp/SmoothClipSharedGeometry.h +20 -0
  14. package/cpp/SmoothClipVelocityTracker.h +6 -0
  15. package/ios/SmoothClipRegistry.mm +173 -17
  16. package/ios/SmoothClipTurboModule.cpp +17 -6
  17. package/ios/SmoothClipTurboModule.h +3 -1
  18. package/ios/SmoothClipView.mm +408 -20
  19. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  20. package/lib/module/SmoothClipView.ios.js +3 -0
  21. package/lib/module/SmoothClipView.ios.js.map +1 -1
  22. package/lib/module/SmoothClipViewNativeComponent.ts +5 -1
  23. package/lib/module/geometry.js +22 -2
  24. package/lib/module/geometry.js.map +1 -1
  25. package/lib/module/presentationCodec.js +5 -3
  26. package/lib/module/presentationCodec.js.map +1 -1
  27. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  28. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  29. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  30. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +3 -1
  31. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  32. package/lib/typescript/src/controllerTypes.d.ts +5 -1
  33. package/lib/typescript/src/controllerTypes.d.ts.map +1 -1
  34. package/lib/typescript/src/geometry.d.ts +9 -0
  35. package/lib/typescript/src/geometry.d.ts.map +1 -1
  36. package/lib/typescript/src/index.d.ts +1 -1
  37. package/lib/typescript/src/index.d.ts.map +1 -1
  38. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  39. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  40. package/package.json +2 -1
  41. package/src/NativeSmoothClipModule.ts +3 -1
  42. package/src/SmoothClipView.ios.tsx +3 -0
  43. package/src/SmoothClipViewNativeComponent.ts +5 -1
  44. package/src/controllerTypes.ts +5 -1
  45. package/src/geometry.ts +35 -0
  46. package/src/index.ts +1 -0
  47. package/src/presentationCodec.ts +8 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ ## [0.4.3](https://github.com/kbrattli/react-native-smooth-clip-view/releases/tag/v0.4.3) — 2026-09-11
4
+
5
+ ### Rotate and fade the whole clip
6
+
7
+ Animate rotation and opacity alongside clip geometry on iOS and Android. The
8
+ aperture, its content, and its shadow move and fade together inside the fixed
9
+ host, using the existing controller and atomic group APIs.
10
+
11
+ - **Rotation:** pass degree or radian strings such as `'12deg'` or `'0.2rad'`.
12
+ Rotation is clockwise around the current aperture center, after content
13
+ translation and scale. Angles interpolate numerically, so `'720deg'` animates
14
+ two full turns from zero. Interruption snapshots preserve those turns.
15
+ - **Group opacity:** set `opacity` to fade content and shadow together, including
16
+ overlapping children. Finite values clamp to `[0, 1]`; spring rendering clamps
17
+ opacity while preserving the spring's internal motion state.
18
+ - **Animation continuity:** rotation and opacity participate in timing and
19
+ spring animations, cancellation snapshots, and background spring pause/resume.
20
+ Explicit spring velocity applies to these channels too.
21
+ - **Touch and accessibility:** hit testing follows the rotated aperture, including
22
+ clips rotated back into the viewport. Fully transparent content accepts no new
23
+ touches and is hidden from accessibility.
24
+ - **Rendering:** appearance updates use native compositor properties without
25
+ Yoga layout work or rebuilding unchanged clipping paths. Translucent groups
26
+ may require offscreen compositing.
27
+ - **Types and validation:** export `ClipRotation`, include rotation and opacity in
28
+ canonical presentations, and reject malformed angles or nonfinite values
29
+ atomically. Add JavaScript and native regression coverage for these behaviors.
30
+
31
+ ### Usage and upgrade notes
32
+
33
+ `rotation` defaults to `'0deg'` and `opacity` to `1`. Presentations are complete:
34
+ omitting either field resets it to its default. Snapshots return rotation in
35
+ radians. See [Rotate and fade a clip](./README.md#rotate-and-fade-a-clip) for a
36
+ controller example.
37
+
38
+ **Rebuild your native app after upgrading**, and run `pod install` on iOS. The
39
+ native presentation protocol has changed; JavaScript from 0.4.3 must run with the
40
+ matching native library, rather than an older binary updated only through OTA.
41
+
42
+ [All changes since 0.4.2](https://github.com/kbrattli/react-native-smooth-clip-view/compare/v0.4.2...v0.4.3)
43
+
44
+ Earlier release notes are available on [GitHub Releases](https://github.com/kbrattli/react-native-smooth-clip-view/releases).
package/README.md CHANGED
@@ -107,9 +107,9 @@ UI-runtime runs without retaining a consumer worklet for the animation lifetime.
107
107
  It does not update React state, animated props, Yoga, the ShadowTree, or a public
108
108
  SharedValue.
109
109
 
110
- `beginInteraction()` atomically interrupts native motion and returns its visible
111
- raw presentation. Apply the final gesture sample with `setFrame()` before calling
112
- it when the release must start from that exact frame.
110
+ `beginInteraction()` atomically interrupts native motion and returns
111
+ its visible raw presentation. Apply the final gesture sample with `setFrame()`
112
+ before calling it when the release must start from that exact frame.
113
113
 
114
114
  ## React API
115
115
 
@@ -134,9 +134,37 @@ defaults match Reanimated 4.5: mass `4`, stiffness `900`, damping `120`, and
134
134
  relative energy threshold `6e-9`. A spring is rejected when its resolved native
135
135
  trajectory could make `contentScale` nonpositive.
136
136
 
137
+ `velocity` is a shared initial progress velocity in inverse seconds, defaulting
138
+ to `0`. Each changing channel starts with velocity proportional to its remaining
139
+ distance: `velocity * (target - start)`. This includes rotation (in radians) and
140
+ opacity. Public controllers and groups do not automatically inherit gesture
141
+ velocity; starting a new spring after `beginInteraction()` still defaults to `0`
142
+ unless `velocity` is supplied. Pausing and resuming an existing native spring
143
+ preserves its internal motion state; public snapshots return clamped opacity.
144
+
145
+ The internal native standalone path also supports inherited velocity. Its
146
+ projection uses only clip position, size, corner radii, and content translation.
147
+ Rotation, opacity, content scale, and shadow channels do not contribute to that
148
+ projection. The resulting normalized velocity can drive all changing channels,
149
+ including rotation and opacity, without mixing their units into the projection.
150
+ Rotation/opacity-only motion therefore supplies no inherited velocity.
151
+
137
152
  An animation requested before the host has a positive layout waits, then starts
138
- with its full duration. Host loss after motion starts freezes every member and
139
- resolves the transaction `false`. Replacement, cancellation, destruction, and
153
+ with its full duration. While the application is inactive, native animation
154
+ transactions stay pending and do not report cancellation. Timing animations
155
+ retain wall-clock progress and catch up after activation. Springs stay frozen;
156
+ their first foreground step consumes at most 64 ms, matching Reanimated, and
157
+ then they continue normally. If the platform discarded an underlying animation
158
+ object, the transaction settles successfully at its requested target.
159
+
160
+ Interactive `setFrame()` updates remain application-owned. The library does not
161
+ reset a drag or mutate related SharedValues or React state when lifecycle state
162
+ changes, so applications do not need lifecycle integration for the clip itself.
163
+
164
+ Host loss while the application is active keeps the existing semantics:
165
+ standalone animations apply their target and finish successfully, while groups
166
+ (including public controllers because they use one-member groups) freeze every
167
+ member and resolve `false`. Replacement, cancellation, destruction, and
140
168
  rejection also settle once with `false`.
141
169
 
142
170
  ## Atomic groups
@@ -176,6 +204,15 @@ APIs share validation, run ownership, and completion behavior.
176
204
  - Corner-overlap scaling follows CSS rules against the requested rectangle.
177
205
  - `contentTranslateX`, `contentTranslateY`, and positive `contentScale` animate
178
206
  with the aperture.
207
+ - `rotation` accepts degree or radian strings (for example, `'12deg'` or
208
+ `'0.2rad'`) and defaults to `'0deg'`. It rotates the aperture, content, and
209
+ shadow together, clockwise around the current aperture center. Existing
210
+ content translation and scale happen before this rotation. The host stays
211
+ fixed and crops the rotated result.
212
+ - `opacity` defaults to `1` and fades the aperture's content and shadow as one
213
+ group. Finite values clamp to `[0, 1]`; malformed angles and nonfinite values
214
+ reject the complete presentation. Spring opacity is clamped for rendering
215
+ without clamping its internal spring state.
179
216
  - Circular and continuous curves and independent corner radii are supported.
180
217
  - One outset `boxShadow` is supported. It escapes the aperture but not the host.
181
218
  - A fully off-host aperture is not touchable, even when only its shadow overlaps.
@@ -185,6 +222,52 @@ APIs share validation, run ownership, and completion behavior.
185
222
  Use `getSmoothClipCapabilities()` when a consumer needs to decide whether a
186
223
  complex native path can be promoted on the current platform.
187
224
 
225
+ ### Rotate and fade a clip
226
+
227
+ Rotation and opacity are presentation fields, so they use the same controller
228
+ and group methods as geometry. For example, start a tilted, translucent card
229
+ and animate it upright:
230
+
231
+ ```ts
232
+ const clip = useSmoothClipController({
233
+ clip: { x: 24, y: 80, width: 240, height: 320, radius: 24 },
234
+ contentTranslateX: 0,
235
+ contentTranslateY: 0,
236
+ rotation: '-12deg',
237
+ opacity: 0.4,
238
+ });
239
+
240
+ // Attach this controller to <SmoothClipView controller={clip}> as above.
241
+ const reveal = () =>
242
+ clip.react.animateTo(
243
+ {
244
+ clip: { x: 24, y: 80, width: 240, height: 320, radius: 24 },
245
+ contentTranslateX: 0,
246
+ contentTranslateY: 0,
247
+ rotation: '0deg',
248
+ opacity: 1,
249
+ },
250
+ { type: 'spring', stiffness: 180, damping: 22 }
251
+ );
252
+ ```
253
+
254
+ For gesture-driven updates, supply the same fields from a UI-runtime worklet:
255
+
256
+ ```ts
257
+ clip.ui.setFrame({ ...frame, rotation: '12deg', opacity: 0.5 });
258
+ ```
259
+
260
+ Angles interpolate numerically, so `'720deg'` requests two full turns from zero;
261
+ there is no automatic shortest-path wrapping. Interruption snapshots preserve
262
+ full turns and return rotation as a radian string. Each presentation is complete:
263
+ omitting rotation or opacity resets that field to its default. At zero opacity,
264
+ the content admits no new touches and is hidden from accessibility.
265
+
266
+ When upgrading to **0.4.3**, rebuild the native app (and run `pod install` on
267
+ iOS). The native presentation protocol includes new rotation and opacity
268
+ channels, so a JavaScript-only OTA update cannot upgrade an older native build.
269
+ See the [changelog](./CHANGELOG.md) for release details.
270
+
188
271
  ## Performance contract
189
272
 
190
273
  - One worklet-to-native call per `ui.setFrame`, or per group batch.
@@ -192,6 +275,10 @@ complex native path can be promoted on the current platform.
192
275
  - The shadow-disabled rendering path keeps no shadow drawing resources.
193
276
  - The fixed host is the maximum rendering viewport; consumers should size it to
194
277
  the region in which content and shadow may appear.
278
+ - Rotation and opacity update native compositor properties without Yoga work
279
+ or rebuilding unchanged clipping paths. Correctly fading overlapping children
280
+ may require offscreen compositing; large translucent groups can cost more GPU
281
+ time than opaque ones. No rasterization or software layer is forced.
195
282
 
196
283
  ## License
197
284
 
@@ -22,7 +22,7 @@ struct JSmoothClipView : facebook::jni::JavaClass<JSmoothClipView> {
22
22
  double contentTranslateXPx,
23
23
  double contentTranslateYPx,
24
24
  double contentScale,
25
- const Shadow &shadowPx) const;
25
+ const Shadow &shadowPx, double rotation, double opacity) const;
26
26
  void setAutonomousMotion(bool active) const;
27
27
  };
28
28
 
@@ -54,6 +54,7 @@ void setViewLifecycleVisibilityAndroid(
54
54
  uint64_t driverId,
55
55
  facebook::jni::alias_ref<JSmoothClipView> view,
56
56
  bool lifecycleVisible);
57
+ void setApplicationActiveAndroid(bool active);
57
58
 
58
59
  // Advances the registry frame loop. Called from Kotlin (SmoothClipBindings)
59
60
  // inside Choreographer#doFrame with the frame's vsync timestamp converted to
@@ -27,7 +27,7 @@ namespace smoothclip {
27
27
  namespace {
28
28
 
29
29
  constexpr double kMaxSafeJavaScriptInteger = 9007199254740991.0;
30
- constexpr size_t kPresentationStride = 21;
30
+ constexpr size_t kPresentationStride = 23;
31
31
  constexpr size_t kSnapshotStride = kPresentationStride + 1;
32
32
  constexpr size_t kMotionEntryStride = kPresentationStride * 2 + 2;
33
33
 
@@ -61,7 +61,8 @@ bool finiteBasePresentation(const Presentation &presentation) {
61
61
  std::isfinite(presentation.contentTranslateX) &&
62
62
  std::isfinite(presentation.contentTranslateY) &&
63
63
  std::isfinite(presentation.contentScale) &&
64
- presentation.contentScale > 0;
64
+ presentation.contentScale > 0 &&
65
+ std::isfinite(presentation.rotation) && std::isfinite(presentation.opacity);
65
66
  }
66
67
 
67
68
  bool finitePresentation(const Presentation &presentation) {
@@ -154,7 +155,7 @@ bool presentationAt(
154
155
  Shadow{
155
156
  packet[12] == 1.0,
156
157
  packet[13], packet[14], packet[15], packet[16],
157
- packet[17], packet[18], packet[19], packet[20]}};
158
+ packet[17], packet[18], packet[19], packet[20]}, packet[21], packet[22]};
158
159
  return finitePresentation(result);
159
160
  }
160
161
 
@@ -190,6 +191,8 @@ void appendPresentation(
190
191
  result.setValueAtIndex(runtime, offset++, presentation.shadow.offsetY);
191
192
  result.setValueAtIndex(runtime, offset++, presentation.shadow.blurRadius);
192
193
  result.setValueAtIndex(runtime, offset++, presentation.shadow.spreadDistance);
194
+ result.setValueAtIndex(runtime, offset++, presentation.rotation);
195
+ result.setValueAtIndex(runtime, offset++, presentation.opacity);
193
196
  }
194
197
 
195
198
  Array presentationArray(Runtime &runtime, const Presentation &presentation) {
@@ -551,9 +554,9 @@ void installBindings(
551
554
  });
552
555
 
553
556
  setHostFunction(
554
- runtime, bindings, "setClipFrameScalars", 22,
557
+ runtime, bindings, "setClipFrameScalars", 24,
555
558
  [](Runtime &, const Value &, const Value *args, size_t count) -> Value {
556
- if (count < 22) return Value::undefined();
559
+ if (count < 24) return Value::undefined();
557
560
  const double driverId = args[0].asNumber();
558
561
  Geometry geometry{
559
562
  args[1].asNumber(), args[2].asNumber(), args[3].asNumber(),
@@ -584,7 +587,7 @@ void installBindings(
584
587
  args[19].asNumber(),
585
588
  args[20].asNumber(),
586
589
  args[21].asNumber(),
587
- }};
590
+ }, args[22].asNumber(), args[23].asNumber()};
588
591
  if (validDriverId(driverId) && validCurve(args[9].asNumber()) &&
589
592
  finitePresentation(presentation)) {
590
593
  setPresentation(
@@ -828,6 +831,8 @@ void nativeRegisterView(
828
831
  jdouble shadowOffsetY,
829
832
  jdouble shadowBlurRadius,
830
833
  jdouble shadowSpreadDistance,
834
+ jdouble rotation,
835
+ jdouble opacity,
831
836
  jdouble density,
832
837
  jdouble widthPx,
833
838
  jdouble heightPx,
@@ -842,7 +847,8 @@ void nativeRegisterView(
842
847
  !std::isfinite(shadowBlue) || !std::isfinite(shadowAlpha) ||
843
848
  !std::isfinite(shadowOffsetX) ||
844
849
  !std::isfinite(shadowOffsetY) || !std::isfinite(shadowBlurRadius) ||
845
- !std::isfinite(shadowSpreadDistance) || contentScale <= 0 ||
850
+ !std::isfinite(shadowSpreadDistance) ||
851
+ !std::isfinite(rotation) || !std::isfinite(opacity) || contentScale <= 0 ||
846
852
  shadowRed < 0 || shadowRed > 1 || shadowGreen < 0 || shadowGreen > 1 ||
847
853
  shadowBlue < 0 || shadowBlue > 1 || shadowAlpha < 0 || shadowAlpha > 1 ||
848
854
  shadowBlurRadius < 0 ||
@@ -874,7 +880,7 @@ void nativeRegisterView(
874
880
  static_cast<uint64_t>(driverId),
875
881
  view,
876
882
  smoothclip::Presentation{
877
- geometry, contentTranslateX, contentTranslateY, contentScale, shadow},
883
+ geometry, contentTranslateX, contentTranslateY, contentScale, shadow, rotation, opacity},
878
884
  density,
879
885
  widthPx,
880
886
  heightPx,
@@ -918,6 +924,12 @@ void nativeSetViewLifecycleVisibility(
918
924
  static_cast<uint64_t>(driverId), view, lifecycleVisible != 0);
919
925
  }
920
926
 
927
+ void nativeSetApplicationActive(
928
+ jni::alias_ref<jni::JObject>,
929
+ jboolean active) {
930
+ smoothclip::setApplicationActiveAndroid(active != 0);
931
+ }
932
+
921
933
  void nativeInvalidate(jni::alias_ref<jni::JObject>) {
922
934
  smoothclip::invalidateBindings();
923
935
  }
@@ -942,6 +954,8 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
942
954
  makeNativeMethod(
943
955
  "nativeSetViewLifecycleVisibility",
944
956
  nativeSetViewLifecycleVisibility),
957
+ makeNativeMethod(
958
+ "nativeSetApplicationActive", nativeSetApplicationActive),
945
959
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
946
960
  makeNativeMethod("nativeOnFrame", nativeOnFrame),
947
961
  });
@@ -166,6 +166,16 @@ std::vector<int32_t> &animatingGroups() {
166
166
  return value;
167
167
  }
168
168
 
169
+ bool &applicationActiveState() {
170
+ static bool value = true;
171
+ return value;
172
+ }
173
+
174
+ std::optional<double> &applicationInactiveSinceS() {
175
+ static std::optional<double> value;
176
+ return value;
177
+ }
178
+
169
179
  // The sink is written from the JS thread (installBindings / invalidate) and
170
180
  // invoked on main. Invoking under the lock also keeps the owning bindings
171
181
  // state alive for the duration of the callback.
@@ -448,7 +458,7 @@ void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
448
458
  translateX,
449
459
  translateY,
450
460
  presentation.contentScale,
451
- shadowPx);
461
+ shadowPx, presentation.rotation, presentation.opacity);
452
462
  }
453
463
 
454
464
  void applyToViews(DriverState &state, const Presentation &presentation) {
@@ -639,6 +649,7 @@ void startPendingGroup(ActiveGroup &group) {
639
649
  }
640
650
 
641
651
  void reconcileGroupReadiness(uint64_t driverId) {
652
+ if (!applicationActiveState()) return;
642
653
  const auto stateIterator = registry().find(driverId);
643
654
  if (stateIterator == registry().end() || stateIterator->second.groupId == 0) {
644
655
  return;
@@ -704,7 +715,8 @@ int32_t startAnimation(
704
715
  // visible all read animation->current, giving a never-rendered pending-run
705
716
  // freeze-at-start / replace-from-start semantics with no extra branches.
706
717
  animation.current = animation.start;
707
- animation.started = anyDisplayableView(state);
718
+ animation.started =
719
+ applicationActiveState() && anyDisplayableView(state);
708
720
  state.animation = std::move(animation);
709
721
  for (ViewEntry &entry : state.views) {
710
722
  entry.participation = state.animation->started && entryDisplayable(entry)
@@ -730,7 +742,8 @@ int32_t startAnimation(
730
742
  }
731
743
 
732
744
  void finishIfHostUnavailable(uint64_t driverId, DriverState &state) {
733
- if (!state.animation.has_value() || !state.animation->started ||
745
+ if (!applicationActiveState() || !state.animation.has_value() ||
746
+ !state.animation->started ||
734
747
  anyDisplayableView(state)) {
735
748
  return;
736
749
  }
@@ -746,7 +759,7 @@ void reconcileViewDisplayability(
746
759
  uint64_t driverId,
747
760
  DriverState &state,
748
761
  ViewEntry &entry) {
749
- if (!state.animation.has_value()) return;
762
+ if (!applicationActiveState() || !state.animation.has_value()) return;
750
763
  if (!state.animation->started) {
751
764
  if (entryDisplayable(entry)) startPendingAnimation(driverId, state);
752
765
  return;
@@ -892,6 +905,7 @@ void advanceGroup(int32_t groupId, ActiveGroup &group, double now) {
892
905
  void onFrameImpl(double now) {
893
906
  SMOOTH_CLIP_TRACE("SmoothClip.frame");
894
907
  gFrameScheduled = false;
908
+ if (!applicationActiveState()) return;
895
909
  // Snapshot: advance() clears live-list entries on completion. advance()
896
910
  // never erases registry entries and never starts animations (completion
897
911
  // delivery hops through CallInvoker::invokeAsync), so the find() guard
@@ -939,6 +953,66 @@ void onFrameAndroid(double frameTimeS) {
939
953
  }
940
954
  }
941
955
 
956
+ void setApplicationActiveAndroid(bool active) {
957
+ if (!isOnMainThread() || applicationActiveState() == active) return;
958
+ if (!active) {
959
+ applicationActiveState() = false;
960
+ applicationInactiveSinceS() = nowSeconds();
961
+ gFrameScheduled = false;
962
+ return;
963
+ }
964
+
965
+ const double now = nowSeconds();
966
+ const double inactiveDurationS = applicationInactiveSinceS().has_value()
967
+ ? std::max(0.0, now - *applicationInactiveSinceS())
968
+ : 0.0;
969
+ applicationInactiveSinceS().reset();
970
+ applicationActiveState() = true;
971
+
972
+ // Reanimated's spring frame delta includes the inactive interval and is
973
+ // clamped to 64 ms, but its termination is energy-based rather than a wall
974
+ // clock timeout. Keep lastFrameS unchanged so the first resumed step sees
975
+ // that clamped delta, while moving only the safety-cap origin forward so an
976
+ // ordinary background interval cannot terminate the spring by itself.
977
+ if (inactiveDurationS > 0) {
978
+ for (auto &[driverId, state] : registry()) {
979
+ (void)driverId;
980
+ if (state.groupId == 0 && state.animation.has_value() &&
981
+ state.animation->started &&
982
+ state.animation->kind == AnimationKind::Spring) {
983
+ state.animation->startedAtS = rebaseSpringStartAfterInactivity(
984
+ state.animation->startedAtS, inactiveDurationS);
985
+ }
986
+ }
987
+ for (auto &[groupId, group] : groupRegistry()) {
988
+ (void)groupId;
989
+ if (!group.started || group.kind != AnimationKind::Spring) continue;
990
+ group.startedAtS = rebaseSpringStartAfterInactivity(
991
+ group.startedAtS, inactiveDurationS);
992
+ for (GroupMemberAnimation &member : group.members) {
993
+ member.animation.startedAtS = rebaseSpringStartAfterInactivity(
994
+ member.animation.startedAtS, inactiveDurationS);
995
+ }
996
+ }
997
+ }
998
+
999
+ for (auto &[driverId, state] : registry()) {
1000
+ if (state.groupId == 0 && state.animation.has_value() &&
1001
+ !state.animation->started && anyDisplayableView(state)) {
1002
+ startPendingAnimation(driverId, state);
1003
+ }
1004
+ }
1005
+ for (auto &[groupId, group] : groupRegistry()) {
1006
+ (void)groupId;
1007
+ if (!group.started && allGroupMembersReady(group)) {
1008
+ startPendingGroup(group);
1009
+ }
1010
+ }
1011
+ if (!animatingDrivers().empty() || !animatingGroups().empty()) {
1012
+ scheduleFrame();
1013
+ }
1014
+ }
1015
+
942
1016
  // --- Public smoothclip:: interface (shared with iOS via SmoothClipRegistry.h)
943
1017
 
944
1018
  void setCompletionCallback(const void *owner, CompletionCallback callback) {
@@ -1241,7 +1315,8 @@ int32_t startGroupCommon(
1241
1315
  member.animation.lastFrameS = stamp.startedAtS;
1242
1316
  member.animation.frameClockAnchored = stamp.frameClockAnchored;
1243
1317
  }
1244
- group.started = allGroupMembersReady(group);
1318
+ group.started =
1319
+ applicationActiveState() && allGroupMembersReady(group);
1245
1320
  groupRegistry().emplace(groupId, std::move(group));
1246
1321
  if (reduce) {
1247
1322
  finishGroupImpl(groupId, true, true);
@@ -1630,7 +1705,7 @@ void JSmoothClipView::applyClip(const Presentation &presentation) const {
1630
1705
  jdouble,
1631
1706
  jdouble,
1632
1707
  jdouble,
1633
- jdouble)>("setClipPresentationDip");
1708
+ jdouble, jdouble, jdouble)>("setClipPresentationDip");
1634
1709
  method(
1635
1710
  self(),
1636
1711
  presentation.clip.x,
@@ -1657,7 +1732,7 @@ void JSmoothClipView::applyClip(const Presentation &presentation) const {
1657
1732
  presentation.shadow.offsetX,
1658
1733
  presentation.shadow.offsetY,
1659
1734
  presentation.shadow.blurRadius,
1660
- presentation.shadow.spreadDistance);
1735
+ presentation.shadow.spreadDistance, presentation.rotation, presentation.opacity);
1661
1736
  }
1662
1737
 
1663
1738
  void JSmoothClipView::applyClipPx(
@@ -1665,7 +1740,7 @@ void JSmoothClipView::applyClipPx(
1665
1740
  double contentTranslateXPx,
1666
1741
  double contentTranslateYPx,
1667
1742
  double contentScale,
1668
- const Shadow &shadowPx) const {
1743
+ const Shadow &shadowPx, double rotation, double opacity) const {
1669
1744
  static const auto method =
1670
1745
  javaClassStatic()
1671
1746
  ->getMethod<void(
@@ -1689,7 +1764,7 @@ void JSmoothClipView::applyClipPx(
1689
1764
  jfloat,
1690
1765
  jfloat,
1691
1766
  jfloat,
1692
- jfloat)>("setClipPresentationPx");
1767
+ jfloat, jdouble, jfloat)>("setClipPresentationPx");
1693
1768
  method(
1694
1769
  self(),
1695
1770
  static_cast<jfloat>(clip.left),
@@ -1712,7 +1787,7 @@ void JSmoothClipView::applyClipPx(
1712
1787
  static_cast<jfloat>(shadowPx.offsetX),
1713
1788
  static_cast<jfloat>(shadowPx.offsetY),
1714
1789
  static_cast<jfloat>(shadowPx.blurRadius),
1715
- static_cast<jfloat>(shadowPx.spreadDistance));
1790
+ static_cast<jfloat>(shadowPx.spreadDistance), rotation, static_cast<jfloat>(opacity));
1716
1791
  }
1717
1792
 
1718
1793
  void JSmoothClipView::setAutonomousMotion(bool active) const {
@@ -240,3 +240,26 @@ internal fun clipAccessibility(
240
240
  } else {
241
241
  requestedAccessibility
242
242
  }
243
+
244
+ /** Conservative for rounded corners, exact for the rotated bounding rectangle. */
245
+ internal fun rotatedRectIntersectsHost(
246
+ left: Double, top: Double, right: Double, bottom: Double,
247
+ pivotX: Double, pivotY: Double, rotation: Double, hostWidth: Double, hostHeight: Double,
248
+ ): Boolean {
249
+ if (right <= left || bottom <= top || hostWidth <= 0 || hostHeight <= 0) return false
250
+ if (rotation == 0.0) return right > 0 && bottom > 0 && left < hostWidth && top < hostHeight
251
+ val c = kotlin.math.cos(rotation)
252
+ val s = kotlin.math.sin(rotation)
253
+ val dx = (left + right) / 2 - pivotX
254
+ val dy = (top + bottom) / 2 - pivotY
255
+ val x = pivotX + c * dx - s * dy - hostWidth / 2
256
+ val y = pivotY + s * dx + c * dy - hostHeight / 2
257
+ val hw = (right - left) / 2
258
+ val hh = (bottom - top) / 2
259
+ val ac = kotlin.math.abs(c)
260
+ val ass = kotlin.math.abs(s)
261
+ return kotlin.math.abs(x) < hostWidth / 2 + ac * hw + ass * hh &&
262
+ kotlin.math.abs(y) < hostHeight / 2 + ass * hw + ac * hh &&
263
+ kotlin.math.abs(c * x + s * y) < hw + ac * hostWidth / 2 + ass * hostHeight / 2 &&
264
+ kotlin.math.abs(-s * x + c * y) < hh + ass * hostWidth / 2 + ac * hostHeight / 2
265
+ }
@@ -43,6 +43,8 @@ internal object SmoothClipBindings {
43
43
  shadowOffsetY: Double,
44
44
  shadowBlurRadius: Double,
45
45
  shadowSpreadDistance: Double,
46
+ rotation: Double,
47
+ opacity: Double,
46
48
  density: Double,
47
49
  widthPx: Double,
48
50
  heightPx: Double,
@@ -77,6 +79,8 @@ internal object SmoothClipBindings {
77
79
  lifecycleVisible: Boolean,
78
80
  )
79
81
 
82
+ external fun nativeSetApplicationActive(active: Boolean)
83
+
80
84
  /**
81
85
  * Releases runtime-bound listener state during host teardown; must run
82
86
  * while the JS runtime is still alive (module invalidate).
@@ -99,6 +103,10 @@ internal object SmoothClipBindings {
99
103
  Choreographer.getInstance().postFrameCallback(frameCallback)
100
104
  }
101
105
 
106
+ fun pauseFrameLoop() {
107
+ Choreographer.getInstance().removeFrameCallback(frameCallback)
108
+ }
109
+
102
110
  /** Advances the frame loop with the doFrame vsync timestamp. */
103
111
  external fun nativeOnFrame(frameTimeNanos: Long)
104
112
  }
@@ -1,10 +1,12 @@
1
1
  package com.smoothclipview
2
2
 
3
3
  import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.LifecycleEventListener
4
5
  import com.facebook.react.bridge.ReactApplicationContext
5
6
  import com.facebook.react.bridge.ReadableArray
6
7
  import com.facebook.react.bridge.UiThreadUtil
7
8
  import com.facebook.react.bridge.WritableArray
9
+ import com.facebook.react.common.LifecycleState
8
10
  import com.facebook.react.module.annotations.ReactModule
9
11
  import com.facebook.react.turbomodule.core.interfaces.BindingsInstallerHolder
10
12
  import com.facebook.react.turbomodule.core.interfaces.TurboModuleWithJSIBindings
@@ -17,12 +19,46 @@ import com.facebook.react.turbomodule.core.interfaces.TurboModuleWithJSIBindings
17
19
  */
18
20
  @ReactModule(name = NativeSmoothClipModuleSpec.NAME)
19
21
  class SmoothClipModule(context: ReactApplicationContext) :
20
- NativeSmoothClipModuleSpec(context), TurboModuleWithJSIBindings {
22
+ NativeSmoothClipModuleSpec(context),
23
+ TurboModuleWithJSIBindings,
24
+ LifecycleEventListener {
25
+
26
+ init {
27
+ context.addLifecycleEventListener(this)
28
+ setApplicationActive(context.lifecycleState == LifecycleState.RESUMED)
29
+ }
30
+
31
+ private fun setApplicationActive(active: Boolean) {
32
+ val updateState = {
33
+ if (!active) {
34
+ SmoothClipBindings.pauseFrameLoop()
35
+ }
36
+ SmoothClipBindings.nativeSetApplicationActive(active)
37
+ }
38
+ if (UiThreadUtil.isOnUiThread()) {
39
+ updateState()
40
+ } else {
41
+ UiThreadUtil.runOnUiThread(updateState)
42
+ }
43
+ }
44
+
45
+ override fun onHostResume() {
46
+ setApplicationActive(true)
47
+ }
48
+
49
+ override fun onHostPause() {
50
+ setApplicationActive(false)
51
+ }
52
+
53
+ override fun onHostDestroy() {
54
+ setApplicationActive(false)
55
+ }
21
56
 
22
57
  override fun getBindingsInstaller(): BindingsInstallerHolder =
23
58
  SmoothClipBindings.getBindingsInstaller()
24
59
 
25
60
  override fun invalidate() {
61
+ reactApplicationContext.removeLifecycleEventListener(this)
26
62
  super.invalidate()
27
63
  // Completions must stop and runtime-bound listener functions must be
28
64
  // released before the JS runtime is torn down; otherwise a later host
@@ -104,6 +140,8 @@ class SmoothClipModule(context: ReactApplicationContext) :
104
140
  shadowOffsetY: Double,
105
141
  shadowBlurRadius: Double,
106
142
  shadowSpreadDistance: Double,
143
+ rotation: Double,
144
+ opacity: Double,
107
145
  ) = Unit
108
146
 
109
147
  override fun beginInteraction(driverId: Double): WritableArray =