react-native-smooth-clip-view 0.2.9 → 0.4.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 (193) hide show
  1. package/README.md +132 -349
  2. package/android/build.gradle +8 -4
  3. package/android/proguard-rules.pro +3 -3
  4. package/android/src/main/cpp/SmoothClipAndroid.h +13 -6
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +605 -123
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +975 -209
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +192 -118
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +22 -4
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +81 -56
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +449 -240
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +150 -38
  12. package/cpp/SmoothClipAnimationCurve.h +317 -397
  13. package/cpp/SmoothClipRegistry.h +84 -20
  14. package/cpp/SmoothClipSharedGeometry.h +131 -22
  15. package/cpp/SmoothClipVelocityTracker.h +16 -12
  16. package/ios/SmoothClipGeometry.h +188 -21
  17. package/ios/SmoothClipRegistry.mm +869 -368
  18. package/ios/SmoothClipRegistrySnapshot.h +13 -0
  19. package/ios/SmoothClipTurboModule.cpp +513 -156
  20. package/ios/SmoothClipTurboModule.h +123 -56
  21. package/ios/SmoothClipView.mm +1074 -449
  22. package/ios/SmoothClipViewRegistry.h +3 -7
  23. package/ios/reactnativesmoothclipview.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  24. package/ios/reactnativesmoothclipview.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  25. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  26. package/lib/module/SmoothClipView.ios.js +94 -11
  27. package/lib/module/SmoothClipView.ios.js.map +1 -1
  28. package/lib/module/SmoothClipView.js +1 -46
  29. package/lib/module/SmoothClipView.js.map +1 -1
  30. package/lib/module/SmoothClipViewNativeComponent.ts +32 -12
  31. package/lib/module/capabilities.js +4 -0
  32. package/lib/module/capabilities.js.map +1 -0
  33. package/lib/module/capabilities.native.js +18 -0
  34. package/lib/module/capabilities.native.js.map +1 -0
  35. package/lib/module/capabilityTypes.js +2 -0
  36. package/lib/module/capabilityTypes.js.map +1 -0
  37. package/lib/module/controllerInternals.js +28 -0
  38. package/lib/module/controllerInternals.js.map +1 -0
  39. package/lib/module/controllerLifecycle.js +9 -0
  40. package/lib/module/controllerLifecycle.js.map +1 -0
  41. package/lib/module/controllerTypes.js +4 -0
  42. package/lib/module/controllerTypes.js.map +1 -0
  43. package/lib/module/controllers.android.js +4 -0
  44. package/lib/module/controllers.android.js.map +1 -0
  45. package/lib/module/controllers.ios.js +4 -0
  46. package/lib/module/controllers.ios.js.map +1 -0
  47. package/lib/module/controllers.js +4 -0
  48. package/lib/module/controllers.js.map +1 -0
  49. package/lib/module/controllers.native.js +89 -0
  50. package/lib/module/controllers.native.js.map +1 -0
  51. package/lib/module/geometry.js +180 -30
  52. package/lib/module/geometry.js.map +1 -1
  53. package/lib/module/groupTypes.js +4 -0
  54. package/lib/module/groupTypes.js.map +1 -0
  55. package/lib/module/groups.android.js +4 -0
  56. package/lib/module/groups.android.js.map +1 -0
  57. package/lib/module/groups.ios.js +4 -0
  58. package/lib/module/groups.ios.js.map +1 -0
  59. package/lib/module/groups.js +4 -0
  60. package/lib/module/groups.js.map +1 -0
  61. package/lib/module/groups.native.js +399 -0
  62. package/lib/module/groups.native.js.map +1 -0
  63. package/lib/module/ids.js +8 -0
  64. package/lib/module/ids.js.map +1 -0
  65. package/lib/module/index.js +4 -3
  66. package/lib/module/index.js.map +1 -1
  67. package/lib/module/nativeCompletion.js +37 -16
  68. package/lib/module/nativeCompletion.js.map +1 -1
  69. package/lib/module/presentationCodec.js +81 -0
  70. package/lib/module/presentationCodec.js.map +1 -0
  71. package/lib/typescript/src/NativeSmoothClipModule.d.ts +20 -4
  72. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  73. package/lib/typescript/src/SmoothClipView.d.ts +2 -10
  74. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  75. package/lib/typescript/src/SmoothClipView.ios.d.ts +139 -4
  76. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  77. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +16 -3
  78. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  79. package/lib/typescript/src/capabilities.d.ts +3 -0
  80. package/lib/typescript/src/capabilities.d.ts.map +1 -0
  81. package/lib/typescript/src/capabilities.native.d.ts +3 -0
  82. package/lib/typescript/src/capabilities.native.d.ts.map +1 -0
  83. package/lib/typescript/src/capabilityTypes.d.ts +4 -0
  84. package/lib/typescript/src/capabilityTypes.d.ts.map +1 -0
  85. package/lib/typescript/src/controllerInternals.d.ts +14 -0
  86. package/lib/typescript/src/controllerInternals.d.ts.map +1 -0
  87. package/lib/typescript/src/controllerLifecycle.d.ts +3 -0
  88. package/lib/typescript/src/controllerLifecycle.d.ts.map +1 -0
  89. package/lib/typescript/src/controllerTypes.d.ts +62 -0
  90. package/lib/typescript/src/controllerTypes.d.ts.map +1 -0
  91. package/lib/typescript/src/controllers.android.d.ts +2 -0
  92. package/lib/typescript/src/controllers.android.d.ts.map +1 -0
  93. package/lib/typescript/src/controllers.d.ts +2 -0
  94. package/lib/typescript/src/controllers.d.ts.map +1 -0
  95. package/lib/typescript/src/controllers.ios.d.ts +2 -0
  96. package/lib/typescript/src/controllers.ios.d.ts.map +1 -0
  97. package/lib/typescript/src/controllers.native.d.ts +3 -0
  98. package/lib/typescript/src/controllers.native.d.ts.map +1 -0
  99. package/lib/typescript/src/geometry.d.ts +49 -5
  100. package/lib/typescript/src/geometry.d.ts.map +1 -1
  101. package/lib/typescript/src/groupTypes.d.ts +31 -0
  102. package/lib/typescript/src/groupTypes.d.ts.map +1 -0
  103. package/lib/typescript/src/groups.android.d.ts +2 -0
  104. package/lib/typescript/src/groups.android.d.ts.map +1 -0
  105. package/lib/typescript/src/groups.d.ts +2 -0
  106. package/lib/typescript/src/groups.d.ts.map +1 -0
  107. package/lib/typescript/src/groups.ios.d.ts +2 -0
  108. package/lib/typescript/src/groups.ios.d.ts.map +1 -0
  109. package/lib/typescript/src/groups.native.d.ts +3 -0
  110. package/lib/typescript/src/groups.native.d.ts.map +1 -0
  111. package/lib/typescript/src/ids.d.ts +2 -0
  112. package/lib/typescript/src/ids.d.ts.map +1 -0
  113. package/lib/typescript/src/index.d.ts +8 -5
  114. package/lib/typescript/src/index.d.ts.map +1 -1
  115. package/lib/typescript/src/nativeCompletion.d.ts +16 -6
  116. package/lib/typescript/src/nativeCompletion.d.ts.map +1 -1
  117. package/lib/typescript/src/presentationCodec.d.ts +10 -0
  118. package/lib/typescript/src/presentationCodec.d.ts.map +1 -0
  119. package/package.json +17 -13
  120. package/src/NativeSmoothClipModule.ts +80 -56
  121. package/src/SmoothClipView.ios.tsx +142 -16
  122. package/src/SmoothClipView.tsx +6 -76
  123. package/src/SmoothClipViewNativeComponent.ts +32 -12
  124. package/src/capabilities.native.ts +19 -0
  125. package/src/capabilities.ts +2 -0
  126. package/src/capabilityTypes.ts +3 -0
  127. package/src/controllerInternals.ts +52 -0
  128. package/src/controllerLifecycle.ts +6 -0
  129. package/src/controllerTypes.ts +87 -0
  130. package/src/controllers.android.ts +1 -0
  131. package/src/controllers.ios.ts +1 -0
  132. package/src/controllers.native.ts +104 -0
  133. package/src/controllers.ts +1 -0
  134. package/src/geometry.ts +293 -45
  135. package/src/groupTypes.ts +53 -0
  136. package/src/groups.android.ts +1 -0
  137. package/src/groups.ios.ts +1 -0
  138. package/src/groups.native.ts +604 -0
  139. package/src/groups.ts +1 -0
  140. package/src/ids.ts +6 -0
  141. package/src/index.ts +32 -12
  142. package/src/nativeCompletion.ts +67 -32
  143. package/src/presentationCodec.ts +134 -0
  144. package/lib/module/SmoothClipView.web.js +0 -54
  145. package/lib/module/SmoothClipView.web.js.map +0 -1
  146. package/lib/module/driverState.js +0 -110
  147. package/lib/module/driverState.js.map +0 -1
  148. package/lib/module/driverTypes.js +0 -4
  149. package/lib/module/driverTypes.js.map +0 -1
  150. package/lib/module/drivers.android.js +0 -6
  151. package/lib/module/drivers.android.js.map +0 -1
  152. package/lib/module/drivers.ios.js +0 -6
  153. package/lib/module/drivers.ios.js.map +0 -1
  154. package/lib/module/drivers.js +0 -284
  155. package/lib/module/drivers.js.map +0 -1
  156. package/lib/module/drivers.native.js +0 -556
  157. package/lib/module/drivers.native.js.map +0 -1
  158. package/lib/module/index.web.js +0 -8
  159. package/lib/module/index.web.js.map +0 -1
  160. package/lib/module/reactRequests.js +0 -56
  161. package/lib/module/reactRequests.js.map +0 -1
  162. package/lib/module/webClipPath.js +0 -25
  163. package/lib/module/webClipPath.js.map +0 -1
  164. package/lib/typescript/src/SmoothClipView.web.d.ts +0 -10
  165. package/lib/typescript/src/SmoothClipView.web.d.ts.map +0 -1
  166. package/lib/typescript/src/driverState.d.ts +0 -31
  167. package/lib/typescript/src/driverState.d.ts.map +0 -1
  168. package/lib/typescript/src/driverTypes.d.ts +0 -107
  169. package/lib/typescript/src/driverTypes.d.ts.map +0 -1
  170. package/lib/typescript/src/drivers.android.d.ts +0 -3
  171. package/lib/typescript/src/drivers.android.d.ts.map +0 -1
  172. package/lib/typescript/src/drivers.d.ts +0 -5
  173. package/lib/typescript/src/drivers.d.ts.map +0 -1
  174. package/lib/typescript/src/drivers.ios.d.ts +0 -3
  175. package/lib/typescript/src/drivers.ios.d.ts.map +0 -1
  176. package/lib/typescript/src/drivers.native.d.ts +0 -5
  177. package/lib/typescript/src/drivers.native.d.ts.map +0 -1
  178. package/lib/typescript/src/index.web.d.ts +0 -9
  179. package/lib/typescript/src/index.web.d.ts.map +0 -1
  180. package/lib/typescript/src/reactRequests.d.ts +0 -9
  181. package/lib/typescript/src/reactRequests.d.ts.map +0 -1
  182. package/lib/typescript/src/webClipPath.d.ts +0 -7
  183. package/lib/typescript/src/webClipPath.d.ts.map +0 -1
  184. package/src/SmoothClipView.web.tsx +0 -66
  185. package/src/driverState.ts +0 -158
  186. package/src/driverTypes.ts +0 -140
  187. package/src/drivers.android.ts +0 -8
  188. package/src/drivers.ios.ts +0 -8
  189. package/src/drivers.native.ts +0 -899
  190. package/src/drivers.ts +0 -427
  191. package/src/index.web.ts +0 -23
  192. package/src/reactRequests.ts +0 -76
  193. package/src/webClipPath.ts +0 -30
@@ -9,6 +9,7 @@
9
9
  #include <algorithm>
10
10
  #include <array>
11
11
  #include <atomic>
12
+ #include <cassert>
12
13
  #include <chrono>
13
14
  #include <cmath>
14
15
  #include <limits>
@@ -26,8 +27,8 @@ using facebook::jni::alias_ref;
26
27
  using facebook::jni::global_ref;
27
28
 
28
29
  enum class Ownership { Interactive, Native };
29
- enum class AnimationKind { Timing, Spring, Keyframes };
30
- enum class ViewParticipation { Deferred, Active, Suspended };
30
+ enum class AnimationKind { Timing, Spring };
31
+ enum class ViewParticipation { Deferred, Active };
31
32
 
32
33
  // Load-bearing invariant, not just a convenience clock: advance()'s frame-clock
33
34
  // anchor compares this value ABSOLUTELY against a choreographer frame stamp
@@ -51,40 +52,35 @@ double nowSeconds() {
51
52
 
52
53
  struct ActiveAnimation {
53
54
  int32_t id = 0;
55
+ int32_t completionTag = 0;
54
56
  AnimationKind kind = AnimationKind::Timing;
55
57
  Presentation start{{0, 0, 0, 0, 0}, 0, 0};
56
58
  Presentation target{{0, 0, 0, 0, 0}, 0, 0};
57
59
  Presentation current{{0, 0, 0, 0, 0}, 0, 0};
58
60
  TimingAnimation timing{};
59
61
  SpringAnimation spring{};
60
- // Owns the keyframes and their precomputed monotone-cubic tangents; the
61
- // tangent solve happens on reset(), never per frame.
62
- KeyframeCurve keyframes;
63
62
  double durationS = 0;
64
63
  double startedAtS = 0;
65
- // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
66
- std::array<double, 7> springPosition{};
67
- std::array<double, 7> springVelocity{};
64
+ // Integrated spring state. Shadow channels are skipped when neither endpoint
65
+ // has a visible shadow.
66
+ Channels springPosition{};
67
+ Channels springVelocity{};
68
+ std::size_t springChannelCount = kBaseChannelCount;
68
69
  double lastFrameS = 0;
69
- bool finished = true;
70
- // False while the animation is latched, and held out of animatingDrivers()
71
- // so it cannot tick. Two ways in: built before any host view could display
72
- // (pre-registration, or a detached/unsized host), or re-latched by
73
- // unregisterViewAndroid when the last DISPLAYABLE host left mid-flight.
74
- // Started by whichever lifecycle/geometry update first sees a displayable
75
- // entry; each rebases the clock through startLatchedAnimation.
70
+ ScalarSpringState scalarSpring{0, 0};
71
+ // False only while a pre-ready animation waits for its first displayable
72
+ // host. Once started, host loss freezes the visible presentation.
76
73
  bool started = false;
77
74
  // False until the first advance() translates the wall-clock start stamp
78
75
  // onto the choreographer frame-time axis (elapsed-preserving). Every
79
- // startedAtS stamp clears it so a re-latched resume re-anchors too — except
80
- // a start that carried the JS-captured Reanimated stamp, which arrives
76
+ // A start that carried the JS-captured Reanimated stamp arrives
81
77
  // pre-anchored (see startAnimation) and must not be min()'d again.
82
78
  bool frameClockAnchored = false;
83
79
  };
84
80
 
85
81
  // Per-view fanout state. Density and host metrics are pushed from Kotlin at
86
- // register time and on size/density changes; density 0 (not pushed yet) falls
87
- // back to DIP delivery through setClipPresentationDip.
82
+ // register time and on size/density changes; density 0 falls back to DIP
83
+ // delivery until physical host metrics arrive.
88
84
  struct ViewEntry {
89
85
  global_ref<JSmoothClipView> view;
90
86
  double density = 0;
@@ -104,6 +100,8 @@ struct DriverState {
104
100
  // replay, hosts mounted in another subtree). The entry is erased when the
105
101
  // last view leaves and revived by a take-ownership setPresentation.
106
102
  bool destroyed = false;
103
+ // Non-zero while this driver is an immutable member of one active group.
104
+ int32_t groupId = 0;
107
105
 
108
106
  // 'inherit' velocity samples; recording/coalescing/projection live in the
109
107
  // shared cpp/SmoothClipVelocityTracker.h (behavior-paired with iOS).
@@ -115,6 +113,34 @@ struct CompletionSink {
115
113
  CompletionCallback callback;
116
114
  };
117
115
 
116
+ struct GroupCompletionSink {
117
+ const void *owner = nullptr;
118
+ GroupCompletionCallback callback;
119
+ };
120
+
121
+ struct GroupMemberAnimation {
122
+ uint64_t driverId = 0;
123
+ ActiveAnimation animation;
124
+ };
125
+
126
+ struct ActiveGroup {
127
+ uint64_t controllerId = 0;
128
+ int32_t id = 0;
129
+ int32_t completionTag = 0;
130
+ AnimationKind kind = AnimationKind::Timing;
131
+ TimingAnimation timing{};
132
+ SpringAnimation spring{};
133
+ ScalarSpringState scalarSpring{0, 0};
134
+ double durationS = 0;
135
+ double startedAtS = 0;
136
+ double lastFrameS = 0;
137
+ bool started = false;
138
+ bool frameClockAnchored = false;
139
+ std::vector<GroupMemberAnimation> members;
140
+ };
141
+
142
+ bool anyDisplayableView(const DriverState &state);
143
+
118
144
  std::unordered_map<uint64_t, DriverState> &registry() {
119
145
  static std::unordered_map<uint64_t, DriverState> value;
120
146
  return value;
@@ -125,6 +151,21 @@ CompletionSink &completionSink() {
125
151
  return sink;
126
152
  }
127
153
 
154
+ GroupCompletionSink &groupCompletionSink() {
155
+ static GroupCompletionSink sink;
156
+ return sink;
157
+ }
158
+
159
+ std::unordered_map<int32_t, ActiveGroup> &groupRegistry() {
160
+ static std::unordered_map<int32_t, ActiveGroup> value;
161
+ return value;
162
+ }
163
+
164
+ std::vector<int32_t> &animatingGroups() {
165
+ static std::vector<int32_t> value;
166
+ return value;
167
+ }
168
+
128
169
  // The sink is written from the JS thread (installBindings / invalidate) and
129
170
  // invoked on main. Invoking under the lock also keeps the owning bindings
130
171
  // state alive for the duration of the callback.
@@ -213,9 +254,16 @@ void scheduleFrame() {
213
254
 
214
255
  // --- Math (ported from SmoothClipRegistry.kt so both platforms match) -----
215
256
  //
216
- // clamp01, toChannels/fromChannels, interpolate, the frame-clock anchor and the
217
- // keyframe curve now live in cpp/SmoothClipAnimationCurve.h so a test binary can
218
- // reach them without linking fbjni; ios/tests pins their behavior.
257
+ // clamp01, toChannels/fromChannels, interpolate, and the frame-clock anchor
258
+ // live in cpp/SmoothClipAnimationCurve.h so a test binary can reach them
259
+ // without linking fbjni; ios/tests pins their behavior.
260
+
261
+ std::array<double, 11> velocityChannels(const Presentation &presentation) {
262
+ const Channels channels = toChannels(presentation);
263
+ std::array<double, 11> result{};
264
+ std::copy_n(channels.begin(), result.size(), result.begin());
265
+ return result;
266
+ }
219
267
 
220
268
  // Springs settle below these per-channel thresholds (DIP and DIP/s).
221
269
  constexpr double kSpringSettleDisplacement = 0.05;
@@ -230,7 +278,9 @@ constexpr double kSpringMaxDurationS = 10.0;
230
278
  // stiffness/mass = 200k. Termination is settle-based: every channel must be
231
279
  // near its target and nearly stationary at the same time.
232
280
  bool advanceSpring(ActiveAnimation &animation, double now) {
233
- const auto target = toChannels(animation.target);
281
+ const auto normalizedEndpoints = normalizeShadowEndpoints(
282
+ animation.start, animation.target);
283
+ const auto target = toChannels(normalizedEndpoints.second);
234
284
  double dt = now - animation.lastFrameS;
235
285
  animation.lastFrameS = now;
236
286
  if (dt < 0) dt = 0;
@@ -242,7 +292,7 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
242
292
  while (dt > 0) {
243
293
  const double step = std::min(dt, kMaxStepS);
244
294
  dt -= step;
245
- for (int index = 0; index < 7; index += 1) {
295
+ for (std::size_t index = 0; index < animation.springChannelCount; index += 1) {
246
296
  const double displacement =
247
297
  animation.springPosition[index] - target[index];
248
298
  const double acceleration =
@@ -254,7 +304,7 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
254
304
  }
255
305
  }
256
306
  bool settled = true;
257
- for (int index = 0; index < 7; index += 1) {
307
+ for (std::size_t index = 0; index < animation.springChannelCount; index += 1) {
258
308
  if (!std::isfinite(animation.springPosition[index]) ||
259
309
  !std::isfinite(animation.springVelocity[index])) {
260
310
  // A divergent configuration must never freeze the clip: end at target.
@@ -269,7 +319,14 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
269
319
  settled = false;
270
320
  }
271
321
  }
272
- animation.current = fromChannels(animation.springPosition);
322
+ animation.current = fromChannels(
323
+ animation.springPosition,
324
+ animation.start.clip.curve,
325
+ animation.start.shadow.enabled || animation.target.shadow.enabled);
326
+ if (!canonicalizePresentation(animation.current)) {
327
+ animation.current = animation.target;
328
+ return true;
329
+ }
273
330
  return settled || now - animation.startedAtS >= kSpringMaxDurationS;
274
331
  }
275
332
 
@@ -289,39 +346,109 @@ bool shouldReduceMotion(int32_t setting) {
289
346
 
290
347
  // --- State helpers --------------------------------------------------------
291
348
 
292
- void emitCompletion(uint64_t driverId, int32_t animationId, bool finished) {
349
+ void emitCompletion(
350
+ uint64_t driverId,
351
+ int32_t animationId,
352
+ int32_t completionTag,
353
+ bool finished) {
293
354
  std::lock_guard<std::mutex> lock(completionSinkMutex());
294
355
  if (completionSink().callback) {
295
- completionSink().callback(driverId, animationId, finished);
356
+ completionSink().callback(
357
+ driverId, animationId, completionTag, finished);
296
358
  }
297
359
  }
298
360
 
299
- // Terminal per-frame delivery: scale DIP -> px and normalize against the
300
- // pushed host metrics in C++, so the JNI call carries final pixel floats and
301
- // the Kotlin side reduces to field stores + invalidateOutline().
361
+ void emitGroupCompletion(const ActiveGroup &group, bool finished) {
362
+ std::vector<DriverSnapshot> snapshots;
363
+ snapshots.reserve(group.members.size());
364
+ for (const GroupMemberAnimation &member : group.members) {
365
+ const auto iterator = registry().find(member.driverId);
366
+ if (iterator == registry().end() || iterator->second.destroyed) {
367
+ snapshots.push_back(
368
+ {member.driverId, unavailablePresentation(), false});
369
+ } else {
370
+ snapshots.push_back({
371
+ member.driverId,
372
+ iterator->second.latest,
373
+ anyDisplayableView(iterator->second),
374
+ });
375
+ }
376
+ }
377
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
378
+ if (groupCompletionSink().callback) {
379
+ groupCompletionSink().callback(
380
+ group.controllerId,
381
+ group.id,
382
+ group.completionTag,
383
+ finished,
384
+ std::move(snapshots));
385
+ }
386
+ }
387
+
388
+ const GroupMemberAnimation *findGroupMember(
389
+ int32_t groupId,
390
+ uint64_t driverId) {
391
+ const auto groupIterator = groupRegistry().find(groupId);
392
+ if (groupIterator == groupRegistry().end()) return nullptr;
393
+ for (const GroupMemberAnimation &member : groupIterator->second.members) {
394
+ if (member.driverId == driverId) return &member;
395
+ }
396
+ return nullptr;
397
+ }
398
+
399
+ Presentation visiblePresentation(
400
+ uint64_t driverId,
401
+ const DriverState &state) {
402
+ if (state.groupId != 0) {
403
+ const GroupMemberAnimation *member =
404
+ findGroupMember(state.groupId, driverId);
405
+ if (member != nullptr) return member->animation.current;
406
+ }
407
+ return state.animation.has_value() ? state.animation->current : state.latest;
408
+ }
409
+
410
+ // Terminal per-frame delivery: scale canonical DIP geometry to pixels without
411
+ // consulting host metrics. Each SmoothClipView applies the fixed viewport crop.
302
412
  void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
303
413
  if (entry.density <= 0) {
304
414
  entry.view->applyClip(presentation);
305
415
  return;
306
416
  }
307
417
  const double density = entry.density;
308
- NormalizedClip clip;
309
- if (!SmoothClipNormalize(
418
+ CanonicalClip clip;
419
+ if (!SmoothClipCanonicalize(
310
420
  presentation.clip.x * density,
311
421
  presentation.clip.y * density,
312
422
  presentation.clip.width * density,
313
423
  presentation.clip.height * density,
314
424
  presentation.clip.radius * density,
315
- entry.hostWidthPx,
316
- entry.hostHeightPx,
425
+ presentation.clip.topLeftRadius * density,
426
+ presentation.clip.topRightRadius * density,
427
+ presentation.clip.bottomRightRadius * density,
428
+ presentation.clip.bottomLeftRadius * density,
429
+ presentation.clip.curve,
317
430
  clip)) {
318
431
  return;
319
432
  }
320
433
  const double translateX = presentation.contentTranslateX * density;
321
434
  const double translateY = presentation.contentTranslateY * density;
322
435
  // Atomic reject, mirroring the Kotlin DIP path's all-or-nothing gate.
323
- if (!std::isfinite(translateX) || !std::isfinite(translateY)) return;
324
- entry.view->applyClipPx(clip, translateX, translateY);
436
+ if (!std::isfinite(translateX) || !std::isfinite(translateY) ||
437
+ !std::isfinite(presentation.contentScale) ||
438
+ presentation.contentScale <= 0) {
439
+ return;
440
+ }
441
+ Shadow shadowPx = presentation.shadow;
442
+ shadowPx.offsetX *= density;
443
+ shadowPx.offsetY *= density;
444
+ shadowPx.blurRadius *= density;
445
+ shadowPx.spreadDistance *= density;
446
+ entry.view->applyClipPx(
447
+ clip,
448
+ translateX,
449
+ translateY,
450
+ presentation.contentScale,
451
+ shadowPx);
325
452
  }
326
453
 
327
454
  void applyToViews(DriverState &state, const Presentation &presentation) {
@@ -330,6 +457,22 @@ void applyToViews(DriverState &state, const Presentation &presentation) {
330
457
  }
331
458
  }
332
459
 
460
+ void setAutonomousMotion(DriverState &state, bool active) {
461
+ for (const ViewEntry &entry : state.views) {
462
+ entry.view->setAutonomousMotion(active);
463
+ }
464
+ }
465
+
466
+ void applyAnimationFrameToViews(
467
+ DriverState &state,
468
+ const Presentation &presentation) {
469
+ for (const auto &entry : state.views) {
470
+ if (entry.participation == ViewParticipation::Active) {
471
+ deliverToView(entry, presentation);
472
+ }
473
+ }
474
+ }
475
+
333
476
  void applyPresentation(DriverState &state, const Presentation &presentation) {
334
477
  state.latest = presentation;
335
478
  state.hasLatest = true;
@@ -346,6 +489,7 @@ std::vector<uint64_t> &animatingDrivers() {
346
489
  // visits animating drivers. Removal is eager: a stale id surviving a driver
347
490
  // erase/recreate cycle would let one frame integrate the animation twice.
348
491
  void clearActiveAnimation(uint64_t driverId, DriverState &state) {
492
+ setAutonomousMotion(state, false);
349
493
  state.animation.reset();
350
494
  for (ViewEntry &entry : state.views) {
351
495
  entry.participation = ViewParticipation::Deferred;
@@ -358,8 +502,9 @@ void clearActiveAnimation(uint64_t driverId, DriverState &state) {
358
502
  void finishActive(uint64_t driverId, DriverState &state, bool finished) {
359
503
  if (!state.animation.has_value()) return;
360
504
  const int32_t animationId = state.animation->id;
505
+ const int32_t completionTag = state.animation->completionTag;
361
506
  clearActiveAnimation(driverId, state);
362
- emitCompletion(driverId, animationId, finished);
507
+ emitCompletion(driverId, animationId, completionTag, finished);
363
508
  }
364
509
 
365
510
  Presentation prepareAnimation(
@@ -369,8 +514,10 @@ Presentation prepareAnimation(
369
514
  Presentation target) {
370
515
  const Presentation visibleBefore =
371
516
  state.animation.has_value() ? state.animation->current : state.latest;
372
- const bool acceptsInteractiveStart =
373
- start.hasInteractiveStart && state.ownership == Ownership::Interactive;
517
+ // `from` is an explicit part of the replacement transaction and therefore
518
+ // overrides the sampled native frame even when the previous owner was a
519
+ // native animation/group.
520
+ const bool acceptsInteractiveStart = start.hasInteractiveStart;
374
521
  finishActive(driverId, state, false);
375
522
  const Presentation resolvedStart =
376
523
  acceptsInteractiveStart ? start.interactiveStart : visibleBefore;
@@ -399,16 +546,126 @@ bool anyDisplayableView(const DriverState &state) {
399
546
  return false;
400
547
  }
401
548
 
402
- // Starts a latched animation: rebases the clock so no progress was burned
403
- // while no view could display, then joins the choreographer loop.
404
- void startLatchedAnimation(uint64_t driverId, DriverState &state) {
549
+ bool allGroupMembersReady(const ActiveGroup &group) {
550
+ for (const GroupMemberAnimation &member : group.members) {
551
+ const auto iterator = registry().find(member.driverId);
552
+ if (iterator == registry().end() || iterator->second.destroyed ||
553
+ !anyDisplayableView(iterator->second)) {
554
+ return false;
555
+ }
556
+ }
557
+ return true;
558
+ }
559
+
560
+ void removeAnimatingGroup(int32_t groupId) {
561
+ auto &active = animatingGroups();
562
+ active.erase(std::remove(active.begin(), active.end(), groupId), active.end());
563
+ }
564
+
565
+ void applyGroupCurrent(const ActiveGroup &group) {
566
+ // Group readiness is the participation contract. Each controller owns one
567
+ // host, and host loss removes the group before another frame can advance.
568
+ for (const GroupMemberAnimation &member : group.members) {
569
+ const auto iterator = registry().find(member.driverId);
570
+ if (iterator != registry().end()) {
571
+ applyToViews(iterator->second, member.animation.current);
572
+ }
573
+ }
574
+ }
575
+
576
+ void startGroupFrameLoop(ActiveGroup &group) {
577
+ assert(group.started);
578
+ assert(allGroupMembersReady(group));
579
+ for (const GroupMemberAnimation &member : group.members) {
580
+ auto iterator = registry().find(member.driverId);
581
+ if (iterator != registry().end()) {
582
+ setAutonomousMotion(iterator->second, true);
583
+ }
584
+ }
585
+ auto &active = animatingGroups();
586
+ if (std::find(active.begin(), active.end(), group.id) == active.end()) {
587
+ active.push_back(group.id);
588
+ }
589
+ applyGroupCurrent(group);
590
+ scheduleFrame();
591
+ }
592
+
593
+ std::vector<DriverSnapshot> finishGroupImpl(
594
+ int32_t groupId,
595
+ bool useTarget,
596
+ bool finished) {
597
+ auto groupIterator = groupRegistry().find(groupId);
598
+ if (groupIterator == groupRegistry().end()) return {};
599
+ ActiveGroup group = std::move(groupIterator->second);
600
+ groupRegistry().erase(groupIterator);
601
+ removeAnimatingGroup(groupId);
602
+
603
+ std::vector<DriverSnapshot> snapshots;
604
+ snapshots.reserve(group.members.size());
605
+ for (GroupMemberAnimation &member : group.members) {
606
+ auto stateIterator = registry().find(member.driverId);
607
+ if (stateIterator == registry().end()) {
608
+ snapshots.push_back({member.driverId, unavailablePresentation(), false});
609
+ continue;
610
+ }
611
+ DriverState &state = stateIterator->second;
612
+ const Presentation result =
613
+ useTarget ? member.animation.target : member.animation.current;
614
+ state.groupId = 0;
615
+ state.latest = result;
616
+ state.hasLatest = true;
617
+ state.ownership = Ownership::Interactive;
618
+ setAutonomousMotion(state, false);
619
+ applyToViews(state, result);
620
+ snapshots.push_back(
621
+ {member.driverId, result, !state.destroyed && anyDisplayableView(state)});
622
+ }
623
+ emitGroupCompletion(group, finished);
624
+ return snapshots;
625
+ }
626
+
627
+ void startPendingGroup(ActiveGroup &group) {
628
+ const double now = nowSeconds();
629
+ group.started = true;
630
+ group.startedAtS = now;
631
+ group.lastFrameS = now;
632
+ group.frameClockAnchored = false;
633
+ for (GroupMemberAnimation &member : group.members) {
634
+ member.animation.startedAtS = now;
635
+ member.animation.lastFrameS = now;
636
+ member.animation.frameClockAnchored = false;
637
+ }
638
+ startGroupFrameLoop(group);
639
+ }
640
+
641
+ void reconcileGroupReadiness(uint64_t driverId) {
642
+ const auto stateIterator = registry().find(driverId);
643
+ if (stateIterator == registry().end() || stateIterator->second.groupId == 0) {
644
+ return;
645
+ }
646
+ const int32_t groupId = stateIterator->second.groupId;
647
+ auto groupIterator = groupRegistry().find(groupId);
648
+ if (groupIterator == groupRegistry().end()) {
649
+ stateIterator->second.groupId = 0;
650
+ return;
651
+ }
652
+ ActiveGroup &group = groupIterator->second;
653
+ const bool ready = allGroupMembersReady(group);
654
+ if (!group.started) {
655
+ if (ready) startPendingGroup(group);
656
+ return;
657
+ }
658
+ if (ready) return;
659
+ finishGroupImpl(groupId, false, false);
660
+ }
661
+
662
+ // Starts a pre-ready animation with its full duration, then joins the
663
+ // choreographer loop.
664
+ void startPendingAnimation(uint64_t driverId, DriverState &state) {
405
665
  auto &animation = *state.animation;
406
666
  animation.started = true;
407
667
  animation.startedAtS = nowSeconds();
408
668
  animation.lastFrameS = animation.startedAtS;
409
- // Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
410
- // ActiveAnimation in place, so a stale anchor would replay the fraction-0
411
- // first frame the anchor exists to remove.
412
669
  animation.frameClockAnchored = false;
413
670
  auto &active = animatingDrivers();
414
671
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
@@ -419,6 +676,7 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
419
676
  entry.participation = ViewParticipation::Active;
420
677
  }
421
678
  }
679
+ setAutonomousMotion(state, true);
422
680
  applyToViews(state, animation.start);
423
681
  scheduleFrame();
424
682
  }
@@ -434,16 +692,16 @@ int32_t startAnimation(
434
692
  // animation arrives pre-anchored and advance() must not min() it against
435
693
  // the dispatching frame, which for a CALLBACK_INPUT start is EARLIER than
436
694
  // the call and would re-open the intra-frame lead the hint closes. Without
437
- // a hint (NaN: latch-less legacy callers, tests, iOS ignoring the field)
695
+ // a hint (NaN: unstamped callers, tests, iOS ignoring the field)
438
696
  // this reduces exactly to the old nowSeconds() + min() anchor path.
439
697
  const double wallNow = nowSeconds();
440
698
  const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
441
699
  animation.startedAtS = stamp.startedAtS;
442
700
  animation.lastFrameS = stamp.startedAtS;
443
701
  animation.frameClockAnchored = stamp.frameClockAnchored;
444
- // current = start even while latched is load-bearing: cancelAnimation,
702
+ // current = start while pre-ready is load-bearing: cancelAnimation,
445
703
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
446
- // visible all read animation->current, giving a never-rendered latch
704
+ // visible all read animation->current, giving a never-rendered pending-run
447
705
  // freeze-at-start / replace-from-start semantics with no extra branches.
448
706
  animation.current = animation.start;
449
707
  animation.started = anyDisplayableView(state);
@@ -454,9 +712,9 @@ int32_t startAnimation(
454
712
  : ViewParticipation::Deferred;
455
713
  }
456
714
  if (!state.animation->started) {
457
- // Latch: no host view can display yet (animateTo raced the mount, or
458
- // every host is detached/unsized). The first displayable registration,
459
- // host-geometry push, or window attach rebases the clock, joins
715
+ // No host can display yet (animateTo raced the mount, or the host is
716
+ // detached/unsized). The first displayable registration, host-geometry
717
+ // push, or window attach starts the full clock, joins
460
718
  // animatingDrivers() and schedules the frame loop. Non-zero id is still
461
719
  // returned so the JS side does not treat this as rejection.
462
720
  return state.animation->id;
@@ -465,60 +723,23 @@ int32_t startAnimation(
465
723
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
466
724
  active.push_back(driverId);
467
725
  }
726
+ setAutonomousMotion(state, true);
468
727
  applyToViews(state, state.animation->start);
469
728
  scheduleFrame();
470
729
  return state.animation->id;
471
730
  }
472
731
 
473
- void relatchIfNoDisplayable(uint64_t driverId, DriverState &state) {
732
+ void finishIfHostUnavailable(uint64_t driverId, DriverState &state) {
474
733
  if (!state.animation.has_value() || !state.animation->started ||
475
734
  anyDisplayableView(state)) {
476
735
  return;
477
736
  }
478
- auto &animation = *state.animation;
479
- for (ViewEntry &entry : state.views) {
480
- if (entry.participation == ViewParticipation::Active) {
481
- entry.participation = ViewParticipation::Suspended;
482
- }
483
- }
484
-
485
- if (animation.kind == AnimationKind::Timing) {
486
- const TimingContinuation continuation = timingContinuationAtFrame(
487
- animation.timing,
488
- animation.start,
489
- animation.target,
490
- animation.lastFrameS,
491
- animation.startedAtS,
492
- animation.durationS);
493
- animation.current = continuation.start;
494
- animation.timing = continuation.animation;
495
- animation.durationS = continuation.animation.durationMs / 1000.0;
496
- } else if (animation.kind == AnimationKind::Keyframes) {
497
- const KeyframeContinuation continuation = keyframeContinuationAtFrame(
498
- animation.keyframes.frames(),
499
- animation.current,
500
- animation.target,
501
- animation.lastFrameS,
502
- animation.startedAtS,
503
- animation.durationS);
504
- animation.current = continuation.start;
505
- animation.keyframes.reset(continuation.frames);
506
- animation.durationS = continuation.durationMs / 1000.0;
507
- }
508
-
509
- if (state.destroyed ||
510
- (animation.kind != AnimationKind::Spring && animation.durationS <= 0)) {
511
- state.ownership = Ownership::Interactive;
512
- finishActive(driverId, state, false);
513
- return;
514
- }
515
-
516
- animation.start = animation.current;
517
- animation.started = false;
518
- animation.frameClockAnchored = false;
519
- auto &active = animatingDrivers();
520
- active.erase(
521
- std::remove(active.begin(), active.end(), driverId), active.end());
737
+ const Presentation target = state.animation->target;
738
+ state.latest = target;
739
+ state.hasLatest = true;
740
+ state.ownership = Ownership::Interactive;
741
+ applyToViews(state, target);
742
+ finishActive(driverId, state, true);
522
743
  }
523
744
 
524
745
  void reconcileViewDisplayability(
@@ -527,7 +748,7 @@ void reconcileViewDisplayability(
527
748
  ViewEntry &entry) {
528
749
  if (!state.animation.has_value()) return;
529
750
  if (!state.animation->started) {
530
- if (entryDisplayable(entry)) startLatchedAnimation(driverId, state);
751
+ if (entryDisplayable(entry)) startPendingAnimation(driverId, state);
531
752
  return;
532
753
  }
533
754
 
@@ -537,20 +758,20 @@ void reconcileViewDisplayability(
537
758
  deliverToView(entry, state.animation->current);
538
759
  }
539
760
  } else if (entry.participation == ViewParticipation::Active) {
540
- entry.participation = ViewParticipation::Suspended;
761
+ entry.participation = ViewParticipation::Deferred;
541
762
  }
542
- relatchIfNoDisplayable(driverId, state);
763
+ finishIfHostUnavailable(driverId, state);
543
764
  }
544
765
 
545
766
  void advance(uint64_t driverId, DriverState &state, double now) {
546
767
  ActiveAnimation &animation = *state.animation;
547
768
 
548
769
  if (!animation.frameClockAnchored) {
549
- // This is the stamp-less/latch fallback. Worklet-issued animations carry
770
+ // This is the unstamped/pre-ready fallback. Worklet-issued animations carry
550
771
  // Reanimated's exact `__frameTimestamp || _getAnimationTimestamp()` value
551
772
  // and arrive pre-anchored, so they skip this approximation — including the
552
773
  // CALLBACK_INPUT case where the current frame stamp predates the call.
553
- // Here startedAtS/lastFrameS hold nowSeconds() sampled at native latch
774
+ // Here startedAtS/lastFrameS hold nowSeconds() sampled at native
554
775
  // attach or by an older caller, while `now` is the frame's vsync stamp on
555
776
  // the same CLOCK_MONOTONIC timebase. Taking the earlier stamp prevents a
556
777
  // mid-frame native start from duplicating fraction zero.
@@ -574,44 +795,97 @@ void advance(uint64_t driverId, DriverState &state, double now) {
574
795
  if (animation.kind == AnimationKind::Spring) {
575
796
  done = advanceSpring(animation, now);
576
797
  } else {
577
- // This is the frame whose presentation is delivered below. Lifecycle
578
- // re-latch must trim from this timestamp, never from a later wall sample.
579
798
  animation.lastFrameS = now;
580
799
  const double fraction =
581
800
  timingFraction(now, animation.startedAtS, animation.durationS);
582
- if (animation.kind == AnimationKind::Keyframes) {
583
- animation.current = animation.keyframes.evaluate(fraction);
584
- } else {
585
- const double eased = cubicBezier(
586
- animation.timing.controlPoint1X,
587
- animation.timing.controlPoint1Y,
588
- animation.timing.controlPoint2X,
589
- animation.timing.controlPoint2Y,
590
- fraction);
591
- animation.current = interpolate(animation.start, animation.target, eased);
801
+ const double eased = cubicBezier(
802
+ animation.timing.controlPoint1X,
803
+ animation.timing.controlPoint1Y,
804
+ animation.timing.controlPoint2X,
805
+ animation.timing.controlPoint2Y,
806
+ fraction);
807
+ animation.current = interpolate(animation.start, animation.target, eased);
808
+ const bool canonical = canonicalizePresentation(animation.current);
809
+ if (!canonical) {
810
+ animation.current = animation.target;
592
811
  }
593
- done = fraction >= 1.0;
812
+ done = !canonical || fraction >= 1.0;
594
813
  }
595
814
  // The completion branch below fans out the exact target; applying the
596
815
  // integrated value too would double every JNI crossing on the final frame.
597
- if (!done) applyToViews(state, animation.current);
816
+ if (!done) applyAnimationFrameToViews(state, animation.current);
598
817
 
599
818
  if (done) {
600
819
  const int32_t animationId = animation.id;
601
- bool finished = animation.finished;
602
- for (const ViewEntry &entry : state.views) {
603
- if (entry.participation == ViewParticipation::Suspended) {
604
- finished = false;
605
- break;
606
- }
607
- }
820
+ const int32_t completionTag = animation.completionTag;
608
821
  const Presentation target = animation.target;
609
822
  clearActiveAnimation(driverId, state);
610
823
  state.latest = target;
611
824
  state.hasLatest = true;
612
825
  state.ownership = Ownership::Interactive;
613
826
  applyToViews(state, target);
614
- emitCompletion(driverId, animationId, finished);
827
+ emitCompletion(driverId, animationId, completionTag, true);
828
+ }
829
+ }
830
+
831
+ void advanceGroup(int32_t groupId, ActiveGroup &group, double now) {
832
+ if (!group.frameClockAnchored) {
833
+ group.startedAtS = anchorStartTime(group.startedAtS, now);
834
+ group.lastFrameS = group.startedAtS;
835
+ group.frameClockAnchored = true;
836
+ for (GroupMemberAnimation &member : group.members) {
837
+ member.animation.startedAtS = group.startedAtS;
838
+ member.animation.lastFrameS = group.startedAtS;
839
+ member.animation.frameClockAnchored = true;
840
+ }
841
+ }
842
+
843
+ bool done = false;
844
+ if (group.kind == AnimationKind::Spring) {
845
+ const double deltaTime = now - group.lastFrameS;
846
+ group.lastFrameS = now;
847
+ group.scalarSpring = advanceScalarSpring(
848
+ group.scalarSpring, group.spring, deltaTime);
849
+ done = now - group.startedAtS >= kSpringMaxDurationS ||
850
+ relativeSpringEnergy(group.scalarSpring, group.spring) <=
851
+ group.spring.energyThreshold;
852
+ for (GroupMemberAnimation &member : group.members) {
853
+ member.animation.current = interpolate(
854
+ member.animation.start,
855
+ member.animation.target,
856
+ done ? 1.0 : group.scalarSpring.position);
857
+ if (!canonicalizePresentation(member.animation.current)) {
858
+ member.animation.current = member.animation.target;
859
+ done = true;
860
+ }
861
+ }
862
+ } else {
863
+ group.lastFrameS = now;
864
+ const double fraction =
865
+ timingFraction(now, group.startedAtS, group.durationS);
866
+ const double eased = cubicBezier(
867
+ group.timing.controlPoint1X,
868
+ group.timing.controlPoint1Y,
869
+ group.timing.controlPoint2X,
870
+ group.timing.controlPoint2Y,
871
+ fraction);
872
+ bool canonical = true;
873
+ for (GroupMemberAnimation &member : group.members) {
874
+ member.animation.lastFrameS = now;
875
+ member.animation.current = interpolate(
876
+ member.animation.start, member.animation.target, eased);
877
+ if (!canonicalizePresentation(member.animation.current)) {
878
+ member.animation.current = member.animation.target;
879
+ canonical = false;
880
+ }
881
+ }
882
+ done = !canonical || fraction >= 1.0;
883
+ }
884
+
885
+ if (done) {
886
+ finishGroupImpl(groupId, true, true);
887
+ } else {
888
+ applyGroupCurrent(group);
615
889
  }
616
890
  }
617
891
 
@@ -634,7 +908,14 @@ void onFrameImpl(double now) {
634
908
  }
635
909
  advance(driverId, iterator->second, now);
636
910
  }
637
- if (!animatingDrivers().empty()) scheduleFrame();
911
+ static std::vector<int32_t> groupScratch;
912
+ groupScratch = animatingGroups();
913
+ for (const int32_t groupId : groupScratch) {
914
+ auto iterator = groupRegistry().find(groupId);
915
+ if (iterator == groupRegistry().end() || !iterator->second.started) continue;
916
+ advanceGroup(groupId, iterator->second, now);
917
+ }
918
+ if (!animatingDrivers().empty() || !animatingGroups().empty()) scheduleFrame();
638
919
  }
639
920
 
640
921
  } // namespace
@@ -673,6 +954,353 @@ void clearCompletionCallback(const void *owner) {
673
954
  completionSink().callback = nullptr;
674
955
  }
675
956
 
957
+ void setGroupCompletionCallback(
958
+ const void *owner,
959
+ GroupCompletionCallback callback) {
960
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
961
+ groupCompletionSink().owner = owner;
962
+ groupCompletionSink().callback = std::move(callback);
963
+ }
964
+
965
+ void clearGroupCompletionCallback(const void *owner) {
966
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
967
+ if (groupCompletionSink().owner != owner) return;
968
+ groupCompletionSink().owner = nullptr;
969
+ groupCompletionSink().callback = nullptr;
970
+ }
971
+
972
+ Presentation snapshotCurrent(uint64_t driverId) {
973
+ if (!isOnMainThread()) return unavailablePresentation();
974
+ const auto iterator = registry().find(driverId);
975
+ if (iterator == registry().end() || iterator->second.destroyed ||
976
+ !iterator->second.hasLatest) {
977
+ return unavailablePresentation();
978
+ }
979
+ return visiblePresentation(driverId, iterator->second);
980
+ }
981
+
982
+ Presentation snapshotCurrentAndroid(uint64_t driverId) {
983
+ return snapshotCurrent(driverId);
984
+ }
985
+
986
+ std::vector<DriverSnapshot> snapshotGroup(
987
+ const std::vector<uint64_t> &driverIds) {
988
+ std::vector<DriverSnapshot> snapshots;
989
+ snapshots.reserve(driverIds.size());
990
+ if (!isOnMainThread()) return snapshots;
991
+ for (const uint64_t driverId : driverIds) {
992
+ const auto iterator = registry().find(driverId);
993
+ if (iterator == registry().end() || iterator->second.destroyed ||
994
+ !iterator->second.hasLatest) {
995
+ snapshots.push_back({driverId, unavailablePresentation(), false});
996
+ continue;
997
+ }
998
+ const DriverState &state = iterator->second;
999
+ snapshots.push_back(
1000
+ {driverId, visiblePresentation(driverId, state),
1001
+ anyDisplayableView(state)});
1002
+ }
1003
+ return snapshots;
1004
+ }
1005
+
1006
+ std::vector<DriverSnapshot> beginGroupInteraction(
1007
+ const std::vector<uint64_t> &driverIds) {
1008
+ if (!isOnMainThread()) return {};
1009
+ std::vector<uint64_t> unique;
1010
+ unique.reserve(driverIds.size());
1011
+ for (const uint64_t driverId : driverIds) {
1012
+ if (driverId == 0 ||
1013
+ std::find(unique.begin(), unique.end(), driverId) != unique.end()) {
1014
+ return {};
1015
+ }
1016
+ const auto iterator = registry().find(driverId);
1017
+ if (iterator == registry().end() || iterator->second.destroyed ||
1018
+ !iterator->second.hasLatest) {
1019
+ return snapshotGroup(driverIds);
1020
+ }
1021
+ unique.push_back(driverId);
1022
+ }
1023
+
1024
+ std::vector<int32_t> groupsToCancel;
1025
+ for (const uint64_t driverId : driverIds) {
1026
+ const int32_t groupId = registry().at(driverId).groupId;
1027
+ if (groupId != 0 &&
1028
+ std::find(groupsToCancel.begin(), groupsToCancel.end(), groupId) ==
1029
+ groupsToCancel.end()) {
1030
+ groupsToCancel.push_back(groupId);
1031
+ }
1032
+ }
1033
+ for (const int32_t groupId : groupsToCancel) {
1034
+ finishGroupImpl(groupId, false, false);
1035
+ }
1036
+ for (const uint64_t driverId : driverIds) {
1037
+ DriverState &state = registry().at(driverId);
1038
+ if (state.animation.has_value()) {
1039
+ const int32_t animationId = state.animation->id;
1040
+ const int32_t completionTag = state.animation->completionTag;
1041
+ const Presentation current = state.animation->current;
1042
+ clearActiveAnimation(driverId, state);
1043
+ state.latest = current;
1044
+ state.hasLatest = true;
1045
+ applyToViews(state, current);
1046
+ emitCompletion(driverId, animationId, completionTag, false);
1047
+ }
1048
+ state.ownership = Ownership::Interactive;
1049
+ }
1050
+ return snapshotGroup(driverIds);
1051
+ }
1052
+
1053
+ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1054
+ if (!isOnMainThread() || entries.empty()) return false;
1055
+ std::vector<BatchEntry> canonicalEntries = entries;
1056
+ for (BatchEntry &entry : canonicalEntries) {
1057
+ if (!canonicalizePresentation(entry.presentation)) return false;
1058
+ }
1059
+ std::vector<uint64_t> unique;
1060
+ std::vector<int32_t> groupsToCancel;
1061
+ unique.reserve(entries.size());
1062
+ for (const BatchEntry &entry : canonicalEntries) {
1063
+ if (entry.driverId == 0 ||
1064
+ std::find(unique.begin(), unique.end(), entry.driverId) != unique.end()) {
1065
+ return false;
1066
+ }
1067
+ const auto iterator = registry().find(entry.driverId);
1068
+ if (iterator == registry().end() || iterator->second.destroyed ||
1069
+ !isFinitePresentation(entry.presentation)) {
1070
+ return false;
1071
+ }
1072
+ if (iterator != registry().end() && iterator->second.groupId != 0 &&
1073
+ std::find(
1074
+ groupsToCancel.begin(),
1075
+ groupsToCancel.end(),
1076
+ iterator->second.groupId) == groupsToCancel.end()) {
1077
+ groupsToCancel.push_back(iterator->second.groupId);
1078
+ }
1079
+ unique.push_back(entry.driverId);
1080
+ }
1081
+
1082
+ // Replacement is all-or-nothing: validation above is deliberately complete
1083
+ // before any old group is dissolved. An overlap dissolves the whole immutable
1084
+ // old group (including members absent from this batch) exactly once.
1085
+ for (const int32_t groupId : groupsToCancel) {
1086
+ finishGroupImpl(groupId, false, false);
1087
+ }
1088
+
1089
+ const double now = nowSeconds();
1090
+ for (const BatchEntry &entry : canonicalEntries) {
1091
+ DriverState &state = registry().at(entry.driverId);
1092
+ state.destroyed = false;
1093
+ finishActive(entry.driverId, state, false);
1094
+ state.latest = entry.presentation;
1095
+ state.hasLatest = true;
1096
+ state.ownership = Ownership::Interactive;
1097
+ recordVelocitySample(
1098
+ state.samples, velocityChannels(entry.presentation), now);
1099
+ }
1100
+ for (const BatchEntry &entry : canonicalEntries) {
1101
+ applyToViews(registry().at(entry.driverId), entry.presentation);
1102
+ }
1103
+ return true;
1104
+ }
1105
+
1106
+ namespace {
1107
+
1108
+ int32_t startGroupCommon(
1109
+ uint64_t controllerId,
1110
+ std::vector<GroupMotionEntry> entries,
1111
+ AnimationKind kind,
1112
+ TimingAnimation timing,
1113
+ SpringAnimation spring,
1114
+ double durationMs,
1115
+ int32_t reduceMotion,
1116
+ int32_t completionTag,
1117
+ double startedAtHintS) {
1118
+ if (!isOnMainThread() || controllerId == 0 || entries.empty()) {
1119
+ return 0;
1120
+ }
1121
+ for (GroupMotionEntry &entry : entries) {
1122
+ if ((entry.hasFrom && !canonicalizePresentation(entry.from)) ||
1123
+ !canonicalizePresentation(entry.target)) {
1124
+ return 0;
1125
+ }
1126
+ }
1127
+ if (kind == AnimationKind::Timing &&
1128
+ !isValidTiming(timing)) {
1129
+ return 0;
1130
+ }
1131
+ if (kind == AnimationKind::Spring &&
1132
+ (!isValidSpring(spring) || spring.inheritVelocity)) {
1133
+ return 0;
1134
+ }
1135
+ const bool reduce = shouldReduceMotion(reduceMotion) ||
1136
+ (kind != AnimationKind::Spring && durationMs <= 0);
1137
+ std::vector<uint64_t> unique;
1138
+ std::vector<int32_t> groupsToCancel;
1139
+ std::vector<Presentation> resolvedStarts;
1140
+ unique.reserve(entries.size());
1141
+ resolvedStarts.reserve(entries.size());
1142
+ for (const GroupMotionEntry &entry : entries) {
1143
+ if (entry.driverId == 0 ||
1144
+ std::find(unique.begin(), unique.end(), entry.driverId) != unique.end()) {
1145
+ return 0;
1146
+ }
1147
+ const auto iterator = registry().find(entry.driverId);
1148
+ if ((iterator == registry().end() || iterator->second.destroyed ||
1149
+ !iterator->second.hasLatest) && !entry.hasFrom) {
1150
+ return 0;
1151
+ }
1152
+ if (iterator != registry().end() && iterator->second.groupId != 0 &&
1153
+ std::find(
1154
+ groupsToCancel.begin(),
1155
+ groupsToCancel.end(),
1156
+ iterator->second.groupId) == groupsToCancel.end()) {
1157
+ groupsToCancel.push_back(iterator->second.groupId);
1158
+ }
1159
+ const Presentation resolvedStart = entry.hasFrom
1160
+ ? entry.from
1161
+ : visiblePresentation(entry.driverId, iterator->second);
1162
+ if (!isFinitePresentation(resolvedStart) ||
1163
+ !isFinitePresentation(entry.target) ||
1164
+ !isAutonomousUniformCircular(resolvedStart) ||
1165
+ !isAutonomousUniformCircular(entry.target)) {
1166
+ return 0;
1167
+ }
1168
+ if (kind == AnimationKind::Spring) {
1169
+ const double velocity = spring.initialVelocity;
1170
+ if (!springScaleStaysPositive(
1171
+ resolvedStart, entry.target, spring, velocity)) {
1172
+ return 0;
1173
+ }
1174
+ }
1175
+ resolvedStarts.push_back(resolvedStart);
1176
+ unique.push_back(entry.driverId);
1177
+ }
1178
+
1179
+ // Capture every implicit start above, while overlapped groups still own
1180
+ // their current frames. Only after full preflight may replacement dissolve
1181
+ // those immutable groups and emit one unfinished completion for each.
1182
+ for (const int32_t groupId : groupsToCancel) {
1183
+ finishGroupImpl(groupId, false, false);
1184
+ }
1185
+
1186
+ ActiveGroup group;
1187
+ group.controllerId = controllerId;
1188
+ group.id = allocateAnimationId();
1189
+ group.completionTag = completionTag;
1190
+ group.kind = kind;
1191
+ group.timing = timing;
1192
+ group.spring = spring;
1193
+ group.scalarSpring = {0, spring.initialVelocity};
1194
+ group.durationS = durationMs / 1000.0;
1195
+ group.members.reserve(entries.size());
1196
+ const double wallNow = nowSeconds();
1197
+
1198
+ for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1199
+ GroupMotionEntry &entry = entries[entryIndex];
1200
+ DriverState &state = registry()[entry.driverId];
1201
+ const Presentation resolvedStart = resolvedStarts[entryIndex];
1202
+ finishActive(entry.driverId, state, false);
1203
+ state.destroyed = false;
1204
+ state.latest = entry.target;
1205
+ state.hasLatest = true;
1206
+ state.ownership = Ownership::Native;
1207
+ state.groupId = group.id;
1208
+
1209
+ GroupMemberAnimation member;
1210
+ member.driverId = entry.driverId;
1211
+ member.animation.id = group.id;
1212
+ member.animation.kind = kind;
1213
+ member.animation.start = resolvedStart;
1214
+ member.animation.target = entry.target;
1215
+ member.animation.current = resolvedStart;
1216
+ member.animation.timing = timing;
1217
+ member.animation.spring = spring;
1218
+ member.animation.durationS = group.durationS;
1219
+ group.members.push_back(std::move(member));
1220
+ }
1221
+
1222
+ // Apply every explicit start after the whole participant set has passed
1223
+ // preflight and acquired the new immutable group id. This remains one
1224
+ // synchronous main-thread transaction even when the initial readiness
1225
+ // barrier is pending; ready hosts must not keep displaying their old frame
1226
+ // while snapshots already report `from`.
1227
+ for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1228
+ if (!entries[entryIndex].hasFrom) continue;
1229
+ applyToViews(
1230
+ registry().at(entries[entryIndex].driverId),
1231
+ resolvedStarts[entryIndex]);
1232
+ }
1233
+
1234
+ const int32_t groupId = group.id;
1235
+ const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
1236
+ group.startedAtS = stamp.startedAtS;
1237
+ group.lastFrameS = stamp.startedAtS;
1238
+ group.frameClockAnchored = stamp.frameClockAnchored;
1239
+ for (GroupMemberAnimation &member : group.members) {
1240
+ member.animation.startedAtS = stamp.startedAtS;
1241
+ member.animation.lastFrameS = stamp.startedAtS;
1242
+ member.animation.frameClockAnchored = stamp.frameClockAnchored;
1243
+ }
1244
+ group.started = allGroupMembersReady(group);
1245
+ groupRegistry().emplace(groupId, std::move(group));
1246
+ if (reduce) {
1247
+ finishGroupImpl(groupId, true, true);
1248
+ return groupId;
1249
+ }
1250
+ ActiveGroup &stored = groupRegistry().at(groupId);
1251
+ if (stored.started) {
1252
+ startGroupFrameLoop(stored);
1253
+ }
1254
+ return groupId;
1255
+ }
1256
+
1257
+ } // namespace
1258
+
1259
+ int32_t animateTimingGroup(
1260
+ uint64_t controllerId,
1261
+ std::vector<GroupMotionEntry> entries,
1262
+ TimingAnimation animation,
1263
+ int32_t completionTag,
1264
+ double startedAtHintS) {
1265
+ return startGroupCommon(
1266
+ controllerId,
1267
+ std::move(entries),
1268
+ AnimationKind::Timing,
1269
+ animation,
1270
+ {},
1271
+ animation.durationMs,
1272
+ animation.reduceMotion,
1273
+ completionTag,
1274
+ startedAtHintS);
1275
+ }
1276
+
1277
+ int32_t animateSpringGroup(
1278
+ uint64_t controllerId,
1279
+ std::vector<GroupMotionEntry> entries,
1280
+ SpringAnimation animation,
1281
+ int32_t completionTag,
1282
+ double startedAtHintS) {
1283
+ return startGroupCommon(
1284
+ controllerId,
1285
+ std::move(entries),
1286
+ AnimationKind::Spring,
1287
+ {},
1288
+ animation,
1289
+ kSpringMaxDurationS * 1000.0,
1290
+ animation.reduceMotion,
1291
+ completionTag,
1292
+ startedAtHintS);
1293
+ }
1294
+
1295
+ std::vector<DriverSnapshot> cancelAnimationGroup(
1296
+ int32_t groupId,
1297
+ GroupCancelBehavior behavior) {
1298
+ if (!isOnMainThread()) return {};
1299
+ const bool finish = behavior == GroupCancelBehavior::Finish;
1300
+ return finishGroupImpl(
1301
+ groupId, finish, finish);
1302
+ }
1303
+
676
1304
  void setPresentation(
677
1305
  uint64_t driverId,
678
1306
  Presentation presentation,
@@ -680,6 +1308,7 @@ void setPresentation(
680
1308
  bool overridePendingAnimation,
681
1309
  bool recordVelocity) {
682
1310
  if (!isOnMainThread()) return;
1311
+ if (!canonicalizePresentation(presentation)) return;
683
1312
  SMOOTH_CLIP_TRACE("SmoothClip.setPresentation");
684
1313
  auto iterator = registry().find(driverId);
685
1314
  if (!takeOwnership) {
@@ -692,12 +1321,16 @@ void setPresentation(
692
1321
  iterator = registry().try_emplace(driverId).first;
693
1322
  }
694
1323
  auto &state = iterator->second;
1324
+ if (state.groupId != 0) {
1325
+ if (!takeOwnership) return;
1326
+ finishGroupImpl(state.groupId, false, false);
1327
+ }
695
1328
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
696
1329
  if (takeOwnership) {
697
1330
  state.destroyed = false;
698
1331
  // Passive seeds and public setters must not displace newer pending intent.
699
1332
  // The fused animation.from write is different: it is the caller's newest,
700
- // authoritative start and explicitly opts in to cancelling the old latch.
1333
+ // authoritative start and explicitly cancels the pending run.
701
1334
  if (state.animation.has_value() && !state.animation->started &&
702
1335
  !overridePendingAnimation) {
703
1336
  return;
@@ -708,7 +1341,8 @@ void setPresentation(
708
1341
  state.hasLatest = true;
709
1342
  state.ownership = Ownership::Interactive;
710
1343
  if (recordVelocity) {
711
- recordVelocitySample(state.samples, toChannels(presentation), nowSeconds());
1344
+ recordVelocitySample(
1345
+ state.samples, velocityChannels(presentation), nowSeconds());
712
1346
  } else if (state.samples.hasLatest) {
713
1347
  // Untracked movement invalidates the recorded velocity pair.
714
1348
  clearVelocitySamples(state.samples);
@@ -721,12 +1355,16 @@ Presentation beginInteraction(uint64_t driverId) {
721
1355
  auto iterator = registry().find(driverId);
722
1356
  if (iterator == registry().end()) return unavailablePresentation();
723
1357
  auto &state = iterator->second;
1358
+ if (state.groupId != 0) {
1359
+ finishGroupImpl(state.groupId, false, false);
1360
+ }
724
1361
  state.destroyed = false;
725
1362
  if (!state.animation.has_value()) {
726
1363
  state.ownership = Ownership::Interactive;
727
1364
  return state.latest;
728
1365
  }
729
1366
  const int32_t animationId = state.animation->id;
1367
+ const int32_t completionTag = state.animation->completionTag;
730
1368
  const Presentation current = state.animation->current;
731
1369
  clearActiveAnimation(driverId, state);
732
1370
  state.latest = current;
@@ -735,7 +1373,7 @@ Presentation beginInteraction(uint64_t driverId) {
735
1373
  // The frozen frame came from native animation movement, not an interactive
736
1374
  // write. Do not let it become a later `initialVelocity: inherit` sample.
737
1375
  applyToViews(state, current);
738
- emitCompletion(driverId, animationId, false);
1376
+ emitCompletion(driverId, animationId, completionTag, false);
739
1377
  return current;
740
1378
  }
741
1379
 
@@ -743,27 +1381,57 @@ int32_t animateTiming(
743
1381
  uint64_t driverId,
744
1382
  AnimationStart start,
745
1383
  Presentation presentation,
746
- TimingAnimation animation) {
747
- if (!isOnMainThread()) return 0;
1384
+ TimingAnimation animation,
1385
+ int32_t completionTag) {
1386
+ const bool validAnimation = isValidTiming(animation);
1387
+ if (!canonicalizePresentation(presentation) ||
1388
+ (start.hasInteractiveStart &&
1389
+ !canonicalizePresentation(start.interactiveStart))) {
1390
+ return 0;
1391
+ }
1392
+ if (!isOnMainThread() || driverId == 0 ||
1393
+ !isFinitePresentation(presentation) || !validAnimation) {
1394
+ return 0;
1395
+ }
748
1396
  auto iterator = registry().find(driverId);
1397
+ Presentation preflightStart;
1398
+ if (iterator == registry().end()) {
1399
+ if (!start.hasInteractiveStart ||
1400
+ !isFinitePresentation(start.interactiveStart)) {
1401
+ return 0;
1402
+ }
1403
+ preflightStart = start.interactiveStart;
1404
+ } else {
1405
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1406
+ preflightStart = start.hasInteractiveStart
1407
+ ? start.interactiveStart
1408
+ : visiblePresentation(driverId, iterator->second);
1409
+ }
1410
+ if (!isFinitePresentation(preflightStart) ||
1411
+ preflightStart.clip.curve != presentation.clip.curve) {
1412
+ return 0;
1413
+ }
1414
+ const bool reduce = shouldReduceMotion(animation.reduceMotion) ||
1415
+ animation.durationMs <= 0;
749
1416
  if (iterator == registry().end()) {
750
- if (!start.hasInteractiveStart) return 0;
751
1417
  iterator = registry().try_emplace(driverId).first;
752
1418
  iterator->second.latest = start.interactiveStart;
753
1419
  iterator->second.hasLatest = true;
754
1420
  }
755
1421
  auto &state = iterator->second;
1422
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
756
1423
  state.destroyed = false;
757
1424
  const int32_t animationId = allocateAnimationId();
758
1425
  const Presentation resolvedStart =
759
1426
  prepareAnimation(driverId, state, start, presentation);
760
- if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
1427
+ if (reduce) {
761
1428
  applyPresentation(state, presentation);
762
- emitCompletion(driverId, animationId, true);
1429
+ emitCompletion(driverId, animationId, completionTag, true);
763
1430
  return animationId;
764
1431
  }
765
1432
  ActiveAnimation active;
766
1433
  active.id = animationId;
1434
+ active.completionTag = completionTag;
767
1435
  active.kind = AnimationKind::Timing;
768
1436
  active.timing = animation;
769
1437
  active.start = resolvedStart;
@@ -776,33 +1444,67 @@ int32_t animateSpring(
776
1444
  uint64_t driverId,
777
1445
  AnimationStart start,
778
1446
  Presentation presentation,
779
- SpringAnimation animation) {
780
- if (!isOnMainThread()) return 0;
1447
+ SpringAnimation animation,
1448
+ int32_t completionTag) {
1449
+ const bool validAnimation = isValidSpring(animation);
1450
+ if (!canonicalizePresentation(presentation) ||
1451
+ (start.hasInteractiveStart &&
1452
+ !canonicalizePresentation(start.interactiveStart))) {
1453
+ return 0;
1454
+ }
1455
+ if (!isOnMainThread() || driverId == 0 ||
1456
+ !isFinitePresentation(presentation) || !validAnimation) {
1457
+ return 0;
1458
+ }
781
1459
  auto iterator = registry().find(driverId);
1460
+ Presentation preflightStart;
1461
+ if (iterator == registry().end()) {
1462
+ if (!start.hasInteractiveStart ||
1463
+ !isFinitePresentation(start.interactiveStart)) {
1464
+ return 0;
1465
+ }
1466
+ preflightStart = start.interactiveStart;
1467
+ } else {
1468
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1469
+ preflightStart = start.hasInteractiveStart
1470
+ ? start.interactiveStart
1471
+ : visiblePresentation(driverId, iterator->second);
1472
+ }
1473
+ const double velocity = animation.inheritVelocity &&
1474
+ iterator != registry().end()
1475
+ ? inheritedVelocity(
1476
+ iterator->second.samples,
1477
+ velocityChannels(presentation),
1478
+ nowSeconds())
1479
+ : animation.initialVelocity;
1480
+ if (!isFinitePresentation(preflightStart) ||
1481
+ preflightStart.clip.curve != presentation.clip.curve ||
1482
+ !springScaleStaysPositive(
1483
+ preflightStart, presentation, animation, velocity)) {
1484
+ return 0;
1485
+ }
782
1486
  if (iterator == registry().end()) {
783
- if (!start.hasInteractiveStart) return 0;
784
1487
  iterator = registry().try_emplace(driverId).first;
785
1488
  iterator->second.latest = start.interactiveStart;
786
1489
  iterator->second.hasLatest = true;
787
1490
  }
788
1491
  auto &state = iterator->second;
1492
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
789
1493
  state.destroyed = false;
790
1494
  const int32_t animationId = allocateAnimationId();
791
1495
  // Scalar velocity along the current-to-target trajectory; each channel is
792
1496
  // seeded with velocity·displacement to match the iOS CASpringAnimation
793
1497
  // per-keypath behavior.
794
- const double velocity = animation.inheritVelocity
795
- ? inheritedVelocity(state.samples, toChannels(presentation), nowSeconds())
796
- : animation.initialVelocity;
797
1498
  const Presentation resolvedStart =
798
1499
  prepareAnimation(driverId, state, start, presentation);
799
1500
  if (shouldReduceMotion(animation.reduceMotion)) {
800
1501
  applyPresentation(state, presentation);
801
- emitCompletion(driverId, animationId, true);
1502
+ emitCompletion(driverId, animationId, completionTag, true);
802
1503
  return animationId;
803
1504
  }
804
1505
  ActiveAnimation active;
805
1506
  active.id = animationId;
1507
+ active.completionTag = completionTag;
806
1508
  active.kind = AnimationKind::Spring;
807
1509
  active.spring = animation;
808
1510
  active.spring.initialVelocity = velocity;
@@ -810,58 +1512,31 @@ int32_t animateSpring(
810
1512
  active.start = resolvedStart;
811
1513
  active.target = presentation;
812
1514
  active.durationS = kSpringMaxDurationS;
813
- const auto startChannels = toChannels(resolvedStart);
814
- const auto targetChannels = toChannels(presentation);
815
- for (int index = 0; index < 7; index += 1) {
816
- active.springPosition[index] = startChannels[index];
1515
+ const auto normalizedEndpoints = normalizeShadowEndpoints(
1516
+ resolvedStart, presentation);
1517
+ const auto startChannels = toChannels(normalizedEndpoints.first);
1518
+ const auto targetChannels = toChannels(normalizedEndpoints.second);
1519
+ active.springChannelCount =
1520
+ (resolvedStart.shadow.enabled && resolvedStart.shadow.alpha > 0) ||
1521
+ (presentation.shadow.enabled && presentation.shadow.alpha > 0)
1522
+ ? kChannelCount
1523
+ : kBaseChannelCount;
1524
+ active.springPosition = startChannels;
1525
+ for (std::size_t index = 0;
1526
+ index < active.springChannelCount;
1527
+ index += 1) {
817
1528
  active.springVelocity[index] =
818
1529
  velocity * (targetChannels[index] - startChannels[index]);
819
1530
  }
820
1531
  return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
821
1532
  }
822
1533
 
823
- int32_t animateKeyframes(
824
- uint64_t driverId,
825
- AnimationStart start,
826
- Presentation presentation,
827
- double durationMs,
828
- std::vector<Keyframe> keyframes,
829
- int32_t reduceMotion) {
830
- if (!isOnMainThread()) return 0;
831
- auto iterator = registry().find(driverId);
832
- if (iterator == registry().end()) {
833
- if (!start.hasInteractiveStart) return 0;
834
- iterator = registry().try_emplace(driverId).first;
835
- iterator->second.latest = start.interactiveStart;
836
- iterator->second.hasLatest = true;
837
- }
838
- auto &state = iterator->second;
839
- state.destroyed = false;
840
- const int32_t animationId = allocateAnimationId();
841
- const Presentation resolvedStart =
842
- prepareAnimation(driverId, state, start, presentation);
843
- if (shouldReduceMotion(reduceMotion) || durationMs <= 0 ||
844
- keyframes.size() < 2) {
845
- applyPresentation(state, presentation);
846
- emitCompletion(driverId, animationId, true);
847
- return animationId;
848
- }
849
- ActiveAnimation active;
850
- active.id = animationId;
851
- active.kind = AnimationKind::Keyframes;
852
- active.keyframes.reset(std::move(keyframes));
853
- active.start = resolvedStart;
854
- active.target = presentation;
855
- active.durationS = durationMs / 1000.0;
856
- return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
857
- }
858
-
859
1534
  int32_t rejectAnimation(uint64_t driverId) {
860
1535
  if (!isOnMainThread()) return 0;
861
1536
  auto iterator = registry().find(driverId);
862
1537
  if (iterator == registry().end()) return 0;
863
1538
  const int32_t animationId = allocateAnimationId();
864
- emitCompletion(driverId, animationId, false);
1539
+ emitCompletion(driverId, animationId, 0, false);
865
1540
  return animationId;
866
1541
  }
867
1542
 
@@ -875,6 +1550,18 @@ CancelResult cancelAnimation(
875
1550
  return {false, {{0, 0, 0, 0, 0}, 0, 0}};
876
1551
  }
877
1552
  auto &state = iterator->second;
1553
+ if (state.groupId != 0) {
1554
+ const int32_t activeGroupId = state.groupId;
1555
+ if (animationId > 0 && animationId != activeGroupId) {
1556
+ return {false, visiblePresentation(driverId, state)};
1557
+ }
1558
+ const std::vector<DriverSnapshot> snapshots =
1559
+ finishGroupImpl(activeGroupId, useTarget, false);
1560
+ for (const DriverSnapshot &snapshot : snapshots) {
1561
+ if (snapshot.driverId == driverId) return {true, snapshot.presentation};
1562
+ }
1563
+ return {false, unavailablePresentation()};
1564
+ }
878
1565
  if (!state.animation.has_value() ||
879
1566
  (animationId > 0 && animationId != state.animation->id)) {
880
1567
  return {false, state.animation.has_value() ? state.animation->current
@@ -883,12 +1570,13 @@ CancelResult cancelAnimation(
883
1570
  const Presentation result =
884
1571
  useTarget ? state.animation->target : state.animation->current;
885
1572
  const int32_t activeId = state.animation->id;
1573
+ const int32_t completionTag = state.animation->completionTag;
886
1574
  clearActiveAnimation(driverId, state);
887
1575
  state.latest = result;
888
1576
  state.hasLatest = true;
889
1577
  state.ownership = Ownership::Interactive;
890
1578
  applyToViews(state, result);
891
- emitCompletion(driverId, activeId, false);
1579
+ emitCompletion(driverId, activeId, completionTag, false);
892
1580
  return {true, result};
893
1581
  }
894
1582
 
@@ -897,6 +1585,7 @@ void destroyDriver(uint64_t driverId) {
897
1585
  auto iterator = registry().find(driverId);
898
1586
  if (iterator == registry().end()) return;
899
1587
  auto &state = iterator->second;
1588
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
900
1589
  state.ownership = Ownership::Interactive;
901
1590
  finishActive(driverId, state, false);
902
1591
  // A destroyed driver's interaction history must not seed a revived
@@ -921,37 +1610,115 @@ void JSmoothClipView::applyClip(const Presentation &presentation) const {
921
1610
  static const auto method =
922
1611
  javaClassStatic()
923
1612
  ->getMethod<void(
924
- jdouble, jdouble, jdouble, jdouble, jdouble, jdouble, jdouble)>(
925
- "setClipPresentationDip");
1613
+ jdouble,
1614
+ jdouble,
1615
+ jdouble,
1616
+ jdouble,
1617
+ jdouble,
1618
+ jdouble,
1619
+ jdouble,
1620
+ jdouble,
1621
+ jint,
1622
+ jdouble,
1623
+ jdouble,
1624
+ jdouble,
1625
+ jboolean,
1626
+ jdouble,
1627
+ jdouble,
1628
+ jdouble,
1629
+ jdouble,
1630
+ jdouble,
1631
+ jdouble,
1632
+ jdouble,
1633
+ jdouble)>("setClipPresentationDip");
926
1634
  method(
927
1635
  self(),
928
1636
  presentation.clip.x,
929
1637
  presentation.clip.y,
930
1638
  presentation.clip.width,
931
1639
  presentation.clip.height,
932
- presentation.clip.radius,
1640
+ SmoothClipResolvedRadius(
1641
+ presentation.clip.topLeftRadius, presentation.clip.radius),
1642
+ SmoothClipResolvedRadius(
1643
+ presentation.clip.topRightRadius, presentation.clip.radius),
1644
+ SmoothClipResolvedRadius(
1645
+ presentation.clip.bottomRightRadius, presentation.clip.radius),
1646
+ SmoothClipResolvedRadius(
1647
+ presentation.clip.bottomLeftRadius, presentation.clip.radius),
1648
+ static_cast<jint>(presentation.clip.curve),
933
1649
  presentation.contentTranslateX,
934
- presentation.contentTranslateY);
1650
+ presentation.contentTranslateY,
1651
+ presentation.contentScale,
1652
+ presentation.shadow.enabled,
1653
+ presentation.shadow.red,
1654
+ presentation.shadow.green,
1655
+ presentation.shadow.blue,
1656
+ presentation.shadow.alpha,
1657
+ presentation.shadow.offsetX,
1658
+ presentation.shadow.offsetY,
1659
+ presentation.shadow.blurRadius,
1660
+ presentation.shadow.spreadDistance);
935
1661
  }
936
1662
 
937
1663
  void JSmoothClipView::applyClipPx(
938
- const NormalizedClip &clip,
1664
+ const CanonicalClip &clip,
939
1665
  double contentTranslateXPx,
940
- double contentTranslateYPx) const {
1666
+ double contentTranslateYPx,
1667
+ double contentScale,
1668
+ const Shadow &shadowPx) const {
941
1669
  static const auto method =
942
1670
  javaClassStatic()
943
1671
  ->getMethod<void(
944
- jfloat, jfloat, jfloat, jfloat, jfloat, jfloat, jfloat)>(
945
- "setClipPresentationPx");
1672
+ jfloat,
1673
+ jfloat,
1674
+ jfloat,
1675
+ jfloat,
1676
+ jfloat,
1677
+ jfloat,
1678
+ jfloat,
1679
+ jfloat,
1680
+ jint,
1681
+ jfloat,
1682
+ jfloat,
1683
+ jfloat,
1684
+ jboolean,
1685
+ jfloat,
1686
+ jfloat,
1687
+ jfloat,
1688
+ jfloat,
1689
+ jfloat,
1690
+ jfloat,
1691
+ jfloat,
1692
+ jfloat)>("setClipPresentationPx");
946
1693
  method(
947
1694
  self(),
948
1695
  static_cast<jfloat>(clip.left),
949
1696
  static_cast<jfloat>(clip.top),
950
1697
  static_cast<jfloat>(clip.right),
951
1698
  static_cast<jfloat>(clip.bottom),
952
- static_cast<jfloat>(clip.radius),
1699
+ static_cast<jfloat>(clip.topLeftRadius),
1700
+ static_cast<jfloat>(clip.topRightRadius),
1701
+ static_cast<jfloat>(clip.bottomRightRadius),
1702
+ static_cast<jfloat>(clip.bottomLeftRadius),
1703
+ static_cast<jint>(clip.curve),
953
1704
  static_cast<jfloat>(contentTranslateXPx),
954
- static_cast<jfloat>(contentTranslateYPx));
1705
+ static_cast<jfloat>(contentTranslateYPx),
1706
+ static_cast<jfloat>(contentScale),
1707
+ shadowPx.enabled,
1708
+ static_cast<jfloat>(shadowPx.red),
1709
+ static_cast<jfloat>(shadowPx.green),
1710
+ static_cast<jfloat>(shadowPx.blue),
1711
+ static_cast<jfloat>(shadowPx.alpha),
1712
+ static_cast<jfloat>(shadowPx.offsetX),
1713
+ static_cast<jfloat>(shadowPx.offsetY),
1714
+ static_cast<jfloat>(shadowPx.blurRadius),
1715
+ static_cast<jfloat>(shadowPx.spreadDistance));
1716
+ }
1717
+
1718
+ void JSmoothClipView::setAutonomousMotion(bool active) const {
1719
+ static const auto method =
1720
+ javaClassStatic()->getMethod<void(jboolean)>("setAutonomousMotion");
1721
+ method(self(), active);
955
1722
  }
956
1723
 
957
1724
  void registerViewAndroid(
@@ -962,15 +1729,14 @@ void registerViewAndroid(
962
1729
  double hostWidthPx,
963
1730
  double hostHeightPx,
964
1731
  bool lifecycleVisible) {
1732
+ if (!canonicalizePresentation(initialPresentation)) return;
965
1733
  auto &state = registry()[driverId];
966
1734
  state.destroyed = false;
967
1735
  if (!state.hasLatest) {
968
1736
  state.latest = initialPresentation;
969
1737
  state.hasLatest = true;
970
1738
  }
971
- const Presentation visible = state.animation.has_value()
972
- ? state.animation->current
973
- : state.latest;
1739
+ const Presentation visible = visiblePresentation(driverId, state);
974
1740
  JNIEnv *env = facebook::jni::Environment::current();
975
1741
  for (auto &existing : state.views) {
976
1742
  if (env->IsSameObject(existing.view.get(), view.get())) {
@@ -980,9 +1746,14 @@ void registerViewAndroid(
980
1746
  existing.lifecycleVisible = lifecycleVisible;
981
1747
  deliverToView(existing, visible);
982
1748
  reconcileViewDisplayability(driverId, state, existing);
1749
+ reconcileGroupReadiness(driverId);
983
1750
  return;
984
1751
  }
985
1752
  }
1753
+ // The public controller contract permits one simultaneous host. The React
1754
+ // wrapper reports this as a development error; native also refuses a second
1755
+ // host so release behavior cannot silently fan one controller out.
1756
+ if (!state.views.empty()) return;
986
1757
  ViewEntry entry{
987
1758
  facebook::jni::make_global(view),
988
1759
  density,
@@ -990,11 +1761,11 @@ void registerViewAndroid(
990
1761
  hostHeightPx,
991
1762
  lifecycleVisible,
992
1763
  ViewParticipation::Deferred};
993
- // `visible` above already delivered animation->current (= start) to the
994
- // registering view — the correct first frame for a latched animation.
1764
+ // `visible` already contains the start of a pre-ready animation.
995
1765
  deliverToView(entry, visible);
996
1766
  state.views.push_back(std::move(entry));
997
1767
  reconcileViewDisplayability(driverId, state, state.views.back());
1768
+ reconcileGroupReadiness(driverId);
998
1769
  }
999
1770
 
1000
1771
  void setViewHostGeometryAndroid(
@@ -1014,12 +1785,10 @@ void setViewHostGeometryAndroid(
1014
1785
  entry.hostWidthPx = hostWidthPx;
1015
1786
  entry.hostHeightPx = hostHeightPx;
1016
1787
  if (state.hasLatest) {
1017
- deliverToView(
1018
- entry,
1019
- state.animation.has_value() ? state.animation->current
1020
- : state.latest);
1788
+ deliverToView(entry, visiblePresentation(driverId, state));
1021
1789
  }
1022
1790
  reconcileViewDisplayability(driverId, state, entry);
1791
+ reconcileGroupReadiness(driverId);
1023
1792
  return;
1024
1793
  }
1025
1794
  }
@@ -1037,6 +1806,7 @@ void setViewLifecycleVisibilityAndroid(
1037
1806
  if (!env->IsSameObject(entry.view.get(), view.get())) continue;
1038
1807
  entry.lifecycleVisible = lifecycleVisible;
1039
1808
  reconcileViewDisplayability(driverId, state, entry);
1809
+ reconcileGroupReadiness(driverId);
1040
1810
  return;
1041
1811
  }
1042
1812
  }
@@ -1048,12 +1818,9 @@ void unregisterViewAndroid(
1048
1818
  if (iterator == registry().end()) return;
1049
1819
  auto &state = iterator->second;
1050
1820
  bool removed = false;
1051
- bool removedParticipant = false;
1052
1821
  JNIEnv *env = facebook::jni::Environment::current();
1053
1822
  for (auto view_it = state.views.begin(); view_it != state.views.end();) {
1054
1823
  if (env->IsSameObject(view_it->view.get(), view.get())) {
1055
- removedParticipant = removedParticipant ||
1056
- view_it->participation != ViewParticipation::Deferred;
1057
1824
  view_it = state.views.erase(view_it);
1058
1825
  removed = true;
1059
1826
  } else {
@@ -1061,10 +1828,6 @@ void unregisterViewAndroid(
1061
1828
  }
1062
1829
  }
1063
1830
  if (removed && state.animation.has_value()) {
1064
- // Only a host that actually joined (or is unresolved after suspension)
1065
- // affects completion. A detached/unsized registration that stayed deferred
1066
- // never installed or rendered this animation.
1067
- if (removedParticipant) state.animation->finished = false;
1068
1831
  if (!isOnMainThread()) {
1069
1832
  // Blast-radius reduction, NOT a synchronization fix — be clear about
1070
1833
  // which. Both call sites (SmoothClipViewManager.onAfterUpdateTransaction
@@ -1078,9 +1841,12 @@ void unregisterViewAndroid(
1078
1841
  // for the next main-thread event (attach, host geometry, destroy) to
1079
1842
  // resolve.
1080
1843
  } else {
1081
- relatchIfNoDisplayable(driverId, state);
1844
+ finishIfHostUnavailable(driverId, state);
1082
1845
  }
1083
1846
  }
1847
+ if (removed && state.groupId != 0 && isOnMainThread()) {
1848
+ reconcileGroupReadiness(driverId);
1849
+ }
1084
1850
  if (state.destroyed && state.views.empty()) {
1085
1851
  registry().erase(iterator);
1086
1852
  }