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

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.
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
 
@@ -135,8 +135,21 @@ relative energy threshold `6e-9`. A spring is rejected when its resolved native
135
135
  trajectory could make `contentScale` nonpositive.
136
136
 
137
137
  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
138
+ with its full duration. While the application is inactive, native animation
139
+ transactions stay pending and do not report cancellation. Timing animations
140
+ retain wall-clock progress and catch up after activation. Springs stay frozen;
141
+ their first foreground step consumes at most 64 ms, matching Reanimated, and
142
+ then they continue normally. If the platform discarded an underlying animation
143
+ object, the transaction settles successfully at its requested target.
144
+
145
+ Interactive `setFrame()` updates remain application-owned. The library does not
146
+ reset a drag or mutate related SharedValues or React state when lifecycle state
147
+ changes, so applications do not need lifecycle integration for the clip itself.
148
+
149
+ Host loss while the application is active keeps the existing semantics:
150
+ standalone animations apply their target and finish successfully, while groups
151
+ (including public controllers because they use one-member groups) freeze every
152
+ member and resolve `false`. Replacement, cancellation, destruction, and
140
153
  rejection also settle once with `false`.
141
154
 
142
155
  ## Atomic groups
@@ -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
@@ -918,6 +918,12 @@ void nativeSetViewLifecycleVisibility(
918
918
  static_cast<uint64_t>(driverId), view, lifecycleVisible != 0);
919
919
  }
920
920
 
921
+ void nativeSetApplicationActive(
922
+ jni::alias_ref<jni::JObject>,
923
+ jboolean active) {
924
+ smoothclip::setApplicationActiveAndroid(active != 0);
925
+ }
926
+
921
927
  void nativeInvalidate(jni::alias_ref<jni::JObject>) {
922
928
  smoothclip::invalidateBindings();
923
929
  }
@@ -942,6 +948,8 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
942
948
  makeNativeMethod(
943
949
  "nativeSetViewLifecycleVisibility",
944
950
  nativeSetViewLifecycleVisibility),
951
+ makeNativeMethod(
952
+ "nativeSetApplicationActive", nativeSetApplicationActive),
945
953
  makeNativeMethod("nativeInvalidate", nativeInvalidate),
946
954
  makeNativeMethod("nativeOnFrame", nativeOnFrame),
947
955
  });
@@ -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.
@@ -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);
@@ -77,6 +77,8 @@ internal object SmoothClipBindings {
77
77
  lifecycleVisible: Boolean,
78
78
  )
79
79
 
80
+ external fun nativeSetApplicationActive(active: Boolean)
81
+
80
82
  /**
81
83
  * Releases runtime-bound listener state during host teardown; must run
82
84
  * while the JS runtime is still alive (module invalidate).
@@ -99,6 +101,10 @@ internal object SmoothClipBindings {
99
101
  Choreographer.getInstance().postFrameCallback(frameCallback)
100
102
  }
101
103
 
104
+ fun pauseFrameLoop() {
105
+ Choreographer.getInstance().removeFrameCallback(frameCallback)
106
+ }
107
+
102
108
  /** Advances the frame loop with the doFrame vsync timestamp. */
103
109
  external fun nativeOnFrame(frameTimeNanos: Long)
104
110
  }
@@ -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
@@ -91,6 +91,14 @@ inline double timingFraction(
91
91
  return clamp01((nowS - startedAtS) / durationS);
92
92
  }
93
93
 
94
+ // Springs clamp a long frame delta, so inactive wall time must remain visible
95
+ // to lastFrameS. Only the independent safety-cap origin moves forward.
96
+ inline double rebaseSpringStartAfterInactivity(
97
+ double startedAtS,
98
+ double inactiveDurationS) {
99
+ return startedAtS + std::max(0.0, inactiveDurationS);
100
+ }
101
+
94
102
  // --- Timing curve --------------------------------------------------------
95
103
 
96
104
  inline double cubicBezierParameterForX(
@@ -44,6 +44,12 @@
44
44
  animationId:(int32_t)animationId
45
45
  sharedBeginTime:(CFTimeInterval)sharedBeginTime;
46
46
  - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
47
+ - (BOOL)smoothClipPauseSpringAnimation:(int32_t)animationId
48
+ atMediaTime:(CFTimeInterval)mediaTime;
49
+ - (BOOL)smoothClipResumeSpringAnimation:(int32_t)animationId
50
+ atMediaTime:(CFTimeInterval)mediaTime
51
+ catchUpBy:(CFTimeInterval)catchUp;
52
+ - (BOOL)smoothClipHasInstalledAnimation:(int32_t)animationId;
47
53
  - (BOOL)smoothClipIsJoinable;
48
54
  - (BOOL)smoothClipCanDisplay;
49
55
  - (double)smoothClipInheritedVelocityToPresentation:
@@ -76,6 +82,8 @@ struct ActiveAnimation {
76
82
  // False while a pre-ready run waits for its first displayable host. Host
77
83
  // loss after the run starts finishes it at the target.
78
84
  bool started = false;
85
+ bool exposedToInactivity = false;
86
+ bool needsForegroundReconciliation = false;
79
87
  };
80
88
 
81
89
  struct DriverState {
@@ -155,6 +163,11 @@ bool &applicationActiveState() {
155
163
  return active;
156
164
  }
157
165
 
166
+ std::optional<CFTimeInterval> &applicationInactiveSince() {
167
+ static std::optional<CFTimeInterval> value;
168
+ return value;
169
+ }
170
+
158
171
  CompletionSink &completionSink() {
159
172
  static CompletionSink sink;
160
173
  return sink;
@@ -185,6 +198,113 @@ std::vector<DriverSnapshot> cancelGroupInternal(
185
198
  void tryStartGroup(int32_t groupId);
186
199
  void finishGroupForHostLoss(int32_t groupId);
187
200
  DriverSnapshot snapshotForDriver(uint64_t driverId);
201
+ SmoothClipView *viewForKey(ViewKey key);
202
+
203
+ bool animationNeedsForegroundReconciliation(const DriverState &state) {
204
+ if (!state.animation.has_value()) return false;
205
+ const ActiveAnimation &animation = *state.animation;
206
+ if (animation.needsForegroundReconciliation) return true;
207
+ if (!animation.exposedToInactivity) return false;
208
+ if (animation.participants.empty()) return true;
209
+ for (const ViewKey key : animation.participants) {
210
+ if (![viewForKey(key)
211
+ smoothClipHasInstalledAnimation:animation.animationId]) {
212
+ return true;
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+
218
+ bool groupNeedsForegroundReconciliation(int32_t groupId) {
219
+ const auto groupIterator = groupRegistry().find(groupId);
220
+ if (groupIterator == groupRegistry().end()) return false;
221
+ for (const uint64_t driverId : groupIterator->second.driverIds) {
222
+ const auto stateIterator = registry().find(driverId);
223
+ if (stateIterator != registry().end() &&
224
+ stateIterator->second.animation.has_value() &&
225
+ stateIterator->second.animation->groupId == groupId &&
226
+ animationNeedsForegroundReconciliation(stateIterator->second)) {
227
+ return true;
228
+ }
229
+ }
230
+ return false;
231
+ }
232
+
233
+ bool pauseSpringForInactivity(
234
+ DriverState &state,
235
+ CFTimeInterval mediaTime) {
236
+ if (!state.animation.has_value() || !state.animation->started ||
237
+ state.animation->kind != AnimationKind::Spring) {
238
+ return true;
239
+ }
240
+ ActiveAnimation &animation = *state.animation;
241
+ if (animation.participants.empty()) {
242
+ animation.needsForegroundReconciliation = true;
243
+ return false;
244
+ }
245
+ bool paused = true;
246
+ for (const ViewKey key : animation.participants) {
247
+ paused = [viewForKey(key)
248
+ smoothClipPauseSpringAnimation:animation.animationId
249
+ atMediaTime:mediaTime] && paused;
250
+ }
251
+ if (!paused) animation.needsForegroundReconciliation = true;
252
+ return paused;
253
+ }
254
+
255
+ bool resumeSpringAfterInactivity(
256
+ DriverState &state,
257
+ CFTimeInterval mediaTime,
258
+ CFTimeInterval catchUp) {
259
+ if (!state.animation.has_value() || !state.animation->started ||
260
+ state.animation->kind != AnimationKind::Spring) {
261
+ return true;
262
+ }
263
+ ActiveAnimation &animation = *state.animation;
264
+ bool resumed = !animation.participants.empty();
265
+ for (const ViewKey key : animation.participants) {
266
+ resumed = [viewForKey(key)
267
+ smoothClipResumeSpringAnimation:animation.animationId
268
+ atMediaTime:mediaTime
269
+ catchUpBy:catchUp] && resumed;
270
+ }
271
+ if (!resumed) animation.needsForegroundReconciliation = true;
272
+ return resumed;
273
+ }
274
+
275
+ bool resumeGroupSpringsAfterInactivity(
276
+ int32_t groupId,
277
+ CFTimeInterval mediaTime,
278
+ CFTimeInterval catchUp) {
279
+ const auto groupIterator = groupRegistry().find(groupId);
280
+ if (groupIterator == groupRegistry().end()) return false;
281
+ bool resumed = true;
282
+ for (const uint64_t driverId : groupIterator->second.driverIds) {
283
+ const auto stateIterator = registry().find(driverId);
284
+ if (stateIterator == registry().end() ||
285
+ !stateIterator->second.animation.has_value() ||
286
+ stateIterator->second.animation->groupId != groupId) {
287
+ return false;
288
+ }
289
+ resumed = resumeSpringAfterInactivity(
290
+ stateIterator->second, mediaTime, catchUp) && resumed;
291
+ }
292
+ return resumed;
293
+ }
294
+
295
+ void clearGroupInactivityExposure(int32_t groupId) {
296
+ const auto groupIterator = groupRegistry().find(groupId);
297
+ if (groupIterator == groupRegistry().end()) return;
298
+ for (const uint64_t driverId : groupIterator->second.driverIds) {
299
+ const auto stateIterator = registry().find(driverId);
300
+ if (stateIterator != registry().end() &&
301
+ stateIterator->second.animation.has_value() &&
302
+ stateIterator->second.animation->groupId == groupId) {
303
+ stateIterator->second.animation->exposedToInactivity = false;
304
+ stateIterator->second.animation->needsForegroundReconciliation = false;
305
+ }
306
+ }
307
+ }
188
308
 
189
309
  Presentation unavailablePresentation() {
190
310
  // Non-finite geometry makes the JS side fall back to its current value
@@ -647,6 +767,7 @@ void tryStartGroup(int32_t groupId) {
647
767
  if (stateIterator == registry().end() ||
648
768
  !stateIterator->second.animation.has_value() ||
649
769
  stateIterator->second.animation->groupId != groupId ||
770
+ stateIterator->second.animation->started ||
650
771
  !driverReady(stateIterator->second)) {
651
772
  return;
652
773
  }
@@ -689,19 +810,14 @@ bool applicationIsActive() {
689
810
  void applicationWillResignActive() {
690
811
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
691
812
  if (!applicationActiveState()) return;
813
+ const CFTimeInterval mediaTime = CACurrentMediaTime();
692
814
  applicationActiveState() = false;
693
- std::vector<int32_t> groupIds;
694
- groupIds.reserve(groupRegistry().size());
695
- for (const auto &[groupId, group] : groupRegistry()) {
696
- (void)group;
697
- groupIds.push_back(groupId);
698
- }
699
- for (const int32_t groupId : groupIds) {
700
- finishGroupForHostLoss(groupId);
701
- }
815
+ applicationInactiveSince() = mediaTime;
702
816
  for (auto &[driverId, state] : registry()) {
703
- if (!state.animation.has_value() || state.animation->groupId == 0) {
704
- finishStandaloneAtTarget(driverId, state);
817
+ (void)driverId;
818
+ if (state.animation.has_value() && state.animation->started) {
819
+ state.animation->exposedToInactivity = true;
820
+ pauseSpringForInactivity(state, mediaTime);
705
821
  }
706
822
  }
707
823
  }
@@ -709,6 +825,14 @@ void applicationWillResignActive() {
709
825
  void applicationDidBecomeActive() {
710
826
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
711
827
  if (applicationActiveState()) return;
828
+ const CFTimeInterval mediaTime = CACurrentMediaTime();
829
+ const CFTimeInterval inactiveDuration = applicationInactiveSince().has_value()
830
+ ? std::max((CFTimeInterval)0,
831
+ mediaTime - *applicationInactiveSince())
832
+ : 0;
833
+ const CFTimeInterval springCatchUp =
834
+ std::min((CFTimeInterval)0.064, inactiveDuration);
835
+ applicationInactiveSince().reset();
712
836
  applicationActiveState() = true;
713
837
  std::vector<int32_t> groupIds;
714
838
  groupIds.reserve(groupRegistry().size());
@@ -717,13 +841,35 @@ void applicationDidBecomeActive() {
717
841
  groupIds.push_back(groupId);
718
842
  }
719
843
  for (const int32_t groupId : groupIds) {
720
- tryStartGroup(groupId);
844
+ bool needsReconciliation = groupNeedsForegroundReconciliation(groupId);
845
+ if (!needsReconciliation) {
846
+ needsReconciliation = !resumeGroupSpringsAfterInactivity(
847
+ groupId, mediaTime, springCatchUp);
848
+ }
849
+ if (needsReconciliation) {
850
+ cancelGroupInternal(groupId, GroupCancelBehavior::Finish, true);
851
+ } else {
852
+ clearGroupInactivityExposure(groupId);
853
+ tryStartGroup(groupId);
854
+ }
721
855
  }
722
856
  for (auto &[driverId, state] : registry()) {
723
- (void)driverId;
724
- if (state.animation.has_value() && !state.animation->started &&
725
- state.animation->groupId == 0 && anyDisplayableView(state)) {
726
- startPendingAnimation(state);
857
+ if (!state.animation.has_value() || state.animation->groupId != 0) {
858
+ continue;
859
+ }
860
+ bool needsReconciliation = animationNeedsForegroundReconciliation(state);
861
+ if (!needsReconciliation) {
862
+ needsReconciliation = !resumeSpringAfterInactivity(
863
+ state, mediaTime, springCatchUp);
864
+ }
865
+ if (needsReconciliation) {
866
+ finishStandaloneAtTarget(driverId, state);
867
+ } else {
868
+ state.animation->exposedToInactivity = false;
869
+ state.animation->needsForegroundReconciliation = false;
870
+ if (!state.animation->started && anyDisplayableView(state)) {
871
+ startPendingAnimation(state);
872
+ }
727
873
  }
728
874
  }
729
875
  }
@@ -818,6 +964,9 @@ void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
818
964
  return;
819
965
  }
820
966
  if (![view smoothClipCanDisplay]) {
967
+ if (!applicationActiveState()) {
968
+ return;
969
+ }
821
970
  if (state.animation->groupId != 0 && state.animation->started) {
822
971
  finishGroupForHostLoss(state.animation->groupId);
823
972
  } else if (state.animation->started) {
@@ -841,7 +990,10 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
841
990
  DriverState &state = iterator->second;
842
991
  const ViewKey key = keyForView(view);
843
992
  if (state.animation.has_value()) {
844
- if (state.animation->groupId != 0 && state.animation->started) {
993
+ if (!applicationActiveState() && state.animation->started) {
994
+ state.animation->participants.erase(key);
995
+ state.animation->needsForegroundReconciliation = true;
996
+ } else if (state.animation->groupId != 0 && state.animation->started) {
845
997
  finishGroupForHostLoss(state.animation->groupId);
846
998
  } else if (state.animation->groupId == 0 && state.animation->started) {
847
999
  finishStandaloneAtTarget(driverId, state);
@@ -1611,6 +1763,10 @@ void viewAnimationDidStop(
1611
1763
 
1612
1764
  const ViewKey key = keyForView(view);
1613
1765
  if (state.animation->participants.erase(key) == 0) return;
1766
+ if (!applicationActiveState() || state.animation->exposedToInactivity) {
1767
+ state.animation->needsForegroundReconciliation = true;
1768
+ return;
1769
+ }
1614
1770
  state.animation->finished = state.animation->finished && finished;
1615
1771
  finishIfNoInstalledParticipants(driverId, state);
1616
1772
  }
@@ -365,6 +365,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
365
365
  smoothclip::TimingAnimation _timingAnimation;
366
366
  smoothclip::SpringAnimation _springAnimation;
367
367
  SmoothClipAnimationDelegate *_animationDelegate;
368
+ BOOL _springPausedForInactivity;
369
+ BOOL _pausedSpringHadMaskAnimation;
370
+ BOOL _pausedSpringHadShadowAnimation;
368
371
 
369
372
  // 'inherit' velocity samples (raw canonical geometry, per view); recording/
370
373
  // coalescing/projection live in the shared cpp/SmoothClipVelocityTracker.h
@@ -445,6 +448,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
445
448
  _ignoreAnimationCallback = NO;
446
449
  _activeAnimationId = 0;
447
450
  _activeAnimationKind = 0;
451
+ _springPausedForInactivity = NO;
452
+ _pausedSpringHadMaskAnimation = NO;
453
+ _pausedSpringHadShadowAnimation = NO;
448
454
  smoothclip::clearVelocitySamples(_velocitySamples);
449
455
  // App-state transitions are observed by the registry itself (see
450
456
  // installApplicationStateObservers): the active flag is process-global,
@@ -927,6 +933,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
927
933
  _activeAnimationId = 0;
928
934
  _activeAnimationKind = 0;
929
935
  _animationDelegate = nil;
936
+ _springPausedForInactivity = NO;
937
+ _pausedSpringHadMaskAnimation = NO;
938
+ _pausedSpringHadShadowAnimation = NO;
930
939
  }
931
940
 
932
941
  - (CABasicAnimation *)basicAnimationForKeyPath:(NSString *)keyPath
@@ -1318,6 +1327,12 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1318
1327
  maskAnimation.duration = group.duration;
1319
1328
  }
1320
1329
 
1330
+ // An explicit media-time origin is required to pause and resume spring
1331
+ // groups without touching the parent layer clock (which would also pause
1332
+ // consumer animations in the clipped subtree).
1333
+ if (sharedBeginTime <= 0) {
1334
+ sharedBeginTime = CACurrentMediaTime();
1335
+ }
1321
1336
  if (sharedBeginTime > 0) {
1322
1337
  // Each layer receives the same absolute media timestamp translated into
1323
1338
  // its local clock. Group participants therefore advance from one epoch
@@ -1343,6 +1358,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1343
1358
  _animationDelegate.driverId = _driverId;
1344
1359
  _animationDelegate.animationId = _activeAnimationId;
1345
1360
  group.delegate = _animationDelegate;
1361
+ _springPausedForInactivity = NO;
1362
+ _pausedSpringHadMaskAnimation = NO;
1363
+ _pausedSpringHadShadowAnimation = NO;
1346
1364
  [layer addAnimation:group forKey:@"smoothClip.geometry"];
1347
1365
  [_contentContainer.layer addAnimation:contentGroup
1348
1366
  forKey:@"smoothClip.content"];
@@ -1465,6 +1483,154 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1465
1483
  }
1466
1484
  }
1467
1485
 
1486
+ - (BOOL)smoothClipPauseSpringAnimation:(int32_t)animationId
1487
+ atMediaTime:(CFTimeInterval)mediaTime {
1488
+ if (_activeAnimationId != animationId || _activeAnimationKind != 2 ||
1489
+ _springPausedForInactivity) {
1490
+ return NO;
1491
+ }
1492
+
1493
+ CALayer *geometryLayer = _clipContainer.layer;
1494
+ CALayer *contentLayer = _contentContainer.layer;
1495
+ CAAnimation *installedGeometry =
1496
+ [geometryLayer animationForKey:@"smoothClip.geometry"];
1497
+ CAAnimation *installedContent =
1498
+ [contentLayer animationForKey:@"smoothClip.content"];
1499
+ if (installedGeometry == nil || installedContent == nil) return NO;
1500
+
1501
+ auto pausedCopy = ^CAAnimation *(CALayer *layer, CAAnimation *installed) {
1502
+ if (installed == nil) return (CAAnimation *)nil;
1503
+ CAAnimation *paused = [installed copy];
1504
+ const CFTimeInterval localNow =
1505
+ [layer convertTime:mediaTime fromLayer:nil];
1506
+ const CFTimeInterval elapsed = std::clamp(
1507
+ localNow - installed.beginTime,
1508
+ (CFTimeInterval)0,
1509
+ installed.duration);
1510
+ paused.speed = 0;
1511
+ paused.timeOffset = elapsed;
1512
+ paused.beginTime = 0;
1513
+ return paused;
1514
+ };
1515
+
1516
+ CAAnimation *geometry = pausedCopy(geometryLayer, installedGeometry);
1517
+ CAAnimation *content = pausedCopy(contentLayer, installedContent);
1518
+ CAAnimation *installedMask =
1519
+ [_unequalCornerMask animationForKey:@"smoothClip.mask"];
1520
+ CAAnimation *mask = pausedCopy(_unequalCornerMask, installedMask);
1521
+ CAAnimation *installedShadow =
1522
+ [_shadowLayer animationForKey:@"smoothClip.shadow"];
1523
+ CAAnimation *shadow = pausedCopy(_shadowLayer, installedShadow);
1524
+
1525
+ _animationDelegate.invalidated = YES;
1526
+ _ignoreAnimationCallback = YES;
1527
+ [geometryLayer removeAnimationForKey:@"smoothClip.geometry"];
1528
+ [contentLayer removeAnimationForKey:@"smoothClip.content"];
1529
+ [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1530
+ [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
1531
+
1532
+ _animationDelegate = [SmoothClipAnimationDelegate new];
1533
+ _animationDelegate.view = self;
1534
+ _animationDelegate.driverId = _driverId;
1535
+ _animationDelegate.animationId = animationId;
1536
+ geometry.delegate = _animationDelegate;
1537
+ [geometryLayer addAnimation:geometry forKey:@"smoothClip.geometry"];
1538
+ [contentLayer addAnimation:content forKey:@"smoothClip.content"];
1539
+ if (mask != nil) {
1540
+ [_unequalCornerMask addAnimation:mask forKey:@"smoothClip.mask"];
1541
+ }
1542
+ if (shadow != nil) {
1543
+ [_shadowLayer addAnimation:shadow forKey:@"smoothClip.shadow"];
1544
+ }
1545
+ _ignoreAnimationCallback = NO;
1546
+ _springPausedForInactivity = YES;
1547
+ _pausedSpringHadMaskAnimation = mask != nil;
1548
+ _pausedSpringHadShadowAnimation = shadow != nil;
1549
+ return YES;
1550
+ }
1551
+
1552
+ - (BOOL)smoothClipResumeSpringAnimation:(int32_t)animationId
1553
+ atMediaTime:(CFTimeInterval)mediaTime
1554
+ catchUpBy:(CFTimeInterval)catchUp {
1555
+ if (_activeAnimationId != animationId || _activeAnimationKind != 2 ||
1556
+ !_springPausedForInactivity) {
1557
+ return NO;
1558
+ }
1559
+
1560
+ CALayer *geometryLayer = _clipContainer.layer;
1561
+ CALayer *contentLayer = _contentContainer.layer;
1562
+ CAAnimation *installedGeometry =
1563
+ [geometryLayer animationForKey:@"smoothClip.geometry"];
1564
+ CAAnimation *installedContent =
1565
+ [contentLayer animationForKey:@"smoothClip.content"];
1566
+ CAAnimation *installedMask =
1567
+ [_unequalCornerMask animationForKey:@"smoothClip.mask"];
1568
+ CAAnimation *installedShadow =
1569
+ [_shadowLayer animationForKey:@"smoothClip.shadow"];
1570
+ if (installedGeometry == nil || installedContent == nil ||
1571
+ (_pausedSpringHadMaskAnimation && installedMask == nil) ||
1572
+ (_pausedSpringHadShadowAnimation && installedShadow == nil)) {
1573
+ return NO;
1574
+ }
1575
+
1576
+ auto resumedCopy = ^CAAnimation *(CALayer *layer, CAAnimation *installed) {
1577
+ if (installed == nil) return (CAAnimation *)nil;
1578
+ CAAnimation *resumed = [installed copy];
1579
+ const CFTimeInterval localNow =
1580
+ [layer convertTime:mediaTime fromLayer:nil];
1581
+ const CFTimeInterval resumedTime = std::min(
1582
+ installed.duration,
1583
+ std::max((CFTimeInterval)0, installed.timeOffset + catchUp));
1584
+ resumed.speed = 1;
1585
+ resumed.timeOffset = 0;
1586
+ resumed.beginTime = localNow - resumedTime;
1587
+ return resumed;
1588
+ };
1589
+
1590
+ CAAnimation *geometry = resumedCopy(geometryLayer, installedGeometry);
1591
+ CAAnimation *content = resumedCopy(contentLayer, installedContent);
1592
+ CAAnimation *mask = resumedCopy(_unequalCornerMask, installedMask);
1593
+ CAAnimation *shadow = resumedCopy(_shadowLayer, installedShadow);
1594
+
1595
+ _animationDelegate.invalidated = YES;
1596
+ _ignoreAnimationCallback = YES;
1597
+ [geometryLayer removeAnimationForKey:@"smoothClip.geometry"];
1598
+ [contentLayer removeAnimationForKey:@"smoothClip.content"];
1599
+ [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1600
+ [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
1601
+
1602
+ _animationDelegate = [SmoothClipAnimationDelegate new];
1603
+ _animationDelegate.view = self;
1604
+ _animationDelegate.driverId = _driverId;
1605
+ _animationDelegate.animationId = animationId;
1606
+ geometry.delegate = _animationDelegate;
1607
+ [geometryLayer addAnimation:geometry forKey:@"smoothClip.geometry"];
1608
+ [contentLayer addAnimation:content forKey:@"smoothClip.content"];
1609
+ if (mask != nil) {
1610
+ [_unequalCornerMask addAnimation:mask forKey:@"smoothClip.mask"];
1611
+ }
1612
+ if (shadow != nil) {
1613
+ [_shadowLayer addAnimation:shadow forKey:@"smoothClip.shadow"];
1614
+ }
1615
+ _ignoreAnimationCallback = NO;
1616
+ _springPausedForInactivity = NO;
1617
+ _pausedSpringHadMaskAnimation = NO;
1618
+ _pausedSpringHadShadowAnimation = NO;
1619
+ return YES;
1620
+ }
1621
+
1622
+ - (BOOL)smoothClipHasInstalledAnimation:(int32_t)animationId {
1623
+ return _activeAnimationId == animationId &&
1624
+ [_clipContainer.layer animationForKey:@"smoothClip.geometry"] != nil &&
1625
+ [_contentContainer.layer animationForKey:@"smoothClip.content"] != nil &&
1626
+ (!_springPausedForInactivity ||
1627
+ (!_pausedSpringHadMaskAnimation ||
1628
+ [_unequalCornerMask animationForKey:@"smoothClip.mask"] != nil)) &&
1629
+ (!_springPausedForInactivity ||
1630
+ (!_pausedSpringHadShadowAnimation ||
1631
+ [_shadowLayer animationForKey:@"smoothClip.shadow"] != nil));
1632
+ }
1633
+
1468
1634
  - (void)smoothClipAnimationDidStopWithDriverId:(uint64_t)driverId
1469
1635
  animationId:(int32_t)animationId
1470
1636
  finished:(BOOL)finished {
@@ -1472,6 +1638,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1472
1638
  _activeAnimationId = 0;
1473
1639
  _activeAnimationKind = 0;
1474
1640
  _animationDelegate = nil;
1641
+ _springPausedForInactivity = NO;
1642
+ _pausedSpringHadMaskAnimation = NO;
1643
+ _pausedSpringHadShadowAnimation = NO;
1475
1644
  [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1476
1645
  [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
1477
1646
  [self applyStaticCornerRepresentation:[self canonicalGeometryValue]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-smooth-clip-view",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Layout-free animated rounded clipping for React Native Fabric",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",