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

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 (199) hide show
  1. package/README.md +135 -386
  2. package/android/build.gradle +4 -7
  3. package/android/proguard-rules.pro +2 -2
  4. package/android/src/main/cpp/SmoothClipAndroid.h +9 -12
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +300 -704
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +348 -594
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +36 -227
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +16 -19
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +36 -294
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +369 -486
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +75 -77
  12. package/cpp/SmoothClipAnimationCurve.h +237 -346
  13. package/cpp/SmoothClipRegistry.h +24 -52
  14. package/cpp/SmoothClipSharedGeometry.h +64 -94
  15. package/cpp/SmoothClipVelocityTracker.h +5 -5
  16. package/ios/SmoothClipGeometry.h +23 -34
  17. package/ios/SmoothClipRegistry.mm +206 -711
  18. package/ios/SmoothClipTurboModule.cpp +217 -715
  19. package/ios/SmoothClipTurboModule.h +54 -297
  20. package/ios/SmoothClipView.mm +599 -505
  21. package/ios/SmoothClipViewRegistry.h +2 -7
  22. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  23. package/lib/module/SmoothClipView.ios.js +84 -17
  24. package/lib/module/SmoothClipView.ios.js.map +1 -1
  25. package/lib/module/SmoothClipView.js +1 -1
  26. package/lib/module/SmoothClipView.js.map +1 -1
  27. package/lib/module/SmoothClipViewNativeComponent.ts +20 -26
  28. package/lib/module/capabilities.native.js +1 -19
  29. package/lib/module/capabilities.native.js.map +1 -1
  30. package/lib/module/capabilityTypes.js +0 -9
  31. package/lib/module/capabilityTypes.js.map +1 -1
  32. package/lib/module/controllerInternals.js +28 -0
  33. package/lib/module/controllerInternals.js.map +1 -0
  34. package/lib/module/controllerLifecycle.js +9 -0
  35. package/lib/module/controllerLifecycle.js.map +1 -0
  36. package/lib/module/controllerTypes.js +4 -0
  37. package/lib/module/controllerTypes.js.map +1 -0
  38. package/lib/module/controllers.android.js +4 -0
  39. package/lib/module/controllers.android.js.map +1 -0
  40. package/lib/module/controllers.ios.js +4 -0
  41. package/lib/module/controllers.ios.js.map +1 -0
  42. package/lib/module/controllers.js +4 -0
  43. package/lib/module/controllers.js.map +1 -0
  44. package/lib/module/controllers.native.js +89 -0
  45. package/lib/module/controllers.native.js.map +1 -0
  46. package/lib/module/geometry.js +71 -51
  47. package/lib/module/geometry.js.map +1 -1
  48. package/lib/module/groupTypes.js +4 -0
  49. package/lib/module/{groupDriverTypes.js.map → groupTypes.js.map} +1 -1
  50. package/lib/module/groups.android.js +4 -0
  51. package/lib/module/groups.android.js.map +1 -0
  52. package/lib/module/groups.ios.js +4 -0
  53. package/lib/module/groups.ios.js.map +1 -0
  54. package/lib/module/groups.js +4 -0
  55. package/lib/module/groups.js.map +1 -0
  56. package/lib/module/groups.native.js +399 -0
  57. package/lib/module/groups.native.js.map +1 -0
  58. package/lib/module/ids.js +8 -0
  59. package/lib/module/ids.js.map +1 -0
  60. package/lib/module/index.js +3 -3
  61. package/lib/module/index.js.map +1 -1
  62. package/lib/module/nativeCompletion.js +37 -16
  63. package/lib/module/nativeCompletion.js.map +1 -1
  64. package/lib/module/presentationCodec.js +81 -0
  65. package/lib/module/presentationCodec.js.map +1 -0
  66. package/lib/typescript/src/NativeSmoothClipModule.d.ts +14 -23
  67. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  68. package/lib/typescript/src/SmoothClipView.d.ts +1 -1
  69. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  70. package/lib/typescript/src/SmoothClipView.ios.d.ts +18 -14
  71. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  72. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +10 -5
  73. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  74. package/lib/typescript/src/capabilities.native.d.ts +1 -1
  75. package/lib/typescript/src/capabilities.native.d.ts.map +1 -1
  76. package/lib/typescript/src/capabilityTypes.d.ts +0 -6
  77. package/lib/typescript/src/capabilityTypes.d.ts.map +1 -1
  78. package/lib/typescript/src/controllerInternals.d.ts +14 -0
  79. package/lib/typescript/src/controllerInternals.d.ts.map +1 -0
  80. package/lib/typescript/src/controllerLifecycle.d.ts +3 -0
  81. package/lib/typescript/src/controllerLifecycle.d.ts.map +1 -0
  82. package/lib/typescript/src/controllerTypes.d.ts +62 -0
  83. package/lib/typescript/src/controllerTypes.d.ts.map +1 -0
  84. package/lib/typescript/src/controllers.android.d.ts +2 -0
  85. package/lib/typescript/src/controllers.android.d.ts.map +1 -0
  86. package/lib/typescript/src/controllers.d.ts +2 -0
  87. package/lib/typescript/src/controllers.d.ts.map +1 -0
  88. package/lib/typescript/src/controllers.ios.d.ts +2 -0
  89. package/lib/typescript/src/controllers.ios.d.ts.map +1 -0
  90. package/lib/typescript/src/controllers.native.d.ts +3 -0
  91. package/lib/typescript/src/controllers.native.d.ts.map +1 -0
  92. package/lib/typescript/src/geometry.d.ts +22 -12
  93. package/lib/typescript/src/geometry.d.ts.map +1 -1
  94. package/lib/typescript/src/groupTypes.d.ts +31 -0
  95. package/lib/typescript/src/groupTypes.d.ts.map +1 -0
  96. package/lib/typescript/src/groups.android.d.ts +2 -0
  97. package/lib/typescript/src/groups.android.d.ts.map +1 -0
  98. package/lib/typescript/src/groups.d.ts +2 -0
  99. package/lib/typescript/src/groups.d.ts.map +1 -0
  100. package/lib/typescript/src/groups.ios.d.ts +2 -0
  101. package/lib/typescript/src/groups.ios.d.ts.map +1 -0
  102. package/lib/typescript/src/groups.native.d.ts +3 -0
  103. package/lib/typescript/src/groups.native.d.ts.map +1 -0
  104. package/lib/typescript/src/ids.d.ts +2 -0
  105. package/lib/typescript/src/ids.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +6 -6
  107. package/lib/typescript/src/index.d.ts.map +1 -1
  108. package/lib/typescript/src/nativeCompletion.d.ts +16 -6
  109. package/lib/typescript/src/nativeCompletion.d.ts.map +1 -1
  110. package/lib/typescript/src/presentationCodec.d.ts +10 -0
  111. package/lib/typescript/src/presentationCodec.d.ts.map +1 -0
  112. package/package.json +8 -3
  113. package/src/NativeSmoothClipModule.ts +41 -292
  114. package/src/SmoothClipView.ios.tsx +130 -23
  115. package/src/SmoothClipView.tsx +5 -1
  116. package/src/SmoothClipViewNativeComponent.ts +20 -26
  117. package/src/capabilities.native.ts +2 -52
  118. package/src/capabilityTypes.ts +0 -14
  119. package/src/controllerInternals.ts +52 -0
  120. package/src/controllerLifecycle.ts +6 -0
  121. package/src/controllerTypes.ts +87 -0
  122. package/src/controllers.android.ts +1 -0
  123. package/src/controllers.ios.ts +1 -0
  124. package/src/controllers.native.ts +104 -0
  125. package/src/controllers.ts +1 -0
  126. package/src/geometry.ts +118 -74
  127. package/src/groupTypes.ts +53 -0
  128. package/src/groups.android.ts +1 -0
  129. package/src/groups.ios.ts +1 -0
  130. package/src/groups.native.ts +604 -0
  131. package/src/groups.ts +1 -0
  132. package/src/ids.ts +6 -0
  133. package/src/index.ts +20 -28
  134. package/src/nativeCompletion.ts +67 -32
  135. package/src/presentationCodec.ts +134 -0
  136. package/lib/module/driverState.js +0 -82
  137. package/lib/module/driverState.js.map +0 -1
  138. package/lib/module/driverTypes.js +0 -4
  139. package/lib/module/driverTypes.js.map +0 -1
  140. package/lib/module/drivers.android.js +0 -6
  141. package/lib/module/drivers.android.js.map +0 -1
  142. package/lib/module/drivers.ios.js +0 -6
  143. package/lib/module/drivers.ios.js.map +0 -1
  144. package/lib/module/drivers.js +0 -4
  145. package/lib/module/drivers.js.map +0 -1
  146. package/lib/module/drivers.native.js +0 -756
  147. package/lib/module/drivers.native.js.map +0 -1
  148. package/lib/module/groupDriverTypes.js +0 -4
  149. package/lib/module/groupDrivers.android.js +0 -4
  150. package/lib/module/groupDrivers.android.js.map +0 -1
  151. package/lib/module/groupDrivers.ios.js +0 -4
  152. package/lib/module/groupDrivers.ios.js.map +0 -1
  153. package/lib/module/groupDrivers.js +0 -4
  154. package/lib/module/groupDrivers.js.map +0 -1
  155. package/lib/module/groupDrivers.native.js +0 -799
  156. package/lib/module/groupDrivers.native.js.map +0 -1
  157. package/lib/module/presentationProtocol.js +0 -15
  158. package/lib/module/presentationProtocol.js.map +0 -1
  159. package/lib/module/reactRequests.js +0 -56
  160. package/lib/module/reactRequests.js.map +0 -1
  161. package/lib/typescript/src/driverState.d.ts +0 -26
  162. package/lib/typescript/src/driverState.d.ts.map +0 -1
  163. package/lib/typescript/src/driverTypes.d.ts +0 -121
  164. package/lib/typescript/src/driverTypes.d.ts.map +0 -1
  165. package/lib/typescript/src/drivers.android.d.ts +0 -3
  166. package/lib/typescript/src/drivers.android.d.ts.map +0 -1
  167. package/lib/typescript/src/drivers.d.ts +0 -3
  168. package/lib/typescript/src/drivers.d.ts.map +0 -1
  169. package/lib/typescript/src/drivers.ios.d.ts +0 -3
  170. package/lib/typescript/src/drivers.ios.d.ts.map +0 -1
  171. package/lib/typescript/src/drivers.native.d.ts +0 -5
  172. package/lib/typescript/src/drivers.native.d.ts.map +0 -1
  173. package/lib/typescript/src/groupDriverTypes.d.ts +0 -66
  174. package/lib/typescript/src/groupDriverTypes.d.ts.map +0 -1
  175. package/lib/typescript/src/groupDrivers.android.d.ts +0 -2
  176. package/lib/typescript/src/groupDrivers.android.d.ts.map +0 -1
  177. package/lib/typescript/src/groupDrivers.d.ts +0 -2
  178. package/lib/typescript/src/groupDrivers.d.ts.map +0 -1
  179. package/lib/typescript/src/groupDrivers.ios.d.ts +0 -2
  180. package/lib/typescript/src/groupDrivers.ios.d.ts.map +0 -1
  181. package/lib/typescript/src/groupDrivers.native.d.ts +0 -4
  182. package/lib/typescript/src/groupDrivers.native.d.ts.map +0 -1
  183. package/lib/typescript/src/presentationProtocol.d.ts +0 -4
  184. package/lib/typescript/src/presentationProtocol.d.ts.map +0 -1
  185. package/lib/typescript/src/reactRequests.d.ts +0 -9
  186. package/lib/typescript/src/reactRequests.d.ts.map +0 -1
  187. package/src/driverState.ts +0 -118
  188. package/src/driverTypes.ts +0 -171
  189. package/src/drivers.android.ts +0 -8
  190. package/src/drivers.ios.ts +0 -8
  191. package/src/drivers.native.ts +0 -1599
  192. package/src/drivers.ts +0 -6
  193. package/src/groupDriverTypes.ts +0 -117
  194. package/src/groupDrivers.android.ts +0 -1
  195. package/src/groupDrivers.ios.ts +0 -1
  196. package/src/groupDrivers.native.ts +0 -1189
  197. package/src/groupDrivers.ts +0 -1
  198. package/src/presentationProtocol.ts +0 -28
  199. package/src/reactRequests.ts +0 -76
@@ -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 segment-linear keyframes and a cursor into the active segment.
61
- KeyframeCurve keyframes;
62
62
  double durationS = 0;
63
63
  double startedAtS = 0;
64
- // Integrated spring state (the eleven continuous V2 channels; curve is
65
- // categorical and remains the start presentation's curve while running).
64
+ // Integrated spring state. Shadow channels are skipped when neither endpoint
65
+ // has a visible shadow.
66
66
  Channels springPosition{};
67
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;
@@ -130,10 +126,11 @@ struct GroupMemberAnimation {
130
126
  struct ActiveGroup {
131
127
  uint64_t controllerId = 0;
132
128
  int32_t id = 0;
129
+ int32_t completionTag = 0;
133
130
  AnimationKind kind = AnimationKind::Timing;
134
- GroupSuspensionPolicy suspensionPolicy = GroupSuspensionPolicy::Pause;
135
131
  TimingAnimation timing{};
136
132
  SpringAnimation spring{};
133
+ ScalarSpringState scalarSpring{0, 0};
137
134
  double durationS = 0;
138
135
  double startedAtS = 0;
139
136
  double lastFrameS = 0;
@@ -142,6 +139,8 @@ struct ActiveGroup {
142
139
  std::vector<GroupMemberAnimation> members;
143
140
  };
144
141
 
142
+ bool anyDisplayableView(const DriverState &state);
143
+
145
144
  std::unordered_map<uint64_t, DriverState> &registry() {
146
145
  static std::unordered_map<uint64_t, DriverState> value;
147
146
  return value;
@@ -255,9 +254,16 @@ void scheduleFrame() {
255
254
 
256
255
  // --- Math (ported from SmoothClipRegistry.kt so both platforms match) -----
257
256
  //
258
- // clamp01, toChannels/fromChannels, interpolate, the frame-clock anchor and the
259
- // keyframe curve now live in cpp/SmoothClipAnimationCurve.h so a test binary can
260
- // 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
+ }
261
267
 
262
268
  // Springs settle below these per-channel thresholds (DIP and DIP/s).
263
269
  constexpr double kSpringSettleDisplacement = 0.05;
@@ -272,7 +278,9 @@ constexpr double kSpringMaxDurationS = 10.0;
272
278
  // stiffness/mass = 200k. Termination is settle-based: every channel must be
273
279
  // near its target and nearly stationary at the same time.
274
280
  bool advanceSpring(ActiveAnimation &animation, double now) {
275
- const auto target = toChannels(animation.target);
281
+ const auto normalizedEndpoints = normalizeShadowEndpoints(
282
+ animation.start, animation.target);
283
+ const auto target = toChannels(normalizedEndpoints.second);
276
284
  double dt = now - animation.lastFrameS;
277
285
  animation.lastFrameS = now;
278
286
  if (dt < 0) dt = 0;
@@ -284,7 +292,7 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
284
292
  while (dt > 0) {
285
293
  const double step = std::min(dt, kMaxStepS);
286
294
  dt -= step;
287
- for (std::size_t index = 0; index < kChannelCount; index += 1) {
295
+ for (std::size_t index = 0; index < animation.springChannelCount; index += 1) {
288
296
  const double displacement =
289
297
  animation.springPosition[index] - target[index];
290
298
  const double acceleration =
@@ -296,7 +304,7 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
296
304
  }
297
305
  }
298
306
  bool settled = true;
299
- for (std::size_t index = 0; index < kChannelCount; index += 1) {
307
+ for (std::size_t index = 0; index < animation.springChannelCount; index += 1) {
300
308
  if (!std::isfinite(animation.springPosition[index]) ||
301
309
  !std::isfinite(animation.springVelocity[index])) {
302
310
  // A divergent configuration must never freeze the clip: end at target.
@@ -311,8 +319,14 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
311
319
  settled = false;
312
320
  }
313
321
  }
314
- animation.current =
315
- fromChannels(animation.springPosition, animation.start.clip.curve);
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
+ }
316
330
  return settled || now - animation.startedAtS >= kSpringMaxDurationS;
317
331
  }
318
332
 
@@ -332,23 +346,42 @@ bool shouldReduceMotion(int32_t setting) {
332
346
 
333
347
  // --- State helpers --------------------------------------------------------
334
348
 
335
- 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) {
336
354
  std::lock_guard<std::mutex> lock(completionSinkMutex());
337
355
  if (completionSink().callback) {
338
- completionSink().callback(driverId, animationId, finished);
356
+ completionSink().callback(
357
+ driverId, animationId, completionTag, finished);
339
358
  }
340
359
  }
341
360
 
342
361
  void emitGroupCompletion(const ActiveGroup &group, bool finished) {
343
- std::vector<uint64_t> driverIds;
344
- driverIds.reserve(group.members.size());
362
+ std::vector<DriverSnapshot> snapshots;
363
+ snapshots.reserve(group.members.size());
345
364
  for (const GroupMemberAnimation &member : group.members) {
346
- driverIds.push_back(member.driverId);
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
+ }
347
376
  }
348
377
  std::lock_guard<std::mutex> lock(completionSinkMutex());
349
378
  if (groupCompletionSink().callback) {
350
379
  groupCompletionSink().callback(
351
- group.controllerId, group.id, finished, std::move(driverIds));
380
+ group.controllerId,
381
+ group.id,
382
+ group.completionTag,
383
+ finished,
384
+ std::move(snapshots));
352
385
  }
353
386
  }
354
387
 
@@ -374,67 +407,28 @@ Presentation visiblePresentation(
374
407
  return state.animation.has_value() ? state.animation->current : state.latest;
375
408
  }
376
409
 
377
- bool needsV2Delivery(const Presentation &presentation) {
378
- const double topLeft = SmoothClipResolvedRadius(
379
- presentation.clip.topLeftRadius, presentation.clip.radius);
380
- const double topRight = SmoothClipResolvedRadius(
381
- presentation.clip.topRightRadius, presentation.clip.radius);
382
- const double bottomRight = SmoothClipResolvedRadius(
383
- presentation.clip.bottomRightRadius, presentation.clip.radius);
384
- const double bottomLeft = SmoothClipResolvedRadius(
385
- presentation.clip.bottomLeftRadius, presentation.clip.radius);
386
- return presentation.clip.curve != ClipCurve::Circular ||
387
- presentation.contentScale != 1.0 ||
388
- topLeft != presentation.clip.radius ||
389
- topRight != presentation.clip.radius ||
390
- bottomRight != presentation.clip.radius ||
391
- bottomLeft != presentation.clip.radius;
392
- }
393
-
394
- // Terminal per-frame delivery: scale DIP -> px and normalize against the
395
- // pushed host metrics in C++, so the JNI call carries final pixel floats and
396
- // the Kotlin side reduces to field stores + invalidateOutline().
410
+ // Terminal per-frame delivery: scale canonical DIP geometry to pixels without
411
+ // consulting host metrics. Each SmoothClipView applies the fixed viewport crop.
397
412
  void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
398
- const bool deliverV2 = needsV2Delivery(presentation);
399
413
  if (entry.density <= 0) {
400
- if (deliverV2) {
401
- entry.view->applyClipV2(presentation);
402
- } else {
403
- entry.view->applyClip(presentation);
404
- }
414
+ entry.view->applyClip(presentation);
405
415
  return;
406
416
  }
407
417
  const double density = entry.density;
408
- NormalizedClip clip;
409
- if (deliverV2) {
410
- if (!SmoothClipNormalizeV2(
411
- presentation.clip.x * density,
412
- presentation.clip.y * density,
413
- presentation.clip.width * density,
414
- presentation.clip.height * density,
415
- presentation.clip.radius * density,
416
- presentation.clip.topLeftRadius * density,
417
- presentation.clip.topRightRadius * density,
418
- presentation.clip.bottomRightRadius * density,
419
- presentation.clip.bottomLeftRadius * density,
420
- presentation.clip.curve,
421
- entry.hostWidthPx,
422
- entry.hostHeightPx,
423
- clip)) {
424
- return;
425
- }
426
- } else {
427
- if (!SmoothClipNormalize(
428
- presentation.clip.x * density,
429
- presentation.clip.y * density,
430
- presentation.clip.width * density,
431
- presentation.clip.height * density,
432
- presentation.clip.radius * density,
433
- entry.hostWidthPx,
434
- entry.hostHeightPx,
435
- clip)) {
436
- return;
437
- }
418
+ CanonicalClip clip;
419
+ if (!SmoothClipCanonicalize(
420
+ presentation.clip.x * density,
421
+ presentation.clip.y * density,
422
+ presentation.clip.width * density,
423
+ presentation.clip.height * density,
424
+ presentation.clip.radius * density,
425
+ presentation.clip.topLeftRadius * density,
426
+ presentation.clip.topRightRadius * density,
427
+ presentation.clip.bottomRightRadius * density,
428
+ presentation.clip.bottomLeftRadius * density,
429
+ presentation.clip.curve,
430
+ clip)) {
431
+ return;
438
432
  }
439
433
  const double translateX = presentation.contentTranslateX * density;
440
434
  const double translateY = presentation.contentTranslateY * density;
@@ -444,12 +438,17 @@ void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
444
438
  presentation.contentScale <= 0) {
445
439
  return;
446
440
  }
447
- if (deliverV2) {
448
- entry.view->applyClipV2Px(
449
- clip, translateX, translateY, presentation.contentScale);
450
- } else {
451
- entry.view->applyClipPx(clip, translateX, translateY);
452
- }
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);
453
452
  }
454
453
 
455
454
  void applyToViews(DriverState &state, const Presentation &presentation) {
@@ -458,6 +457,22 @@ void applyToViews(DriverState &state, const Presentation &presentation) {
458
457
  }
459
458
  }
460
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
+
461
476
  void applyPresentation(DriverState &state, const Presentation &presentation) {
462
477
  state.latest = presentation;
463
478
  state.hasLatest = true;
@@ -474,6 +489,7 @@ std::vector<uint64_t> &animatingDrivers() {
474
489
  // visits animating drivers. Removal is eager: a stale id surviving a driver
475
490
  // erase/recreate cycle would let one frame integrate the animation twice.
476
491
  void clearActiveAnimation(uint64_t driverId, DriverState &state) {
492
+ setAutonomousMotion(state, false);
477
493
  state.animation.reset();
478
494
  for (ViewEntry &entry : state.views) {
479
495
  entry.participation = ViewParticipation::Deferred;
@@ -486,8 +502,9 @@ void clearActiveAnimation(uint64_t driverId, DriverState &state) {
486
502
  void finishActive(uint64_t driverId, DriverState &state, bool finished) {
487
503
  if (!state.animation.has_value()) return;
488
504
  const int32_t animationId = state.animation->id;
505
+ const int32_t completionTag = state.animation->completionTag;
489
506
  clearActiveAnimation(driverId, state);
490
- emitCompletion(driverId, animationId, finished);
507
+ emitCompletion(driverId, animationId, completionTag, finished);
491
508
  }
492
509
 
493
510
  Presentation prepareAnimation(
@@ -522,77 +539,6 @@ bool entryDisplayable(const ViewEntry &entry) {
522
539
  entry.lifecycleVisible;
523
540
  }
524
541
 
525
- Geometry geometryInPixels(
526
- const Geometry &geometry,
527
- double density) {
528
- Geometry result = geometry;
529
- result.x *= density;
530
- result.y *= density;
531
- result.width *= density;
532
- result.height *= density;
533
- result.radius *= density;
534
- result.topLeftRadius *= density;
535
- result.topRightRadius *= density;
536
- result.bottomRightRadius *= density;
537
- result.bottomLeftRadius *= density;
538
- return result;
539
- }
540
-
541
- bool presentationFitsEveryKnownHostWithoutNormalization(
542
- const DriverState &state,
543
- const Presentation &presentation) {
544
- for (const ViewEntry &entry : state.views) {
545
- // Zero host metrics are still waiting on Fabric layout and remain covered
546
- // by the existing readiness latch. Once metrics exist, density is required
547
- // to compare the DIP presentation against the exact pixel host bounds.
548
- if (entry.hostWidthPx <= 0 || entry.hostHeightPx <= 0) continue;
549
- if (entry.density <= 0 ||
550
- !SmoothClipGeometryNormalizationIsIdentity(
551
- geometryInPixels(presentation.clip, entry.density),
552
- entry.hostWidthPx,
553
- entry.hostHeightPx)) {
554
- return false;
555
- }
556
- }
557
- return true;
558
- }
559
-
560
- bool timingPlanPreservesLinearHostNormalization(
561
- const DriverState &state,
562
- const Presentation &start,
563
- const Presentation &target,
564
- const TimingAnimation &animation) {
565
- const double minimumProgress = std::min(
566
- {0.0, 1.0, animation.controlPoint1Y, animation.controlPoint2Y});
567
- const double maximumProgress = std::max(
568
- {0.0, 1.0, animation.controlPoint1Y, animation.controlPoint2Y});
569
- const Presentation minimum =
570
- interpolate(start, target, minimumProgress);
571
- const Presentation maximum =
572
- interpolate(start, target, maximumProgress);
573
- return isFiniteProtocolV2Presentation(minimum) &&
574
- isFiniteProtocolV2Presentation(maximum) &&
575
- presentationFitsEveryKnownHostWithoutNormalization(state, minimum) &&
576
- presentationFitsEveryKnownHostWithoutNormalization(state, maximum);
577
- }
578
-
579
- bool keyframePlanPreservesLinearHostNormalization(
580
- const DriverState &state,
581
- const Presentation &resolvedStart,
582
- const std::vector<Keyframe> &keyframes) {
583
- if (!presentationFitsEveryKnownHostWithoutNormalization(
584
- state, resolvedStart)) {
585
- return false;
586
- }
587
- for (std::size_t index = 1; index < keyframes.size(); index += 1) {
588
- if (!presentationFitsEveryKnownHostWithoutNormalization(
589
- state, keyframes[index].presentation)) {
590
- return false;
591
- }
592
- }
593
- return true;
594
- }
595
-
596
542
  bool anyDisplayableView(const DriverState &state) {
597
543
  for (const ViewEntry &entry : state.views) {
598
544
  if (entryDisplayable(entry)) return true;
@@ -617,6 +563,8 @@ void removeAnimatingGroup(int32_t groupId) {
617
563
  }
618
564
 
619
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.
620
568
  for (const GroupMemberAnimation &member : group.members) {
621
569
  const auto iterator = registry().find(member.driverId);
622
570
  if (iterator != registry().end()) {
@@ -625,6 +573,23 @@ void applyGroupCurrent(const ActiveGroup &group) {
625
573
  }
626
574
  }
627
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
+
628
593
  std::vector<DriverSnapshot> finishGroupImpl(
629
594
  int32_t groupId,
630
595
  bool useTarget,
@@ -650,6 +615,7 @@ std::vector<DriverSnapshot> finishGroupImpl(
650
615
  state.latest = result;
651
616
  state.hasLatest = true;
652
617
  state.ownership = Ownership::Interactive;
618
+ setAutonomousMotion(state, false);
653
619
  applyToViews(state, result);
654
620
  snapshots.push_back(
655
621
  {member.driverId, result, !state.destroyed && anyDisplayableView(state)});
@@ -658,7 +624,7 @@ std::vector<DriverSnapshot> finishGroupImpl(
658
624
  return snapshots;
659
625
  }
660
626
 
661
- void startLatchedGroup(ActiveGroup &group) {
627
+ void startPendingGroup(ActiveGroup &group) {
662
628
  const double now = nowSeconds();
663
629
  group.started = true;
664
630
  group.startedAtS = now;
@@ -669,45 +635,7 @@ void startLatchedGroup(ActiveGroup &group) {
669
635
  member.animation.lastFrameS = now;
670
636
  member.animation.frameClockAnchored = false;
671
637
  }
672
- auto &active = animatingGroups();
673
- if (std::find(active.begin(), active.end(), group.id) == active.end()) {
674
- active.push_back(group.id);
675
- }
676
- applyGroupCurrent(group);
677
- scheduleFrame();
678
- }
679
-
680
- void pauseGroup(ActiveGroup &group) {
681
- if (group.kind == AnimationKind::Timing) {
682
- const double rawProgress = timingFraction(
683
- group.lastFrameS, group.startedAtS, group.durationS);
684
- const TimingRemainder remainder = timingRemainder(group.timing, rawProgress);
685
- group.timing = remainder.animation;
686
- group.durationS = remainder.animation.durationMs / 1000.0;
687
- for (GroupMemberAnimation &member : group.members) {
688
- member.animation.start = member.animation.current;
689
- member.animation.timing = remainder.animation;
690
- }
691
- } else if (group.kind == AnimationKind::Keyframes) {
692
- double remainingDurationMs = 0;
693
- for (GroupMemberAnimation &member : group.members) {
694
- const KeyframeContinuation continuation = keyframeContinuationAtFrame(
695
- member.animation.keyframes.frames(),
696
- member.animation.current,
697
- member.animation.target,
698
- group.lastFrameS,
699
- group.startedAtS,
700
- group.durationS);
701
- member.animation.start = continuation.start;
702
- member.animation.current = continuation.start;
703
- member.animation.keyframes.reset(continuation.frames);
704
- remainingDurationMs = continuation.durationMs;
705
- }
706
- group.durationS = remainingDurationMs / 1000.0;
707
- }
708
- group.started = false;
709
- group.frameClockAnchored = false;
710
- removeAnimatingGroup(group.id);
638
+ startGroupFrameLoop(group);
711
639
  }
712
640
 
713
641
  void reconcileGroupReadiness(uint64_t driverId) {
@@ -724,27 +652,20 @@ void reconcileGroupReadiness(uint64_t driverId) {
724
652
  ActiveGroup &group = groupIterator->second;
725
653
  const bool ready = allGroupMembersReady(group);
726
654
  if (!group.started) {
727
- if (ready) startLatchedGroup(group);
655
+ if (ready) startPendingGroup(group);
728
656
  return;
729
657
  }
730
658
  if (ready) return;
731
- if (group.suspensionPolicy == GroupSuspensionPolicy::Finish) {
732
- finishGroupImpl(groupId, true, true);
733
- } else {
734
- pauseGroup(group);
735
- }
659
+ finishGroupImpl(groupId, false, false);
736
660
  }
737
661
 
738
- // Starts a latched animation: rebases the clock so no progress was burned
739
- // while no view could display, then joins the choreographer loop.
740
- void startLatchedAnimation(uint64_t driverId, DriverState &state) {
662
+ // Starts a pre-ready animation with its full duration, then joins the
663
+ // choreographer loop.
664
+ void startPendingAnimation(uint64_t driverId, DriverState &state) {
741
665
  auto &animation = *state.animation;
742
666
  animation.started = true;
743
667
  animation.startedAtS = nowSeconds();
744
668
  animation.lastFrameS = animation.startedAtS;
745
- // Load-bearing for re-latch resumes: unregisterViewAndroid rewrites this
746
- // ActiveAnimation in place, so a stale anchor would replay the fraction-0
747
- // first frame the anchor exists to remove.
748
669
  animation.frameClockAnchored = false;
749
670
  auto &active = animatingDrivers();
750
671
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
@@ -755,6 +676,7 @@ void startLatchedAnimation(uint64_t driverId, DriverState &state) {
755
676
  entry.participation = ViewParticipation::Active;
756
677
  }
757
678
  }
679
+ setAutonomousMotion(state, true);
758
680
  applyToViews(state, animation.start);
759
681
  scheduleFrame();
760
682
  }
@@ -770,16 +692,16 @@ int32_t startAnimation(
770
692
  // animation arrives pre-anchored and advance() must not min() it against
771
693
  // the dispatching frame, which for a CALLBACK_INPUT start is EARLIER than
772
694
  // the call and would re-open the intra-frame lead the hint closes. Without
773
- // a hint (NaN: latch-less legacy callers, tests, iOS ignoring the field)
695
+ // a hint (NaN: unstamped callers, tests, iOS ignoring the field)
774
696
  // this reduces exactly to the old nowSeconds() + min() anchor path.
775
697
  const double wallNow = nowSeconds();
776
698
  const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
777
699
  animation.startedAtS = stamp.startedAtS;
778
700
  animation.lastFrameS = stamp.startedAtS;
779
701
  animation.frameClockAnchored = stamp.frameClockAnchored;
780
- // current = start even while latched is load-bearing: cancelAnimation,
702
+ // current = start while pre-ready is load-bearing: cancelAnimation,
781
703
  // beginInteraction, prepareAnimation's visibleBefore and registerView's
782
- // visible all read animation->current, giving a never-rendered latch
704
+ // visible all read animation->current, giving a never-rendered pending-run
783
705
  // freeze-at-start / replace-from-start semantics with no extra branches.
784
706
  animation.current = animation.start;
785
707
  animation.started = anyDisplayableView(state);
@@ -790,9 +712,9 @@ int32_t startAnimation(
790
712
  : ViewParticipation::Deferred;
791
713
  }
792
714
  if (!state.animation->started) {
793
- // Latch: no host view can display yet (animateTo raced the mount, or
794
- // every host is detached/unsized). The first displayable registration,
795
- // 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
796
718
  // animatingDrivers() and schedules the frame loop. Non-zero id is still
797
719
  // returned so the JS side does not treat this as rejection.
798
720
  return state.animation->id;
@@ -801,60 +723,23 @@ int32_t startAnimation(
801
723
  if (std::find(active.begin(), active.end(), driverId) == active.end()) {
802
724
  active.push_back(driverId);
803
725
  }
726
+ setAutonomousMotion(state, true);
804
727
  applyToViews(state, state.animation->start);
805
728
  scheduleFrame();
806
729
  return state.animation->id;
807
730
  }
808
731
 
809
- void relatchIfNoDisplayable(uint64_t driverId, DriverState &state) {
732
+ void finishIfHostUnavailable(uint64_t driverId, DriverState &state) {
810
733
  if (!state.animation.has_value() || !state.animation->started ||
811
734
  anyDisplayableView(state)) {
812
735
  return;
813
736
  }
814
- auto &animation = *state.animation;
815
- for (ViewEntry &entry : state.views) {
816
- if (entry.participation == ViewParticipation::Active) {
817
- entry.participation = ViewParticipation::Suspended;
818
- }
819
- }
820
-
821
- if (animation.kind == AnimationKind::Timing) {
822
- const TimingContinuation continuation = timingContinuationAtFrame(
823
- animation.timing,
824
- animation.start,
825
- animation.target,
826
- animation.lastFrameS,
827
- animation.startedAtS,
828
- animation.durationS);
829
- animation.current = continuation.start;
830
- animation.timing = continuation.animation;
831
- animation.durationS = continuation.animation.durationMs / 1000.0;
832
- } else if (animation.kind == AnimationKind::Keyframes) {
833
- const KeyframeContinuation continuation = keyframeContinuationAtFrame(
834
- animation.keyframes.frames(),
835
- animation.current,
836
- animation.target,
837
- animation.lastFrameS,
838
- animation.startedAtS,
839
- animation.durationS);
840
- animation.current = continuation.start;
841
- animation.keyframes.reset(continuation.frames);
842
- animation.durationS = continuation.durationMs / 1000.0;
843
- }
844
-
845
- if (state.destroyed ||
846
- (animation.kind != AnimationKind::Spring && animation.durationS <= 0)) {
847
- state.ownership = Ownership::Interactive;
848
- finishActive(driverId, state, false);
849
- return;
850
- }
851
-
852
- animation.start = animation.current;
853
- animation.started = false;
854
- animation.frameClockAnchored = false;
855
- auto &active = animatingDrivers();
856
- active.erase(
857
- 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);
858
743
  }
859
744
 
860
745
  void reconcileViewDisplayability(
@@ -863,7 +748,7 @@ void reconcileViewDisplayability(
863
748
  ViewEntry &entry) {
864
749
  if (!state.animation.has_value()) return;
865
750
  if (!state.animation->started) {
866
- if (entryDisplayable(entry)) startLatchedAnimation(driverId, state);
751
+ if (entryDisplayable(entry)) startPendingAnimation(driverId, state);
867
752
  return;
868
753
  }
869
754
 
@@ -873,20 +758,20 @@ void reconcileViewDisplayability(
873
758
  deliverToView(entry, state.animation->current);
874
759
  }
875
760
  } else if (entry.participation == ViewParticipation::Active) {
876
- entry.participation = ViewParticipation::Suspended;
761
+ entry.participation = ViewParticipation::Deferred;
877
762
  }
878
- relatchIfNoDisplayable(driverId, state);
763
+ finishIfHostUnavailable(driverId, state);
879
764
  }
880
765
 
881
766
  void advance(uint64_t driverId, DriverState &state, double now) {
882
767
  ActiveAnimation &animation = *state.animation;
883
768
 
884
769
  if (!animation.frameClockAnchored) {
885
- // This is the stamp-less/latch fallback. Worklet-issued animations carry
770
+ // This is the unstamped/pre-ready fallback. Worklet-issued animations carry
886
771
  // Reanimated's exact `__frameTimestamp || _getAnimationTimestamp()` value
887
772
  // and arrive pre-anchored, so they skip this approximation — including the
888
773
  // CALLBACK_INPUT case where the current frame stamp predates the call.
889
- // Here startedAtS/lastFrameS hold nowSeconds() sampled at native latch
774
+ // Here startedAtS/lastFrameS hold nowSeconds() sampled at native
890
775
  // attach or by an older caller, while `now` is the frame's vsync stamp on
891
776
  // the same CLOCK_MONOTONIC timebase. Taking the earlier stamp prevents a
892
777
  // mid-frame native start from duplicating fraction zero.
@@ -910,44 +795,36 @@ void advance(uint64_t driverId, DriverState &state, double now) {
910
795
  if (animation.kind == AnimationKind::Spring) {
911
796
  done = advanceSpring(animation, now);
912
797
  } else {
913
- // This is the frame whose presentation is delivered below. Lifecycle
914
- // re-latch must trim from this timestamp, never from a later wall sample.
915
798
  animation.lastFrameS = now;
916
799
  const double fraction =
917
800
  timingFraction(now, animation.startedAtS, animation.durationS);
918
- if (animation.kind == AnimationKind::Keyframes) {
919
- animation.current = animation.keyframes.evaluate(fraction);
920
- } else {
921
- const double eased = cubicBezier(
922
- animation.timing.controlPoint1X,
923
- animation.timing.controlPoint1Y,
924
- animation.timing.controlPoint2X,
925
- animation.timing.controlPoint2Y,
926
- fraction);
927
- 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;
928
811
  }
929
- done = fraction >= 1.0;
812
+ done = !canonical || fraction >= 1.0;
930
813
  }
931
814
  // The completion branch below fans out the exact target; applying the
932
815
  // integrated value too would double every JNI crossing on the final frame.
933
- if (!done) applyToViews(state, animation.current);
816
+ if (!done) applyAnimationFrameToViews(state, animation.current);
934
817
 
935
818
  if (done) {
936
819
  const int32_t animationId = animation.id;
937
- bool finished = animation.finished;
938
- for (const ViewEntry &entry : state.views) {
939
- if (entry.participation == ViewParticipation::Suspended) {
940
- finished = false;
941
- break;
942
- }
943
- }
820
+ const int32_t completionTag = animation.completionTag;
944
821
  const Presentation target = animation.target;
945
822
  clearActiveAnimation(driverId, state);
946
823
  state.latest = target;
947
824
  state.hasLatest = true;
948
825
  state.ownership = Ownership::Interactive;
949
826
  applyToViews(state, target);
950
- emitCompletion(driverId, animationId, finished);
827
+ emitCompletion(driverId, animationId, completionTag, true);
951
828
  }
952
829
  }
953
830
 
@@ -965,31 +842,44 @@ void advanceGroup(int32_t groupId, ActiveGroup &group, double now) {
965
842
 
966
843
  bool done = false;
967
844
  if (group.kind == AnimationKind::Spring) {
968
- done = true;
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;
969
852
  for (GroupMemberAnimation &member : group.members) {
970
- if (!advanceSpring(member.animation, now)) done = false;
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
+ }
971
861
  }
972
862
  } else {
973
863
  group.lastFrameS = now;
974
864
  const double fraction =
975
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;
976
873
  for (GroupMemberAnimation &member : group.members) {
977
874
  member.animation.lastFrameS = now;
978
- if (group.kind == AnimationKind::Keyframes) {
979
- member.animation.current =
980
- member.animation.keyframes.evaluate(fraction);
981
- } else {
982
- const double eased = cubicBezier(
983
- group.timing.controlPoint1X,
984
- group.timing.controlPoint1Y,
985
- group.timing.controlPoint2X,
986
- group.timing.controlPoint2Y,
987
- fraction);
988
- member.animation.current = interpolate(
989
- member.animation.start, member.animation.target, eased);
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;
990
880
  }
991
881
  }
992
- done = fraction >= 1.0;
882
+ done = !canonical || fraction >= 1.0;
993
883
  }
994
884
 
995
885
  if (done) {
@@ -1147,12 +1037,13 @@ std::vector<DriverSnapshot> beginGroupInteraction(
1147
1037
  DriverState &state = registry().at(driverId);
1148
1038
  if (state.animation.has_value()) {
1149
1039
  const int32_t animationId = state.animation->id;
1040
+ const int32_t completionTag = state.animation->completionTag;
1150
1041
  const Presentation current = state.animation->current;
1151
1042
  clearActiveAnimation(driverId, state);
1152
1043
  state.latest = current;
1153
1044
  state.hasLatest = true;
1154
1045
  applyToViews(state, current);
1155
- emitCompletion(driverId, animationId, false);
1046
+ emitCompletion(driverId, animationId, completionTag, false);
1156
1047
  }
1157
1048
  state.ownership = Ownership::Interactive;
1158
1049
  }
@@ -1161,17 +1052,21 @@ std::vector<DriverSnapshot> beginGroupInteraction(
1161
1052
 
1162
1053
  bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1163
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
+ }
1164
1059
  std::vector<uint64_t> unique;
1165
1060
  std::vector<int32_t> groupsToCancel;
1166
1061
  unique.reserve(entries.size());
1167
- for (const BatchEntry &entry : entries) {
1062
+ for (const BatchEntry &entry : canonicalEntries) {
1168
1063
  if (entry.driverId == 0 ||
1169
1064
  std::find(unique.begin(), unique.end(), entry.driverId) != unique.end()) {
1170
1065
  return false;
1171
1066
  }
1172
1067
  const auto iterator = registry().find(entry.driverId);
1173
1068
  if (iterator == registry().end() || iterator->second.destroyed ||
1174
- !isFiniteProtocolV2Presentation(entry.presentation)) {
1069
+ !isFinitePresentation(entry.presentation)) {
1175
1070
  return false;
1176
1071
  }
1177
1072
  if (iterator != registry().end() && iterator->second.groupId != 0 &&
@@ -1192,16 +1087,17 @@ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1192
1087
  }
1193
1088
 
1194
1089
  const double now = nowSeconds();
1195
- for (const BatchEntry &entry : entries) {
1090
+ for (const BatchEntry &entry : canonicalEntries) {
1196
1091
  DriverState &state = registry().at(entry.driverId);
1197
1092
  state.destroyed = false;
1198
1093
  finishActive(entry.driverId, state, false);
1199
1094
  state.latest = entry.presentation;
1200
1095
  state.hasLatest = true;
1201
1096
  state.ownership = Ownership::Interactive;
1202
- recordVelocitySample(state.samples, toChannels(entry.presentation), now);
1097
+ recordVelocitySample(
1098
+ state.samples, velocityChannels(entry.presentation), now);
1203
1099
  }
1204
- for (const BatchEntry &entry : entries) {
1100
+ for (const BatchEntry &entry : canonicalEntries) {
1205
1101
  applyToViews(registry().at(entry.driverId), entry.presentation);
1206
1102
  }
1207
1103
  return true;
@@ -1217,24 +1113,23 @@ int32_t startGroupCommon(
1217
1113
  SpringAnimation spring,
1218
1114
  double durationMs,
1219
1115
  int32_t reduceMotion,
1220
- GroupSuspensionPolicy suspensionPolicy,
1116
+ int32_t completionTag,
1221
1117
  double startedAtHintS) {
1222
- if (!isOnMainThread() || controllerId == 0 || entries.empty() ||
1223
- (suspensionPolicy != GroupSuspensionPolicy::Pause &&
1224
- suspensionPolicy != GroupSuspensionPolicy::Finish)) {
1118
+ if (!isOnMainThread() || controllerId == 0 || entries.empty()) {
1225
1119
  return 0;
1226
1120
  }
1121
+ for (GroupMotionEntry &entry : entries) {
1122
+ if ((entry.hasFrom && !canonicalizePresentation(entry.from)) ||
1123
+ !canonicalizePresentation(entry.target)) {
1124
+ return 0;
1125
+ }
1126
+ }
1227
1127
  if (kind == AnimationKind::Timing &&
1228
- !isValidProtocolV2Timing(timing)) {
1128
+ !isValidTiming(timing)) {
1229
1129
  return 0;
1230
1130
  }
1231
1131
  if (kind == AnimationKind::Spring &&
1232
- !isValidProtocolV2Spring(spring)) {
1233
- return 0;
1234
- }
1235
- if (kind == AnimationKind::Keyframes &&
1236
- (!std::isfinite(durationMs) || durationMs < 0 ||
1237
- !isValidReduceMotionCode(reduceMotion))) {
1132
+ (!isValidSpring(spring) || spring.inheritVelocity)) {
1238
1133
  return 0;
1239
1134
  }
1240
1135
  const bool reduce = shouldReduceMotion(reduceMotion) ||
@@ -1264,36 +1159,16 @@ int32_t startGroupCommon(
1264
1159
  const Presentation resolvedStart = entry.hasFrom
1265
1160
  ? entry.from
1266
1161
  : visiblePresentation(entry.driverId, iterator->second);
1267
- if (!isFiniteProtocolV2Presentation(resolvedStart) ||
1268
- !isFiniteProtocolV2Presentation(entry.target) ||
1269
- resolvedStart.clip.curve != entry.target.clip.curve) {
1270
- return 0;
1271
- }
1272
- if (kind == AnimationKind::Spring &&
1273
- !protocolV2SpringScaleIsProvablyPositive(
1274
- resolvedStart, entry.target, spring)) {
1275
- return 0;
1276
- }
1277
- if (kind == AnimationKind::Keyframes) {
1278
- if (!isValidProtocolV2Keyframes(
1279
- entry.keyframes,
1280
- resolvedStart,
1281
- entry.target,
1282
- entry.hasFrom)) {
1283
- return 0;
1284
- }
1285
- } else if (!entry.keyframes.empty()) {
1162
+ if (!isFinitePresentation(resolvedStart) ||
1163
+ !isFinitePresentation(entry.target) ||
1164
+ !isAutonomousUniformCircular(resolvedStart) ||
1165
+ !isAutonomousUniformCircular(entry.target)) {
1286
1166
  return 0;
1287
1167
  }
1288
- if (!reduce && iterator != registry().end()) {
1289
- if (kind == AnimationKind::Timing &&
1290
- !timingPlanPreservesLinearHostNormalization(
1291
- iterator->second, resolvedStart, entry.target, timing)) {
1292
- return 0;
1293
- }
1294
- if (kind == AnimationKind::Keyframes &&
1295
- !keyframePlanPreservesLinearHostNormalization(
1296
- iterator->second, resolvedStart, entry.keyframes)) {
1168
+ if (kind == AnimationKind::Spring) {
1169
+ const double velocity = spring.initialVelocity;
1170
+ if (!springScaleStaysPositive(
1171
+ resolvedStart, entry.target, spring, velocity)) {
1297
1172
  return 0;
1298
1173
  }
1299
1174
  }
@@ -1311,10 +1186,11 @@ int32_t startGroupCommon(
1311
1186
  ActiveGroup group;
1312
1187
  group.controllerId = controllerId;
1313
1188
  group.id = allocateAnimationId();
1189
+ group.completionTag = completionTag;
1314
1190
  group.kind = kind;
1315
- group.suspensionPolicy = suspensionPolicy;
1316
1191
  group.timing = timing;
1317
1192
  group.spring = spring;
1193
+ group.scalarSpring = {0, spring.initialVelocity};
1318
1194
  group.durationS = durationMs / 1000.0;
1319
1195
  group.members.reserve(entries.size());
1320
1196
  const double wallNow = nowSeconds();
@@ -1340,32 +1216,13 @@ int32_t startGroupCommon(
1340
1216
  member.animation.timing = timing;
1341
1217
  member.animation.spring = spring;
1342
1218
  member.animation.durationS = group.durationS;
1343
- if (kind == AnimationKind::Keyframes) {
1344
- if (!entry.hasFrom) {
1345
- entry.keyframes.front().presentation = resolvedStart;
1346
- }
1347
- member.animation.keyframes.reset(std::move(entry.keyframes));
1348
- } else if (kind == AnimationKind::Spring) {
1349
- const double velocity = spring.inheritVelocity
1350
- ? inheritedVelocity(state.samples, toChannels(entry.target), wallNow)
1351
- : spring.initialVelocity;
1352
- member.animation.spring.initialVelocity = velocity;
1353
- member.animation.spring.inheritVelocity = false;
1354
- const Channels startChannels = toChannels(resolvedStart);
1355
- const Channels targetChannels = toChannels(entry.target);
1356
- for (std::size_t index = 0; index < kChannelCount; index += 1) {
1357
- member.animation.springPosition[index] = startChannels[index];
1358
- member.animation.springVelocity[index] =
1359
- velocity * (targetChannels[index] - startChannels[index]);
1360
- }
1361
- }
1362
1219
  group.members.push_back(std::move(member));
1363
1220
  }
1364
1221
 
1365
1222
  // Apply every explicit start after the whole participant set has passed
1366
1223
  // preflight and acquired the new immutable group id. This remains one
1367
1224
  // synchronous main-thread transaction even when the initial readiness
1368
- // barrier is latched; ready hosts must not keep displaying their old frame
1225
+ // barrier is pending; ready hosts must not keep displaying their old frame
1369
1226
  // while snapshots already report `from`.
1370
1227
  for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1371
1228
  if (!entries[entryIndex].hasFrom) continue;
@@ -1392,9 +1249,7 @@ int32_t startGroupCommon(
1392
1249
  }
1393
1250
  ActiveGroup &stored = groupRegistry().at(groupId);
1394
1251
  if (stored.started) {
1395
- animatingGroups().push_back(groupId);
1396
- applyGroupCurrent(stored);
1397
- scheduleFrame();
1252
+ startGroupFrameLoop(stored);
1398
1253
  }
1399
1254
  return groupId;
1400
1255
  }
@@ -1405,7 +1260,7 @@ int32_t animateTimingGroup(
1405
1260
  uint64_t controllerId,
1406
1261
  std::vector<GroupMotionEntry> entries,
1407
1262
  TimingAnimation animation,
1408
- GroupSuspensionPolicy suspensionPolicy,
1263
+ int32_t completionTag,
1409
1264
  double startedAtHintS) {
1410
1265
  return startGroupCommon(
1411
1266
  controllerId,
@@ -1415,7 +1270,7 @@ int32_t animateTimingGroup(
1415
1270
  {},
1416
1271
  animation.durationMs,
1417
1272
  animation.reduceMotion,
1418
- suspensionPolicy,
1273
+ completionTag,
1419
1274
  startedAtHintS);
1420
1275
  }
1421
1276
 
@@ -1423,7 +1278,7 @@ int32_t animateSpringGroup(
1423
1278
  uint64_t controllerId,
1424
1279
  std::vector<GroupMotionEntry> entries,
1425
1280
  SpringAnimation animation,
1426
- GroupSuspensionPolicy suspensionPolicy,
1281
+ int32_t completionTag,
1427
1282
  double startedAtHintS) {
1428
1283
  return startGroupCommon(
1429
1284
  controllerId,
@@ -1433,26 +1288,7 @@ int32_t animateSpringGroup(
1433
1288
  animation,
1434
1289
  kSpringMaxDurationS * 1000.0,
1435
1290
  animation.reduceMotion,
1436
- suspensionPolicy,
1437
- startedAtHintS);
1438
- }
1439
-
1440
- int32_t animateKeyframesGroup(
1441
- uint64_t controllerId,
1442
- std::vector<GroupMotionEntry> entries,
1443
- double durationMs,
1444
- int32_t reduceMotion,
1445
- GroupSuspensionPolicy suspensionPolicy,
1446
- double startedAtHintS) {
1447
- return startGroupCommon(
1448
- controllerId,
1449
- std::move(entries),
1450
- AnimationKind::Keyframes,
1451
- {},
1452
- {},
1453
- durationMs,
1454
- reduceMotion,
1455
- suspensionPolicy,
1291
+ completionTag,
1456
1292
  startedAtHintS);
1457
1293
  }
1458
1294
 
@@ -1472,6 +1308,7 @@ void setPresentation(
1472
1308
  bool overridePendingAnimation,
1473
1309
  bool recordVelocity) {
1474
1310
  if (!isOnMainThread()) return;
1311
+ if (!canonicalizePresentation(presentation)) return;
1475
1312
  SMOOTH_CLIP_TRACE("SmoothClip.setPresentation");
1476
1313
  auto iterator = registry().find(driverId);
1477
1314
  if (!takeOwnership) {
@@ -1493,7 +1330,7 @@ void setPresentation(
1493
1330
  state.destroyed = false;
1494
1331
  // Passive seeds and public setters must not displace newer pending intent.
1495
1332
  // The fused animation.from write is different: it is the caller's newest,
1496
- // authoritative start and explicitly opts in to cancelling the old latch.
1333
+ // authoritative start and explicitly cancels the pending run.
1497
1334
  if (state.animation.has_value() && !state.animation->started &&
1498
1335
  !overridePendingAnimation) {
1499
1336
  return;
@@ -1504,7 +1341,8 @@ void setPresentation(
1504
1341
  state.hasLatest = true;
1505
1342
  state.ownership = Ownership::Interactive;
1506
1343
  if (recordVelocity) {
1507
- recordVelocitySample(state.samples, toChannels(presentation), nowSeconds());
1344
+ recordVelocitySample(
1345
+ state.samples, velocityChannels(presentation), nowSeconds());
1508
1346
  } else if (state.samples.hasLatest) {
1509
1347
  // Untracked movement invalidates the recorded velocity pair.
1510
1348
  clearVelocitySamples(state.samples);
@@ -1526,6 +1364,7 @@ Presentation beginInteraction(uint64_t driverId) {
1526
1364
  return state.latest;
1527
1365
  }
1528
1366
  const int32_t animationId = state.animation->id;
1367
+ const int32_t completionTag = state.animation->completionTag;
1529
1368
  const Presentation current = state.animation->current;
1530
1369
  clearActiveAnimation(driverId, state);
1531
1370
  state.latest = current;
@@ -1534,7 +1373,7 @@ Presentation beginInteraction(uint64_t driverId) {
1534
1373
  // The frozen frame came from native animation movement, not an interactive
1535
1374
  // write. Do not let it become a later `initialVelocity: inherit` sample.
1536
1375
  applyToViews(state, current);
1537
- emitCompletion(driverId, animationId, false);
1376
+ emitCompletion(driverId, animationId, completionTag, false);
1538
1377
  return current;
1539
1378
  }
1540
1379
 
@@ -1543,29 +1382,22 @@ int32_t animateTiming(
1543
1382
  AnimationStart start,
1544
1383
  Presentation presentation,
1545
1384
  TimingAnimation animation,
1546
- AnimationValidationMode validationMode) {
1547
- const bool strictV2 =
1548
- validationMode == AnimationValidationMode::ProtocolV2;
1549
- const bool validAnimation = strictV2
1550
- ? isValidProtocolV2Timing(animation)
1551
- : std::isfinite(animation.durationMs) &&
1552
- std::isfinite(animation.controlPoint1X) &&
1553
- std::isfinite(animation.controlPoint1Y) &&
1554
- std::isfinite(animation.controlPoint2X) &&
1555
- std::isfinite(animation.controlPoint2Y) &&
1556
- isValidReduceMotionCode(animation.reduceMotion);
1557
- if (!strictV2 && validAnimation) {
1558
- animation.durationMs = std::max(0.0, animation.durationMs);
1385
+ int32_t completionTag) {
1386
+ const bool validAnimation = isValidTiming(animation);
1387
+ if (!canonicalizePresentation(presentation) ||
1388
+ (start.hasInteractiveStart &&
1389
+ !canonicalizePresentation(start.interactiveStart))) {
1390
+ return 0;
1559
1391
  }
1560
1392
  if (!isOnMainThread() || driverId == 0 ||
1561
- !isFiniteProtocolV2Presentation(presentation) || !validAnimation) {
1393
+ !isFinitePresentation(presentation) || !validAnimation) {
1562
1394
  return 0;
1563
1395
  }
1564
1396
  auto iterator = registry().find(driverId);
1565
1397
  Presentation preflightStart;
1566
1398
  if (iterator == registry().end()) {
1567
1399
  if (!start.hasInteractiveStart ||
1568
- !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1400
+ !isFinitePresentation(start.interactiveStart)) {
1569
1401
  return 0;
1570
1402
  }
1571
1403
  preflightStart = start.interactiveStart;
@@ -1575,17 +1407,12 @@ int32_t animateTiming(
1575
1407
  ? start.interactiveStart
1576
1408
  : visiblePresentation(driverId, iterator->second);
1577
1409
  }
1578
- if (!isFiniteProtocolV2Presentation(preflightStart) ||
1410
+ if (!isFinitePresentation(preflightStart) ||
1579
1411
  preflightStart.clip.curve != presentation.clip.curve) {
1580
1412
  return 0;
1581
1413
  }
1582
1414
  const bool reduce = shouldReduceMotion(animation.reduceMotion) ||
1583
1415
  animation.durationMs <= 0;
1584
- if (strictV2 && !reduce && iterator != registry().end() &&
1585
- !timingPlanPreservesLinearHostNormalization(
1586
- iterator->second, preflightStart, presentation, animation)) {
1587
- return 0;
1588
- }
1589
1416
  if (iterator == registry().end()) {
1590
1417
  iterator = registry().try_emplace(driverId).first;
1591
1418
  iterator->second.latest = start.interactiveStart;
@@ -1599,11 +1426,12 @@ int32_t animateTiming(
1599
1426
  prepareAnimation(driverId, state, start, presentation);
1600
1427
  if (reduce) {
1601
1428
  applyPresentation(state, presentation);
1602
- emitCompletion(driverId, animationId, true);
1429
+ emitCompletion(driverId, animationId, completionTag, true);
1603
1430
  return animationId;
1604
1431
  }
1605
1432
  ActiveAnimation active;
1606
1433
  active.id = animationId;
1434
+ active.completionTag = completionTag;
1607
1435
  active.kind = AnimationKind::Timing;
1608
1436
  active.timing = animation;
1609
1437
  active.start = resolvedStart;
@@ -1617,30 +1445,22 @@ int32_t animateSpring(
1617
1445
  AnimationStart start,
1618
1446
  Presentation presentation,
1619
1447
  SpringAnimation animation,
1620
- AnimationValidationMode validationMode) {
1621
- const bool strictV2 =
1622
- validationMode == AnimationValidationMode::ProtocolV2;
1623
- const bool validAnimation = strictV2
1624
- ? isValidProtocolV2Spring(animation)
1625
- : std::isfinite(animation.mass) &&
1626
- std::isfinite(animation.stiffness) &&
1627
- std::isfinite(animation.damping) &&
1628
- std::isfinite(animation.initialVelocity) &&
1629
- isValidReduceMotionCode(animation.reduceMotion);
1630
- if (!strictV2 && validAnimation) {
1631
- animation.mass = std::max(0.0001, animation.mass);
1632
- animation.stiffness = std::max(0.0001, animation.stiffness);
1633
- animation.damping = std::max(0.0, animation.damping);
1448
+ int32_t completionTag) {
1449
+ const bool validAnimation = isValidSpring(animation);
1450
+ if (!canonicalizePresentation(presentation) ||
1451
+ (start.hasInteractiveStart &&
1452
+ !canonicalizePresentation(start.interactiveStart))) {
1453
+ return 0;
1634
1454
  }
1635
1455
  if (!isOnMainThread() || driverId == 0 ||
1636
- !isFiniteProtocolV2Presentation(presentation) || !validAnimation) {
1456
+ !isFinitePresentation(presentation) || !validAnimation) {
1637
1457
  return 0;
1638
1458
  }
1639
1459
  auto iterator = registry().find(driverId);
1640
1460
  Presentation preflightStart;
1641
1461
  if (iterator == registry().end()) {
1642
1462
  if (!start.hasInteractiveStart ||
1643
- !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1463
+ !isFinitePresentation(start.interactiveStart)) {
1644
1464
  return 0;
1645
1465
  }
1646
1466
  preflightStart = start.interactiveStart;
@@ -1650,10 +1470,17 @@ int32_t animateSpring(
1650
1470
  ? start.interactiveStart
1651
1471
  : visiblePresentation(driverId, iterator->second);
1652
1472
  }
1653
- if (!isFiniteProtocolV2Presentation(preflightStart) ||
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) ||
1654
1481
  preflightStart.clip.curve != presentation.clip.curve ||
1655
- (strictV2 && !protocolV2SpringScaleIsProvablyPositive(
1656
- preflightStart, presentation, animation))) {
1482
+ !springScaleStaysPositive(
1483
+ preflightStart, presentation, animation, velocity)) {
1657
1484
  return 0;
1658
1485
  }
1659
1486
  if (iterator == registry().end()) {
@@ -1668,18 +1495,16 @@ int32_t animateSpring(
1668
1495
  // Scalar velocity along the current-to-target trajectory; each channel is
1669
1496
  // seeded with velocity·displacement to match the iOS CASpringAnimation
1670
1497
  // per-keypath behavior.
1671
- const double velocity = animation.inheritVelocity
1672
- ? inheritedVelocity(state.samples, toChannels(presentation), nowSeconds())
1673
- : animation.initialVelocity;
1674
1498
  const Presentation resolvedStart =
1675
1499
  prepareAnimation(driverId, state, start, presentation);
1676
1500
  if (shouldReduceMotion(animation.reduceMotion)) {
1677
1501
  applyPresentation(state, presentation);
1678
- emitCompletion(driverId, animationId, true);
1502
+ emitCompletion(driverId, animationId, completionTag, true);
1679
1503
  return animationId;
1680
1504
  }
1681
1505
  ActiveAnimation active;
1682
1506
  active.id = animationId;
1507
+ active.completionTag = completionTag;
1683
1508
  active.kind = AnimationKind::Spring;
1684
1509
  active.spring = animation;
1685
1510
  active.spring.initialVelocity = velocity;
@@ -1687,106 +1512,31 @@ int32_t animateSpring(
1687
1512
  active.start = resolvedStart;
1688
1513
  active.target = presentation;
1689
1514
  active.durationS = kSpringMaxDurationS;
1690
- const auto startChannels = toChannels(resolvedStart);
1691
- const auto targetChannels = toChannels(presentation);
1692
- for (std::size_t index = 0; index < kChannelCount; index += 1) {
1693
- 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) {
1694
1528
  active.springVelocity[index] =
1695
1529
  velocity * (targetChannels[index] - startChannels[index]);
1696
1530
  }
1697
1531
  return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
1698
1532
  }
1699
1533
 
1700
- int32_t animateKeyframes(
1701
- uint64_t driverId,
1702
- AnimationStart start,
1703
- Presentation presentation,
1704
- double durationMs,
1705
- std::vector<Keyframe> keyframes,
1706
- int32_t reduceMotion,
1707
- AnimationValidationMode validationMode) {
1708
- const bool strictV2 =
1709
- validationMode == AnimationValidationMode::ProtocolV2;
1710
- const bool finiteDuration = std::isfinite(durationMs);
1711
- if (!strictV2 && finiteDuration) durationMs = std::max(0.0, durationMs);
1712
- if (!isOnMainThread() || driverId == 0 ||
1713
- !isFiniteProtocolV2Presentation(presentation) ||
1714
- !finiteDuration || (strictV2 && durationMs < 0) ||
1715
- !isValidReduceMotionCode(reduceMotion)) {
1716
- return 0;
1717
- }
1718
- auto iterator = registry().find(driverId);
1719
- Presentation preflightStart;
1720
- if (iterator == registry().end()) {
1721
- if (!start.hasInteractiveStart ||
1722
- !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1723
- return 0;
1724
- }
1725
- preflightStart = start.interactiveStart;
1726
- } else {
1727
- if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1728
- preflightStart = start.hasInteractiveStart
1729
- ? start.interactiveStart
1730
- : visiblePresentation(driverId, iterator->second);
1731
- }
1732
- bool validKeyframes = keyframes.size() >= 2 &&
1733
- keyframes.front().offset == 0 && keyframes.back().offset == 1;
1734
- double previousOffset = -1;
1735
- for (const Keyframe &keyframe : keyframes) {
1736
- validKeyframes = validKeyframes && std::isfinite(keyframe.offset) &&
1737
- keyframe.offset > previousOffset && keyframe.offset >= 0 &&
1738
- keyframe.offset <= 1 &&
1739
- isFiniteProtocolV2Presentation(keyframe.presentation);
1740
- previousOffset = keyframe.offset;
1741
- }
1742
- if (strictV2) {
1743
- validKeyframes = isValidProtocolV2Keyframes(
1744
- keyframes, preflightStart, presentation, start.hasInteractiveStart);
1745
- }
1746
- if (!isFiniteProtocolV2Presentation(preflightStart) || !validKeyframes) {
1747
- return 0;
1748
- }
1749
- const bool reduce = shouldReduceMotion(reduceMotion) || durationMs <= 0;
1750
- if (strictV2 && !reduce && iterator != registry().end() &&
1751
- !keyframePlanPreservesLinearHostNormalization(
1752
- iterator->second, preflightStart, keyframes)) {
1753
- return 0;
1754
- }
1755
- if (iterator == registry().end()) {
1756
- iterator = registry().try_emplace(driverId).first;
1757
- iterator->second.latest = start.interactiveStart;
1758
- iterator->second.hasLatest = true;
1759
- }
1760
- auto &state = iterator->second;
1761
- if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
1762
- state.destroyed = false;
1763
- const int32_t animationId = allocateAnimationId();
1764
- const Presentation resolvedStart =
1765
- prepareAnimation(driverId, state, start, presentation);
1766
- if (reduce) {
1767
- applyPresentation(state, presentation);
1768
- emitCompletion(driverId, animationId, true);
1769
- return animationId;
1770
- }
1771
- if (strictV2 && !start.hasInteractiveStart) {
1772
- keyframes.front().presentation = resolvedStart;
1773
- }
1774
- ActiveAnimation active;
1775
- active.id = animationId;
1776
- active.kind = AnimationKind::Keyframes;
1777
- active.keyframes.reset(std::move(keyframes));
1778
- active.start = resolvedStart;
1779
- active.target = presentation;
1780
- active.durationS = durationMs / 1000.0;
1781
- return startAnimation(driverId, state, std::move(active), start.startedAtHintS);
1782
- }
1783
-
1784
1534
  int32_t rejectAnimation(uint64_t driverId) {
1785
1535
  if (!isOnMainThread()) return 0;
1786
1536
  auto iterator = registry().find(driverId);
1787
1537
  if (iterator == registry().end()) return 0;
1788
1538
  const int32_t animationId = allocateAnimationId();
1789
- emitCompletion(driverId, animationId, false);
1539
+ emitCompletion(driverId, animationId, 0, false);
1790
1540
  return animationId;
1791
1541
  }
1792
1542
 
@@ -1820,12 +1570,13 @@ CancelResult cancelAnimation(
1820
1570
  const Presentation result =
1821
1571
  useTarget ? state.animation->target : state.animation->current;
1822
1572
  const int32_t activeId = state.animation->id;
1573
+ const int32_t completionTag = state.animation->completionTag;
1823
1574
  clearActiveAnimation(driverId, state);
1824
1575
  state.latest = result;
1825
1576
  state.hasLatest = true;
1826
1577
  state.ownership = Ownership::Interactive;
1827
1578
  applyToViews(state, result);
1828
- emitCompletion(driverId, activeId, false);
1579
+ emitCompletion(driverId, activeId, completionTag, false);
1829
1580
  return {true, result};
1830
1581
  }
1831
1582
 
@@ -1856,23 +1607,6 @@ void destroyDriver(uint64_t driverId) {
1856
1607
  // --- Android view lifecycle ----------------------------------------------
1857
1608
 
1858
1609
  void JSmoothClipView::applyClip(const Presentation &presentation) const {
1859
- static const auto method =
1860
- javaClassStatic()
1861
- ->getMethod<void(
1862
- jdouble, jdouble, jdouble, jdouble, jdouble, jdouble, jdouble)>(
1863
- "setClipPresentationDip");
1864
- method(
1865
- self(),
1866
- presentation.clip.x,
1867
- presentation.clip.y,
1868
- presentation.clip.width,
1869
- presentation.clip.height,
1870
- presentation.clip.radius,
1871
- presentation.contentTranslateX,
1872
- presentation.contentTranslateY);
1873
- }
1874
-
1875
- void JSmoothClipView::applyClipV2(const Presentation &presentation) const {
1876
1610
  static const auto method =
1877
1611
  javaClassStatic()
1878
1612
  ->getMethod<void(
@@ -1887,7 +1621,16 @@ void JSmoothClipView::applyClipV2(const Presentation &presentation) const {
1887
1621
  jint,
1888
1622
  jdouble,
1889
1623
  jdouble,
1890
- jdouble)>("setClipPresentationV2Dip");
1624
+ jdouble,
1625
+ jboolean,
1626
+ jdouble,
1627
+ jdouble,
1628
+ jdouble,
1629
+ jdouble,
1630
+ jdouble,
1631
+ jdouble,
1632
+ jdouble,
1633
+ jdouble)>("setClipPresentationDip");
1891
1634
  method(
1892
1635
  self(),
1893
1636
  presentation.clip.x,
@@ -1905,34 +1648,24 @@ void JSmoothClipView::applyClipV2(const Presentation &presentation) const {
1905
1648
  static_cast<jint>(presentation.clip.curve),
1906
1649
  presentation.contentTranslateX,
1907
1650
  presentation.contentTranslateY,
1908
- presentation.contentScale);
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);
1909
1661
  }
1910
1662
 
1911
1663
  void JSmoothClipView::applyClipPx(
1912
- const NormalizedClip &clip,
1913
- double contentTranslateXPx,
1914
- double contentTranslateYPx) const {
1915
- static const auto method =
1916
- javaClassStatic()
1917
- ->getMethod<void(
1918
- jfloat, jfloat, jfloat, jfloat, jfloat, jfloat, jfloat)>(
1919
- "setClipPresentationPx");
1920
- method(
1921
- self(),
1922
- static_cast<jfloat>(clip.left),
1923
- static_cast<jfloat>(clip.top),
1924
- static_cast<jfloat>(clip.right),
1925
- static_cast<jfloat>(clip.bottom),
1926
- static_cast<jfloat>(clip.radius),
1927
- static_cast<jfloat>(contentTranslateXPx),
1928
- static_cast<jfloat>(contentTranslateYPx));
1929
- }
1930
-
1931
- void JSmoothClipView::applyClipV2Px(
1932
- const NormalizedClip &clip,
1664
+ const CanonicalClip &clip,
1933
1665
  double contentTranslateXPx,
1934
1666
  double contentTranslateYPx,
1935
- double contentScale) const {
1667
+ double contentScale,
1668
+ const Shadow &shadowPx) const {
1936
1669
  static const auto method =
1937
1670
  javaClassStatic()
1938
1671
  ->getMethod<void(
@@ -1947,7 +1680,16 @@ void JSmoothClipView::applyClipV2Px(
1947
1680
  jint,
1948
1681
  jfloat,
1949
1682
  jfloat,
1950
- jfloat)>("setClipPresentationV2Px");
1683
+ jfloat,
1684
+ jboolean,
1685
+ jfloat,
1686
+ jfloat,
1687
+ jfloat,
1688
+ jfloat,
1689
+ jfloat,
1690
+ jfloat,
1691
+ jfloat,
1692
+ jfloat)>("setClipPresentationPx");
1951
1693
  method(
1952
1694
  self(),
1953
1695
  static_cast<jfloat>(clip.left),
@@ -1961,7 +1703,22 @@ void JSmoothClipView::applyClipV2Px(
1961
1703
  static_cast<jint>(clip.curve),
1962
1704
  static_cast<jfloat>(contentTranslateXPx),
1963
1705
  static_cast<jfloat>(contentTranslateYPx),
1964
- static_cast<jfloat>(contentScale));
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);
1965
1722
  }
1966
1723
 
1967
1724
  void registerViewAndroid(
@@ -1972,6 +1729,7 @@ void registerViewAndroid(
1972
1729
  double hostWidthPx,
1973
1730
  double hostHeightPx,
1974
1731
  bool lifecycleVisible) {
1732
+ if (!canonicalizePresentation(initialPresentation)) return;
1975
1733
  auto &state = registry()[driverId];
1976
1734
  state.destroyed = false;
1977
1735
  if (!state.hasLatest) {
@@ -1992,6 +1750,10 @@ void registerViewAndroid(
1992
1750
  return;
1993
1751
  }
1994
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;
1995
1757
  ViewEntry entry{
1996
1758
  facebook::jni::make_global(view),
1997
1759
  density,
@@ -1999,8 +1761,7 @@ void registerViewAndroid(
1999
1761
  hostHeightPx,
2000
1762
  lifecycleVisible,
2001
1763
  ViewParticipation::Deferred};
2002
- // `visible` above already delivered animation->current (= start) to the
2003
- // registering view — the correct first frame for a latched animation.
1764
+ // `visible` already contains the start of a pre-ready animation.
2004
1765
  deliverToView(entry, visible);
2005
1766
  state.views.push_back(std::move(entry));
2006
1767
  reconcileViewDisplayability(driverId, state, state.views.back());
@@ -2057,12 +1818,9 @@ void unregisterViewAndroid(
2057
1818
  if (iterator == registry().end()) return;
2058
1819
  auto &state = iterator->second;
2059
1820
  bool removed = false;
2060
- bool removedParticipant = false;
2061
1821
  JNIEnv *env = facebook::jni::Environment::current();
2062
1822
  for (auto view_it = state.views.begin(); view_it != state.views.end();) {
2063
1823
  if (env->IsSameObject(view_it->view.get(), view.get())) {
2064
- removedParticipant = removedParticipant ||
2065
- view_it->participation != ViewParticipation::Deferred;
2066
1824
  view_it = state.views.erase(view_it);
2067
1825
  removed = true;
2068
1826
  } else {
@@ -2070,10 +1828,6 @@ void unregisterViewAndroid(
2070
1828
  }
2071
1829
  }
2072
1830
  if (removed && state.animation.has_value()) {
2073
- // Only a host that actually joined (or is unresolved after suspension)
2074
- // affects completion. A detached/unsized registration that stayed deferred
2075
- // never installed or rendered this animation.
2076
- if (removedParticipant) state.animation->finished = false;
2077
1831
  if (!isOnMainThread()) {
2078
1832
  // Blast-radius reduction, NOT a synchronization fix — be clear about
2079
1833
  // which. Both call sites (SmoothClipViewManager.onAfterUpdateTransaction
@@ -2087,7 +1841,7 @@ void unregisterViewAndroid(
2087
1841
  // for the next main-thread event (attach, host geometry, destroy) to
2088
1842
  // resolve.
2089
1843
  } else {
2090
- relatchIfNoDisplayable(driverId, state);
1844
+ finishIfHostUnavailable(driverId, state);
2091
1845
  }
2092
1846
  }
2093
1847
  if (removed && state.groupId != 0 && isOnMainThread()) {