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
@@ -43,20 +43,11 @@
43
43
  animation:(smoothclip::SpringAnimation)animation
44
44
  animationId:(int32_t)animationId
45
45
  sharedBeginTime:(CFTimeInterval)sharedBeginTime;
46
- - (BOOL)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
47
- keyframes:(const std::vector<smoothclip::Keyframe> &)keyframes
48
- durationMs:(double)durationMs
49
- animationId:(int32_t)animationId;
50
- - (BOOL)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
51
- keyframes:(const std::vector<smoothclip::Keyframe> &)keyframes
52
- durationMs:(double)durationMs
53
- animationId:(int32_t)animationId
54
- sharedBeginTime:(CFTimeInterval)sharedBeginTime;
55
46
  - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
56
47
  - (BOOL)smoothClipIsJoinable;
57
48
  - (BOOL)smoothClipCanDisplay;
58
- - (BOOL)smoothClipHasPendingInstall;
59
- - (double)smoothClipSpringContinuationVelocity;
49
+ - (double)smoothClipInheritedVelocityToPresentation:
50
+ (smoothclip::Presentation)presentation;
60
51
  - (void)smoothClipClearVelocitySamples;
61
52
  @end
62
53
 
@@ -66,18 +57,15 @@ namespace {
66
57
  using ViewKey = uintptr_t;
67
58
 
68
59
  enum class Ownership { Interactive, Native };
69
- enum class AnimationKind { Timing, Spring, Keyframes };
60
+ enum class AnimationKind { Timing, Spring };
70
61
 
71
62
  struct ActiveAnimation {
72
63
  int32_t animationId;
73
- // Zero identifies a legacy/single-driver animation. A non-zero value binds
64
+ int32_t completionTag = 0;
65
+ // Zero identifies a single-driver animation. A non-zero value binds
74
66
  // the driver to an immutable group whose completion is aggregated once.
75
67
  int32_t groupId = 0;
76
68
  std::unordered_set<ViewKey> participants;
77
- // Hosts that installed this id and temporarily lost displayability. They
78
- // may rejoin the same remainder; completion is false if any remain
79
- // unresolved when the run ends or unregister.
80
- std::unordered_set<ViewKey> suspendedParticipants;
81
69
  bool finished = true;
82
70
  AnimationKind kind = AnimationKind::Timing;
83
71
  // Geometry the transition started from. Used when a view must join the
@@ -85,16 +73,8 @@ struct ActiveAnimation {
85
73
  Presentation start{{0, 0, 0, 0, 0}, 0, 0};
86
74
  TimingAnimation timing{};
87
75
  SpringAnimation spring{};
88
- std::vector<Keyframe> keyframes;
89
- double durationMs = 0;
90
- CFTimeInterval startedAt = 0;
91
- // False while the animation is latched. Two ways in: built before any host
92
- // view could display (pre-registration, or a host in a detached subtree), or
93
- // re-latched by unregisterView when the last DISPLAYABLE host left
94
- // mid-flight. Started by whichever registration/displayability update or
95
- // joinActiveAnimation first finds a host that can produce a frame; each
96
- // rebases startedAt through startLatchedAnimation. startedAt is only
97
- // meaningful once started.
76
+ // False while a pre-ready run waits for its first displayable host. Host
77
+ // loss after the run starts finishes it at the target.
98
78
  bool started = false;
99
79
  };
100
80
 
@@ -123,9 +103,9 @@ struct GroupCompletionSink {
123
103
  struct GroupState {
124
104
  uint64_t controllerId;
125
105
  int32_t groupId;
106
+ int32_t completionTag;
126
107
  std::vector<uint64_t> driverIds;
127
108
  std::unordered_set<uint64_t> remainingDriverIds;
128
- GroupSuspensionPolicy suspensionPolicy = GroupSuspensionPolicy::Pause;
129
109
  bool finished = true;
130
110
  bool mutating = false;
131
111
  };
@@ -203,7 +183,8 @@ std::vector<DriverSnapshot> cancelGroupInternal(
203
183
  GroupCancelBehavior behavior,
204
184
  bool completionFinished);
205
185
  void tryStartGroup(int32_t groupId);
206
- void suspendGroup(int32_t groupId);
186
+ void finishGroupForHostLoss(int32_t groupId);
187
+ DriverSnapshot snapshotForDriver(uint64_t driverId);
207
188
 
208
189
  Presentation unavailablePresentation() {
209
190
  // Non-finite geometry makes the JS side fall back to its current value
@@ -240,6 +221,9 @@ void emitCompletion(
240
221
  const int32_t groupId = state.animation.has_value()
241
222
  ? state.animation->groupId
242
223
  : 0;
224
+ const int32_t completionTag = state.animation.has_value()
225
+ ? state.animation->completionTag
226
+ : 0;
243
227
  state.animation.reset();
244
228
  if (groupId != 0) {
245
229
  groupMemberCompleted(groupId, driverId, finished);
@@ -248,7 +232,8 @@ void emitCompletion(
248
232
  {
249
233
  std::lock_guard<std::mutex> lock(completionSinkMutex());
250
234
  if (completionSink().callback) {
251
- completionSink().callback(driverId, animationId, finished);
235
+ completionSink().callback(
236
+ driverId, animationId, completionTag, finished);
252
237
  }
253
238
  }
254
239
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -262,12 +247,22 @@ void emitCompletion(
262
247
  void emitGroupCompletion(
263
248
  uint64_t controllerId,
264
249
  int32_t groupId,
250
+ int32_t completionTag,
265
251
  bool finished,
266
252
  const std::vector<uint64_t> &driverIds) {
253
+ std::vector<DriverSnapshot> snapshots;
254
+ snapshots.reserve(driverIds.size());
255
+ for (const uint64_t driverId : driverIds) {
256
+ snapshots.push_back(snapshotForDriver(driverId));
257
+ }
267
258
  std::lock_guard<std::mutex> lock(completionSinkMutex());
268
259
  if (groupCompletionSink().callback) {
269
260
  groupCompletionSink().callback(
270
- controllerId, groupId, finished, driverIds);
261
+ controllerId,
262
+ groupId,
263
+ completionTag,
264
+ finished,
265
+ std::move(snapshots));
271
266
  }
272
267
  }
273
268
 
@@ -283,21 +278,28 @@ void groupMemberCompleted(
283
278
  if (!group.remainingDriverIds.empty() || group.mutating) return;
284
279
 
285
280
  const uint64_t controllerId = group.controllerId;
281
+ const int32_t completionTag = group.completionTag;
286
282
  const bool aggregateFinished = group.finished;
287
283
  const std::vector<uint64_t> driverIds = group.driverIds;
288
284
  groupRegistry().erase(iterator);
289
285
  emitGroupCompletion(
290
- controllerId, groupId, aggregateFinished, driverIds);
286
+ controllerId,
287
+ groupId,
288
+ completionTag,
289
+ aggregateFinished,
290
+ driverIds);
291
291
  }
292
292
 
293
293
  void emitStandaloneCompletion(
294
294
  uint64_t driverId,
295
295
  int32_t animationId,
296
+ int32_t completionTag,
296
297
  bool finished) {
297
298
  {
298
299
  std::lock_guard<std::mutex> lock(completionSinkMutex());
299
300
  if (completionSink().callback) {
300
- completionSink().callback(driverId, animationId, finished);
301
+ completionSink().callback(
302
+ driverId, animationId, completionTag, finished);
301
303
  }
302
304
  }
303
305
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -311,7 +313,7 @@ void emitStandaloneCompletion(
311
313
  Presentation canonicalFrozenPresentation(
312
314
  DriverState &state,
313
315
  SmoothClipView *preferred = nil) {
314
- // A latched animation never rendered, so state.latest already holds its
316
+ // A pre-ready animation never rendered, so state.latest already holds its
315
317
  // target; freezing there would jump the clip. Freeze at the start instead.
316
318
  Presentation canonical =
317
319
  state.animation.has_value() && !state.animation->started
@@ -333,13 +335,12 @@ Presentation canonicalFrozenPresentation(
333
335
  canonical = frozen;
334
336
  hasCanonical = true;
335
337
  } else if (!hasParticipantFallback && state.animation.has_value() &&
336
- (state.animation->participants.count(key) > 0 ||
337
- state.animation->suspendedParticipants.count(key) > 0)) {
338
+ state.animation->participants.count(key) > 0) {
338
339
  // An installed participant whose HOST just lost its size still holds
339
340
  // real mid-flight geometry on its clip layer (host bounds do not feed
340
341
  // the clip container). Without this fallback, a sole running host
341
- // resized to zero froze the re-latch at state.latest — the target —
342
- // and the resumed animation snapped to its endpoint.
342
+ // resized to zero must still freeze from its real clip layer rather
343
+ // than state.latest, which already holds the target.
343
344
  hasParticipantFallback = true;
344
345
  participantFallback = frozen;
345
346
  }
@@ -371,65 +372,18 @@ bool driverReady(const DriverState &state) {
371
372
  return false;
372
373
  }
373
374
 
374
- bool presentationFitsEveryKnownHostWithoutNormalization(
375
+ double resolvedSpringVelocity(
375
376
  const DriverState &state,
376
- const Presentation &presentation) {
377
+ const Presentation &target,
378
+ const SpringAnimation &spring) {
379
+ if (!spring.inheritVelocity) return spring.initialVelocity;
377
380
  for (const ViewKey key : state.views) {
378
381
  SmoothClipView *view = viewForKey(key);
379
- // A pre-layout host has no authoritative bounds yet. It remains covered by
380
- // the existing readiness latch; fixed-host promotion reaches this path
381
- // only after its participating host has laid out.
382
- if (![view smoothClipIsJoinable]) continue;
383
- const CGSize hostSize = view.bounds.size;
384
- if (!SmoothClipGeometryNormalizationIsIdentity(
385
- presentation.clip, hostSize.width, hostSize.height)) {
386
- return false;
387
- }
388
- }
389
- return true;
390
- }
391
-
392
- bool timingPlanPreservesLinearHostNormalization(
393
- const DriverState &state,
394
- const Presentation &start,
395
- const Presentation &target,
396
- const TimingAnimation &animation) {
397
- // A cubic Bezier stays inside the convex hull of its Y control points. The
398
- // bound is deliberately conservative: if an overshooting easing still fits
399
- // at both extrema, every presentation it can generate fits too because all
400
- // host/radius eligibility inequalities are linear.
401
- const double minimumProgress = std::min(
402
- {0.0, 1.0, animation.controlPoint1Y, animation.controlPoint2Y});
403
- const double maximumProgress = std::max(
404
- {0.0, 1.0, animation.controlPoint1Y, animation.controlPoint2Y});
405
- const Presentation minimum =
406
- interpolate(start, target, minimumProgress);
407
- const Presentation maximum =
408
- interpolate(start, target, maximumProgress);
409
- return isFiniteProtocolV2Presentation(minimum) &&
410
- isFiniteProtocolV2Presentation(maximum) &&
411
- presentationFitsEveryKnownHostWithoutNormalization(state, minimum) &&
412
- presentationFitsEveryKnownHostWithoutNormalization(state, maximum);
413
- }
414
-
415
- bool keyframePlanPreservesLinearHostNormalization(
416
- const DriverState &state,
417
- const Presentation &resolvedStart,
418
- const std::vector<Keyframe> &keyframes) {
419
- if (!presentationFitsEveryKnownHostWithoutNormalization(
420
- state, resolvedStart)) {
421
- return false;
422
- }
423
- // Frame zero is replaced by resolvedStart for an implicit native retarget.
424
- // Every later anchor must be identity-normalized; convex segment-wise linear
425
- // interpolation then cannot cross a host boundary or activate radius scaling.
426
- for (std::size_t index = 1; index < keyframes.size(); index += 1) {
427
- if (!presentationFitsEveryKnownHostWithoutNormalization(
428
- state, keyframes[index].presentation)) {
429
- return false;
382
+ if ([view smoothClipIsJoinable]) {
383
+ return [view smoothClipInheritedVelocityToPresentation:target];
430
384
  }
431
385
  }
432
- return true;
386
+ return 0;
433
387
  }
434
388
 
435
389
  DriverSnapshot snapshotForDriver(uint64_t driverId) {
@@ -476,11 +430,11 @@ std::vector<DriverSnapshot> cancelGroupInternal(
476
430
  Presentation resolved;
477
431
  if (behavior == GroupCancelBehavior::Finish) {
478
432
  resolved = state.latest;
479
- const bool wasLatched = !state.animation->started;
433
+ const bool wasPending = !state.animation->started;
480
434
  for (const ViewKey key : state.views) {
481
435
  [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
482
436
  }
483
- if (wasLatched) {
437
+ if (wasPending) {
484
438
  for (const ViewKey key : state.views) {
485
439
  [viewForKey(key) smoothClipApplyPresentation:resolved
486
440
  recordVelocitySample:NO];
@@ -504,12 +458,17 @@ std::vector<DriverSnapshot> cancelGroupInternal(
504
458
  remainingGroup.mutating = false;
505
459
  if (remainingGroup.remainingDriverIds.empty()) {
506
460
  const uint64_t controllerId = remainingGroup.controllerId;
461
+ const int32_t completionTag = remainingGroup.completionTag;
507
462
  const bool finished = remainingGroup.finished;
508
463
  const std::vector<uint64_t> completedDrivers =
509
464
  remainingGroup.driverIds;
510
465
  groupRegistry().erase(groupIterator);
511
466
  emitGroupCompletion(
512
- controllerId, groupId, finished, completedDrivers);
467
+ controllerId,
468
+ groupId,
469
+ completionTag,
470
+ finished,
471
+ completedDrivers);
513
472
  }
514
473
  }
515
474
  return snapshots;
@@ -538,14 +497,28 @@ void cancelActive(
538
497
  emitCompletion(driverId, state, animationId, false);
539
498
  }
540
499
 
500
+ void finishStandaloneAtTarget(uint64_t driverId, DriverState &state) {
501
+ if (!state.animation.has_value()) return;
502
+ if (state.animation->groupId != 0) {
503
+ cancelGroupInternal(
504
+ state.animation->groupId, GroupCancelBehavior::Finish, true);
505
+ return;
506
+ }
507
+ const int32_t animationId = state.animation->animationId;
508
+ for (const ViewKey key : state.views) {
509
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
510
+ }
511
+ state.ownership = Ownership::Interactive;
512
+ emitCompletion(driverId, state, animationId, true);
513
+ }
514
+
541
515
  void finishIfNoInstalledParticipants(uint64_t driverId, DriverState &state) {
542
516
  if (!state.animation.has_value() || !state.animation->started ||
543
517
  !state.animation->participants.empty()) {
544
518
  return;
545
519
  }
546
520
  const int32_t animationId = state.animation->animationId;
547
- const bool finished = state.animation->finished &&
548
- state.animation->suspendedParticipants.empty();
521
+ const bool finished = state.animation->finished;
549
522
  // A detached/unlaid-out view can have a requested animation without any CA
550
523
  // delegate capable of completing it. Once every installed animation ends,
551
524
  // make those deferred views statically correct and invalidate their pending
@@ -561,6 +534,7 @@ void applyPresentation(
561
534
  DriverState &state,
562
535
  Presentation presentation,
563
536
  bool recordVelocitySample = true) {
537
+ if (!canonicalizePresentation(presentation)) return;
564
538
  state.latest = presentation;
565
539
  state.hasLatest = true;
566
540
  state.ownership = Ownership::Interactive;
@@ -596,9 +570,9 @@ Presentation resolvedAnimationStart(
596
570
  if (start.hasInteractiveStart) {
597
571
  return start.interactiveStart;
598
572
  }
599
- // Replacing a latched animation (e.g. open→close before the host mounted):
573
+ // Replacing a pre-ready animation (e.g. open→close before the host mounted):
600
574
  // it never rendered, and state.latest is its target — start from where the
601
- // clip actually is, the latch's own start.
575
+ // clip actually is, the pending run's own start.
602
576
  if (state.animation.has_value() && !state.animation->started) {
603
577
  return state.animation->start;
604
578
  }
@@ -607,66 +581,28 @@ Presentation resolvedAnimationStart(
607
581
  : state.latest;
608
582
  }
609
583
 
610
- // Installs the remaining part of the active animation on a view that joins
611
- // mid-flight, starting from `visible`. Shared by registerView (mount during
612
- // animation) and joinActiveAnimation (first layout after such a mount).
613
- void dispatchActiveAnimationJoin(
584
+ // Installs a run that waited for its host to become displayable.
585
+ void installPendingAnimation(
614
586
  DriverState &state,
615
587
  SmoothClipView *view,
616
588
  ViewKey key,
617
589
  const Presentation &visible,
618
- SmoothClipView *reference = nil,
619
590
  CFTimeInterval sharedBeginTime = 0) {
620
591
  auto &active = *state.animation;
621
- const double elapsedMs = sharedBeginTime > 0
622
- ? 0
623
- : (CACurrentMediaTime() - active.startedAt) * 1000.0;
624
- const double remainingMs = MAX(0, active.durationMs - elapsedMs);
625
592
  BOOL installed = NO;
626
593
  if (active.kind == AnimationKind::Timing) {
627
- const double progress = active.durationMs <= 0
628
- ? 1
629
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
630
- TimingAnimation timing = timingRemainder(active.timing, progress).animation;
631
594
  installed = [view smoothClipAnimateTiming:state.latest
632
- animation:timing
595
+ animation:active.timing
633
596
  animationId:active.animationId
634
597
  sharedBeginTime:sharedBeginTime];
635
598
  } else if (active.kind == AnimationKind::Spring) {
636
- SpringAnimation spring = active.spring;
637
- if (reference != nil) {
638
- // A late host must join the spring's CURRENT physical state. Reusing the
639
- // original launch velocity (or resolving `inherit` from the joining
640
- // host's unrelated samples) creates a different spring at the seam.
641
- spring.inheritVelocity = false;
642
- spring.initialVelocity =
643
- [reference smoothClipSpringContinuationVelocity];
644
- }
645
599
  installed = [view smoothClipAnimateSpring:state.latest
646
- animation:spring
600
+ animation:active.spring
647
601
  animationId:active.animationId
648
602
  sharedBeginTime:sharedBeginTime];
649
- } else {
650
- const double progress = active.durationMs <= 0
651
- ? 1
652
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
653
- std::vector<Keyframe> remaining;
654
- remaining.push_back({0, visible});
655
- for (const Keyframe &frame : active.keyframes) {
656
- if (frame.offset <= progress) continue;
657
- const double offset = (frame.offset - progress) / (1 - progress);
658
- remaining.push_back({offset, frame.presentation});
659
- }
660
- if (remaining.size() == 1) remaining.push_back({1, state.latest});
661
- installed = [view smoothClipAnimateKeyframes:state.latest
662
- keyframes:remaining
663
- durationMs:remainingMs
664
- animationId:active.animationId
665
- sharedBeginTime:sharedBeginTime];
666
603
  }
667
604
  if (installed) {
668
605
  active.participants.insert(key);
669
- active.suspendedParticipants.erase(key);
670
606
  }
671
607
  }
672
608
 
@@ -682,89 +618,11 @@ bool anyDisplayableView(const DriverState &state) {
682
618
  return false;
683
619
  }
684
620
 
685
- void relatchActiveAnimation(
686
- uint64_t driverId,
687
- DriverState &state,
688
- SmoothClipView *preferred = nil) {
689
- if (!state.animation.has_value() || !state.animation->started) return;
690
- auto &active = *state.animation;
691
- double springVelocity = 0;
692
- if (active.kind == AnimationKind::Spring) {
693
- SmoothClipView *source = preferred;
694
- if (source == nil ||
695
- active.participants.count(keyForView(source)) == 0) {
696
- source = active.participants.empty()
697
- ? nil
698
- : viewForKey(*active.participants.begin());
699
- }
700
- if (source != nil) {
701
- springVelocity = [source smoothClipSpringContinuationVelocity];
702
- }
703
- }
704
- const Presentation frozen = canonicalFrozenPresentation(state, preferred);
705
- // Captured before the migration below: only hosts that were ALREADY
706
- // suspended are unresolved. The active participants being frozen right now
707
- // rendered every frame up to this instant.
708
- const bool hadUnresolvedSuspended = !active.suspendedParticipants.empty();
709
- active.suspendedParticipants.insert(
710
- active.participants.begin(), active.participants.end());
711
- active.participants.clear();
712
-
713
- const double elapsedMs = MAX(
714
- 0, (CACurrentMediaTime() - active.startedAt) * 1000.0);
715
- const double progress = active.durationMs <= 0
716
- ? 1
717
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
718
- if (active.kind == AnimationKind::Timing) {
719
- active.timing = timingRemainder(active.timing, progress).animation;
720
- active.durationMs = active.timing.durationMs;
721
- } else if (active.kind == AnimationKind::Keyframes) {
722
- const KeyframeContinuation continuation = keyframeContinuation(
723
- active.keyframes,
724
- frozen,
725
- state.latest,
726
- active.durationMs,
727
- progress);
728
- active.keyframes = continuation.frames;
729
- active.durationMs = continuation.durationMs;
730
- } else {
731
- active.spring.inheritVelocity = false;
732
- active.spring.initialVelocity = springVelocity;
733
- }
734
-
735
- if (state.destroyed ||
736
- (active.kind != AnimationKind::Spring && active.durationMs <= 0)) {
737
- // A residual trimmed to zero means the curve fully elapsed while a host
738
- // displayed every frame — only the asynchronous didStop was outstanding
739
- // when this freeze arrived. Report that run honestly instead of stamping
740
- // the freeze-path false; hosts that were suspended before the freeze are
741
- // unresolved and still poison it.
742
- const bool ranToEnd = !state.destroyed && progress >= 1 &&
743
- active.finished && !hadUnresolvedSuspended;
744
- // Deferred peers hold pending installs for this id; invalidate them and
745
- // finalize statically at the target, exactly as the didStop completion
746
- // path does. Frozen participants already have no animation to cancel.
747
- for (const ViewKey key : state.views) {
748
- [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
749
- }
750
- state.ownership = Ownership::Interactive;
751
- emitCompletion(driverId, state, active.animationId, ranToEnd);
752
- return;
753
- }
754
- active.start = frozen;
755
- active.started = false;
756
- }
757
-
758
- // Starts a latched animation: rebases the clock so no progress was burned
759
- // while no view could display, then dispatches the join to every registered
760
- // view. Views that still cannot display defer their install per-view and
761
- // resume via a later positive displayability update.
762
- void startLatchedAnimation(DriverState &state) {
621
+ void startPendingAnimation(DriverState &state) {
763
622
  if (state.animation->groupId != 0) {
764
623
  tryStartGroup(state.animation->groupId);
765
624
  return;
766
625
  }
767
- state.animation->startedAt = CACurrentMediaTime();
768
626
  state.animation->started = true;
769
627
  // Participants describe live CA delegates, not registered views. Anything
770
628
  // left from the frozen run can only produce a stale callback for that run.
@@ -773,7 +631,7 @@ void startLatchedAnimation(DriverState &state) {
773
631
  for (const ViewKey key : state.views) {
774
632
  SmoothClipView *participant = viewForKey(key);
775
633
  [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
776
- dispatchActiveAnimationJoin(state, participant, key, start);
634
+ installPendingAnimation(state, participant, key, start);
777
635
  }
778
636
  }
779
637
 
@@ -800,61 +658,26 @@ void tryStartGroup(int32_t groupId) {
800
658
  for (const uint64_t driverId : driverIds) {
801
659
  DriverState &state = registry().find(driverId)->second;
802
660
  ActiveAnimation &active = *state.animation;
803
- active.startedAt = sharedBeginTime;
804
661
  active.started = true;
805
662
  active.participants.clear();
806
663
  const Presentation start = active.start;
807
664
  for (const ViewKey key : state.views) {
808
665
  SmoothClipView *participant = viewForKey(key);
809
666
  [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
810
- dispatchActiveAnimationJoin(
811
- state,
812
- participant,
813
- key,
814
- start,
815
- nil,
816
- sharedBeginTime);
667
+ installPendingAnimation(
668
+ state, participant, key, start, sharedBeginTime);
817
669
  }
818
670
  }
819
671
  [CATransaction commit];
820
672
  }
821
673
 
822
- void suspendGroup(int32_t groupId) {
674
+ void finishGroupForHostLoss(int32_t groupId) {
823
675
  auto groupIterator = groupRegistry().find(groupId);
824
676
  if (groupIterator == groupRegistry().end() ||
825
677
  groupIterator->second.mutating) {
826
678
  return;
827
679
  }
828
- if (groupIterator->second.suspensionPolicy ==
829
- GroupSuspensionPolicy::Finish) {
830
- cancelGroupInternal(
831
- groupId, GroupCancelBehavior::Finish, true);
832
- return;
833
- }
834
-
835
- groupIterator->second.mutating = true;
836
- const std::vector<uint64_t> driverIds = groupIterator->second.driverIds;
837
- for (const uint64_t driverId : driverIds) {
838
- auto stateIterator = registry().find(driverId);
839
- if (stateIterator != registry().end() &&
840
- stateIterator->second.animation.has_value() &&
841
- stateIterator->second.animation->groupId == groupId) {
842
- relatchActiveAnimation(driverId, stateIterator->second);
843
- } else {
844
- groupMemberCompleted(groupId, driverId, false);
845
- }
846
- }
847
-
848
- groupIterator = groupRegistry().find(groupId);
849
- if (groupIterator == groupRegistry().end()) return;
850
- GroupState &group = groupIterator->second;
851
- group.mutating = false;
852
- if (!group.remainingDriverIds.empty()) return;
853
- const uint64_t controllerId = group.controllerId;
854
- const bool finished = group.finished;
855
- const std::vector<uint64_t> completedDrivers = group.driverIds;
856
- groupRegistry().erase(groupIterator);
857
- emitGroupCompletion(controllerId, groupId, finished, completedDrivers);
680
+ cancelGroupInternal(groupId, GroupCancelBehavior::Freeze, false);
858
681
  }
859
682
 
860
683
  } // namespace
@@ -874,11 +697,11 @@ void applicationWillResignActive() {
874
697
  groupIds.push_back(groupId);
875
698
  }
876
699
  for (const int32_t groupId : groupIds) {
877
- suspendGroup(groupId);
700
+ finishGroupForHostLoss(groupId);
878
701
  }
879
702
  for (auto &[driverId, state] : registry()) {
880
703
  if (!state.animation.has_value() || state.animation->groupId == 0) {
881
- relatchActiveAnimation(driverId, state);
704
+ finishStandaloneAtTarget(driverId, state);
882
705
  }
883
706
  }
884
707
  }
@@ -900,7 +723,7 @@ void applicationDidBecomeActive() {
900
723
  (void)driverId;
901
724
  if (state.animation.has_value() && !state.animation->started &&
902
725
  state.animation->groupId == 0 && anyDisplayableView(state)) {
903
- startLatchedAnimation(state);
726
+ startPendingAnimation(state);
904
727
  }
905
728
  }
906
729
  }
@@ -940,8 +763,17 @@ void registerView(
940
763
  SmoothClipView *view,
941
764
  Presentation initialPresentation) {
942
765
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
766
+ if (!canonicalizePresentation(initialPresentation)) return;
943
767
  auto &state = registry()[driverId];
944
768
  state.destroyed = false;
769
+ const ViewKey key = keyForView(view);
770
+ const auto existing = std::find(state.views.begin(), state.views.end(), key);
771
+ if (existing == state.views.end() && !state.views.empty()) {
772
+ #if DEBUG
773
+ NSCAssert(NO, @"One SmoothClip controller cannot drive two mounted hosts");
774
+ #endif
775
+ return;
776
+ }
945
777
  // Seed canonical geometry from the mounting view's initial props BEFORE
946
778
  // sampling `visible`; otherwise the first registration captures the default
947
779
  // empty {0,0,0,0} rect, seeding the view (and the presentation layer the
@@ -951,251 +783,74 @@ void registerView(
951
783
  state.latest = initialPresentation;
952
784
  state.hasLatest = true;
953
785
  }
954
- const bool hasAnimation = state.animation.has_value();
955
- const bool hasLatch = hasAnimation && !state.animation->started;
956
- // A latch may only start once this view can produce a visible frame; a
786
+ const bool hasPendingRun =
787
+ state.animation.has_value() && !state.animation->started;
788
+ // A pending run may only start once this view can produce a visible frame; a
957
789
  // registration from a detached subtree (transparentModal before its view
958
- // controller is presented) keeps the latch held; the positive
790
+ // controller is presented) keeps it waiting; a positive
959
791
  // displayability update starts it inside the attach commit.
960
- const bool startsLatch = hasLatch && [view smoothClipCanDisplay];
961
- const bool shouldReplay =
962
- hasAnimation && state.animation->started && !state.views.empty();
792
+ const bool startsPendingRun =
793
+ hasPendingRun && [view smoothClipCanDisplay];
963
794
  Presentation visible = state.latest;
964
- SmoothClipView *reference = nil;
965
- if (shouldReplay) {
966
- // A registered peer that never laid out reports zero geometry; joining
967
- // from it would visibly collapse the clip. Prefer the first laid-out
968
- // peer and fall back to the animation's start.
969
- for (const ViewKey candidate : state.animation->participants) {
970
- SmoothClipView *candidateView = viewForKey(candidate);
971
- if ([candidateView smoothClipIsJoinable]) {
972
- reference = candidateView;
973
- break;
974
- }
975
- }
976
- visible = reference != nil
977
- ? [reference smoothClipCurrentPresentation]
978
- : state.animation->start;
979
- } else if (hasLatch) {
980
- // state.latest already holds the latch's TARGET; applying it here is
795
+ if (hasPendingRun) {
796
+ // state.latest already holds the target; applying it here is
981
797
  // exactly the jump this path exists to avoid.
982
798
  visible = state.animation->start;
983
799
  }
984
- const ViewKey key = keyForView(view);
985
- if (std::find(state.views.begin(), state.views.end(), key) ==
986
- state.views.end()) {
800
+ if (existing == state.views.end()) {
987
801
  state.views.push_back(key);
988
802
  }
989
803
  [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
990
- if (startsLatch) {
991
- // Rebase the join clock and dispatch to every registered view. Only views
992
- // that actually install their CA groups become completion participants.
993
- startLatchedAnimation(state);
994
- return;
804
+ if (startsPendingRun) {
805
+ startPendingAnimation(state);
995
806
  }
996
- if (!shouldReplay) return;
997
- dispatchActiveAnimationJoin(state, view, key, visible, reference);
998
807
  }
999
808
 
1000
809
  void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
1001
810
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
1002
811
  auto iterator = registry().find(driverId);
1003
812
  if (iterator == registry().end()) return;
1004
- auto &state = iterator->second;
813
+ DriverState &state = iterator->second;
1005
814
  if (!state.animation.has_value()) return;
1006
815
  const ViewKey key = keyForView(view);
1007
816
  if (std::find(state.views.begin(), state.views.end(), key) ==
1008
817
  state.views.end()) {
1009
818
  return;
1010
819
  }
1011
- const int32_t groupId = state.animation->groupId;
1012
- if (groupId != 0) {
1013
- if (![view smoothClipCanDisplay]) {
1014
- if (!state.animation->started) {
1015
- if (!driverReady(state)) suspendGroup(groupId);
1016
- return;
1017
- }
1018
- if (state.animation->participants.count(key) == 0) {
1019
- return;
1020
- }
1021
- if (!driverReady(state)) {
1022
- suspendGroup(groupId);
1023
- return;
1024
- }
1025
- [view smoothClipFreezePresentation];
1026
- if (state.animation.has_value() &&
1027
- state.animation->groupId == groupId) {
1028
- state.animation->participants.erase(key);
1029
- state.animation->suspendedParticipants.insert(key);
1030
- }
1031
- return;
1032
- }
1033
- if (!state.animation->started) {
1034
- tryStartGroup(groupId);
1035
- return;
1036
- }
1037
- if ([view smoothClipHasPendingInstall] ||
1038
- state.animation->suspendedParticipants.count(key) > 0) {
1039
- joinActiveAnimation(driverId, view);
1040
- }
1041
- return;
1042
- }
1043
820
  if (![view smoothClipCanDisplay]) {
1044
- if (!state.animation->started) return;
1045
- if (state.animation->participants.count(key) == 0) return;
1046
- if (!anyDisplayableView(state)) {
1047
- relatchActiveAnimation(driverId, state, view);
1048
- return;
821
+ if (state.animation->groupId != 0 && state.animation->started) {
822
+ finishGroupForHostLoss(state.animation->groupId);
823
+ } else if (state.animation->started) {
824
+ finishStandaloneAtTarget(driverId, state);
1049
825
  }
1050
- [view smoothClipFreezePresentation];
1051
- state.animation->participants.erase(key);
1052
- state.animation->suspendedParticipants.insert(key);
1053
826
  return;
1054
827
  }
1055
828
  if (!state.animation->started) {
1056
- startLatchedAnimation(state);
1057
- return;
1058
- }
1059
- // A deferred or suspended host joins from the registry's rebased remainder.
1060
- if ([view smoothClipHasPendingInstall] ||
1061
- state.animation->suspendedParticipants.count(key) > 0) {
1062
- joinActiveAnimation(driverId, view);
1063
- }
1064
- }
1065
-
1066
- bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
1067
- NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
1068
- auto iterator = registry().find(driverId);
1069
- if (iterator == registry().end()) {
1070
- return false;
1071
- }
1072
- auto &state = iterator->second;
1073
- if (!state.animation.has_value() || state.views.empty()) {
1074
- return false;
1075
- }
1076
- const ViewKey key = keyForView(view);
1077
- if (std::find(state.views.begin(), state.views.end(), key) ==
1078
- state.views.end()) {
1079
- return false;
1080
- }
1081
- // A pending install used to imply the animation was dispatched, which
1082
- // implied started. Re-latching on displayability broke that: when the last
1083
- // displayable host leaves, a peer that deferred its install keeps
1084
- // `_pendingAnimationInstall` while the animation goes back to un-started, and
1085
- // its first layout lands here. Start the latch — returning false would make
1086
- // the caller clear `_activeAnimationId` and apply the requested geometry,
1087
- // snapping this view straight to the target. A *join* must still never
1088
- // compute elapsed time against an un-rebased latch clock, so this returns
1089
- // instead of falling through.
1090
- if (!state.animation->started) {
1091
- if (![view smoothClipCanDisplay]) return false;
1092
829
  if (state.animation->groupId != 0) {
1093
- const int32_t groupId = state.animation->groupId;
1094
- tryStartGroup(groupId);
1095
- return state.animation.has_value() && state.animation->started;
1096
- }
1097
- startLatchedAnimation(state);
1098
- return true;
1099
- }
1100
- // Join from a laid-out peer's live presentation, exactly as a view that had
1101
- // registered at this moment would. When the joining view is the only
1102
- // registered host, its own layer never received geometry (the install was
1103
- // deferred for lack of layout), so fall back to the animation's start.
1104
- SmoothClipView *reference = nil;
1105
- for (const ViewKey candidate : state.animation->participants) {
1106
- if (candidate != key && [viewForKey(candidate) smoothClipIsJoinable]) {
1107
- reference = viewForKey(candidate);
1108
- break;
830
+ tryStartGroup(state.animation->groupId);
831
+ } else {
832
+ startPendingAnimation(state);
1109
833
  }
1110
834
  }
1111
- const Presentation visible = reference != nil
1112
- ? [reference smoothClipCurrentPresentation]
1113
- : state.animation->start;
1114
- [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
1115
- dispatchActiveAnimationJoin(state, view, key, visible, reference);
1116
- return true;
1117
835
  }
1118
836
 
1119
837
  void unregisterView(uint64_t driverId, SmoothClipView *view) {
1120
838
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
1121
839
  auto iterator = registry().find(driverId);
1122
840
  if (iterator == registry().end()) return;
1123
-
1124
- auto &state = iterator->second;
841
+ DriverState &state = iterator->second;
1125
842
  const ViewKey key = keyForView(view);
1126
- const int32_t groupId = state.animation.has_value()
1127
- ? state.animation->groupId
1128
- : 0;
1129
- if (groupId != 0) {
1130
- const bool installed =
1131
- state.animation->participants.count(key) > 0 ||
1132
- state.animation->suspendedParticipants.count(key) > 0;
1133
- bool otherDisplayable = false;
1134
- for (const ViewKey candidate : state.views) {
1135
- if (candidate != key && [viewForKey(candidate) smoothClipCanDisplay]) {
1136
- otherDisplayable = true;
1137
- break;
1138
- }
1139
- }
1140
- if (!otherDisplayable) {
1141
- suspendGroup(groupId);
1142
- } else if (state.animation.has_value() && state.animation->started &&
1143
- state.animation->participants.count(key) > 0) {
1144
- [view smoothClipFreezePresentation];
1145
- }
1146
- state.views.erase(
1147
- std::remove(state.views.begin(), state.views.end(), key),
1148
- state.views.end());
1149
- if (state.animation.has_value() &&
1150
- state.animation->groupId == groupId) {
1151
- state.animation->participants.erase(key);
1152
- state.animation->suspendedParticipants.erase(key);
1153
- // A duplicate rendering host may leave without poisoning the immutable
1154
- // driver participant. If the driver lost its only host, the group's
1155
- // suspension policy above owns the result.
1156
- if (installed && otherDisplayable &&
1157
- state.animation->participants.empty()) {
1158
- suspendGroup(groupId);
1159
- }
1160
- }
1161
- if (state.destroyed && state.views.empty()) {
1162
- registry().erase(iterator);
1163
- }
1164
- return;
1165
- }
1166
- bool installed = false;
1167
843
  if (state.animation.has_value()) {
1168
- installed = state.animation->participants.count(key) > 0 ||
1169
- state.animation->suspendedParticipants.count(key) > 0;
1170
- if (state.animation->started &&
1171
- state.animation->participants.count(key) > 0) {
1172
- bool otherDisplayable = false;
1173
- for (const ViewKey candidate : state.views) {
1174
- if (candidate != key && [viewForKey(candidate) smoothClipCanDisplay]) {
1175
- otherDisplayable = true;
1176
- break;
1177
- }
1178
- }
1179
- if (!otherDisplayable) {
1180
- relatchActiveAnimation(driverId, state, view);
1181
- }
844
+ if (state.animation->groupId != 0 && state.animation->started) {
845
+ finishGroupForHostLoss(state.animation->groupId);
846
+ } else if (state.animation->groupId == 0 && state.animation->started) {
847
+ finishStandaloneAtTarget(driverId, state);
1182
848
  }
1183
849
  }
1184
-
1185
850
  state.views.erase(
1186
851
  std::remove(state.views.begin(), state.views.end(), key),
1187
852
  state.views.end());
1188
- if (state.animation.has_value()) {
1189
- state.animation->participants.erase(key);
1190
- state.animation->suspendedParticipants.erase(key);
1191
- if (installed) state.animation->finished = false;
1192
- if (state.animation->started && state.animation->participants.empty()) {
1193
- finishIfNoInstalledParticipants(driverId, state);
1194
- }
1195
- }
1196
- if (state.destroyed && state.views.empty()) {
1197
- registry().erase(iterator);
1198
- }
853
+ if (state.destroyed && state.views.empty()) registry().erase(iterator);
1199
854
  }
1200
855
 
1201
856
  void setPresentation(
@@ -1204,6 +859,7 @@ void setPresentation(
1204
859
  bool takeOwnership,
1205
860
  bool overridePendingAnimation,
1206
861
  bool recordVelocity) {
862
+ if (!canonicalizePresentation(presentation)) return;
1207
863
  // CALayer writes require the main thread (not main-queue drain context).
1208
864
  if (!NSThread.isMainThread) {
1209
865
  // Never block an off-main caller: a synchronous hop can deadlock against
@@ -1255,7 +911,7 @@ void setPresentation(
1255
911
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
1256
912
  if (signpostsEnabled) {
1257
913
  os_signpost_interval_end(
1258
- signpostLog(), identifier, "lookup-fanout", "ignored=held-latch");
914
+ signpostLog(), identifier, "lookup-fanout", "ignored=pending-run");
1259
915
  }
1260
916
  #endif
1261
917
  return;
@@ -1277,7 +933,8 @@ Presentation beginInteraction(uint64_t driverId) {
1277
933
  // See setPresentation: blocking off-main risks a deadlock, so the call
1278
934
  // fails defined and the JS side keeps its current value.
1279
935
  return unavailablePresentation();
1280
- } auto iterator = registry().find(driverId);
936
+ }
937
+ auto iterator = registry().find(driverId);
1281
938
  if (iterator == registry().end()) return unavailablePresentation();
1282
939
  auto &state = iterator->second;
1283
940
  state.destroyed = false;
@@ -1377,18 +1034,22 @@ std::vector<DriverSnapshot> beginGroupInteraction(
1377
1034
 
1378
1035
  bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1379
1036
  if (!NSThread.isMainThread) return false;
1037
+ std::vector<BatchEntry> canonicalEntries = entries;
1038
+ for (BatchEntry &entry : canonicalEntries) {
1039
+ if (!canonicalizePresentation(entry.presentation)) return false;
1040
+ }
1380
1041
  std::unordered_set<uint64_t> unique;
1381
- for (const BatchEntry &entry : entries) {
1042
+ for (const BatchEntry &entry : canonicalEntries) {
1382
1043
  const auto iterator = registry().find(entry.driverId);
1383
1044
  if (entry.driverId == 0 || !unique.insert(entry.driverId).second ||
1384
1045
  iterator == registry().end() || iterator->second.destroyed ||
1385
- !isFiniteProtocolV2Presentation(entry.presentation)) {
1046
+ !isFinitePresentation(entry.presentation)) {
1386
1047
  return false;
1387
1048
  }
1388
1049
  }
1389
1050
 
1390
1051
  std::unordered_set<int32_t> cancelledGroups;
1391
- for (const BatchEntry &entry : entries) {
1052
+ for (const BatchEntry &entry : canonicalEntries) {
1392
1053
  DriverState &state = registry().find(entry.driverId)->second;
1393
1054
  if (!state.animation.has_value()) continue;
1394
1055
  const int32_t groupId = state.animation->groupId;
@@ -1409,7 +1070,7 @@ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1409
1070
 
1410
1071
  [CATransaction begin];
1411
1072
  [CATransaction setDisableActions:YES];
1412
- for (const BatchEntry &entry : entries) {
1073
+ for (const BatchEntry &entry : canonicalEntries) {
1413
1074
  applyPresentation(
1414
1075
  registry().find(entry.driverId)->second, entry.presentation, true);
1415
1076
  }
@@ -1420,7 +1081,7 @@ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1420
1081
  namespace {
1421
1082
 
1422
1083
  bool preflightGroupEntries(
1423
- const std::vector<GroupMotionEntry> &entries,
1084
+ std::vector<GroupMotionEntry> &entries,
1424
1085
  AnimationKind kind,
1425
1086
  const TimingAnimation &timing,
1426
1087
  const SpringAnimation &spring,
@@ -1429,17 +1090,18 @@ bool preflightGroupEntries(
1429
1090
  bool &reduced,
1430
1091
  std::vector<Presentation> &resolvedStarts) {
1431
1092
  if (entries.empty()) return false;
1093
+ for (GroupMotionEntry &entry : entries) {
1094
+ if ((entry.hasFrom && !canonicalizePresentation(entry.from)) ||
1095
+ !canonicalizePresentation(entry.target)) {
1096
+ return false;
1097
+ }
1098
+ }
1432
1099
  if (kind == AnimationKind::Timing &&
1433
- !isValidProtocolV2Timing(timing)) {
1100
+ !isValidTiming(timing)) {
1434
1101
  return false;
1435
1102
  }
1436
1103
  if (kind == AnimationKind::Spring &&
1437
- !isValidProtocolV2Spring(spring)) {
1438
- return false;
1439
- }
1440
- if (kind == AnimationKind::Keyframes &&
1441
- (!std::isfinite(durationMs) || durationMs < 0 ||
1442
- !isValidReduceMotionCode(reduceMotion))) {
1104
+ (!isValidSpring(spring) || spring.inheritVelocity)) {
1443
1105
  return false;
1444
1106
  }
1445
1107
  reduced = shouldReduceMotion(reduceMotion) ||
@@ -1459,36 +1121,16 @@ bool preflightGroupEntries(
1459
1121
  const Presentation resolvedStart = entry.hasFrom
1460
1122
  ? entry.from
1461
1123
  : canonicalVisiblePresentation(iterator->second);
1462
- if (!isFiniteProtocolV2Presentation(resolvedStart) ||
1463
- !isFiniteProtocolV2Presentation(entry.target) ||
1464
- resolvedStart.clip.curve != entry.target.clip.curve) {
1465
- return false;
1466
- }
1467
- if (kind == AnimationKind::Spring &&
1468
- !protocolV2SpringScaleIsProvablyPositive(
1469
- resolvedStart, entry.target, spring)) {
1124
+ if (!isFinitePresentation(resolvedStart) ||
1125
+ !isFinitePresentation(entry.target) ||
1126
+ !isAutonomousUniformCircular(resolvedStart) ||
1127
+ !isAutonomousUniformCircular(entry.target)) {
1470
1128
  return false;
1471
1129
  }
1472
- if (kind == AnimationKind::Keyframes) {
1473
- if (!isValidProtocolV2Keyframes(
1474
- entry.keyframes,
1475
- resolvedStart,
1476
- entry.target,
1477
- entry.hasFrom)) {
1478
- return false;
1479
- }
1480
- } else if (!entry.keyframes.empty()) {
1481
- return false;
1482
- }
1483
- if (!reduced && iterator != registry().end()) {
1484
- if (kind == AnimationKind::Timing &&
1485
- !timingPlanPreservesLinearHostNormalization(
1486
- iterator->second, resolvedStart, entry.target, timing)) {
1487
- return false;
1488
- }
1489
- if (kind == AnimationKind::Keyframes &&
1490
- !keyframePlanPreservesLinearHostNormalization(
1491
- iterator->second, resolvedStart, entry.keyframes)) {
1130
+ if (kind == AnimationKind::Spring) {
1131
+ const double velocity = spring.initialVelocity;
1132
+ if (!springScaleStaysPositive(
1133
+ resolvedStart, entry.target, spring, velocity)) {
1492
1134
  return false;
1493
1135
  }
1494
1136
  }
@@ -1532,7 +1174,7 @@ int32_t createGroupAnimation(
1532
1174
  SpringAnimation spring,
1533
1175
  double durationMs,
1534
1176
  int32_t reduceMotion,
1535
- GroupSuspensionPolicy suspensionPolicy) {
1177
+ int32_t completionTag) {
1536
1178
  std::vector<Presentation> resolvedStarts;
1537
1179
  bool reduced = false;
1538
1180
  if (!NSThread.isMainThread || controllerId == 0 ||
@@ -1572,16 +1214,17 @@ int32_t createGroupAnimation(
1572
1214
  applyPresentation(state, entry.target, false);
1573
1215
  }
1574
1216
  [CATransaction commit];
1575
- emitGroupCompletion(controllerId, groupId, true, driverIds);
1217
+ emitGroupCompletion(
1218
+ controllerId, groupId, completionTag, true, driverIds);
1576
1219
  return groupId;
1577
1220
  }
1578
1221
 
1579
1222
  GroupState group{
1580
1223
  controllerId,
1581
1224
  groupId,
1225
+ completionTag,
1582
1226
  driverIds,
1583
1227
  std::unordered_set<uint64_t>(driverIds.begin(), driverIds.end()),
1584
- suspensionPolicy,
1585
1228
  };
1586
1229
  groupRegistry().emplace(groupId, std::move(group));
1587
1230
 
@@ -1597,13 +1240,6 @@ int32_t createGroupAnimation(
1597
1240
  applyPresentation(state, entry.from, true);
1598
1241
  }
1599
1242
  const Presentation start = resolvedStarts[entryIndex];
1600
- if (kind == AnimationKind::Keyframes && !entry.keyframes.empty()) {
1601
- // An omitted `from` is a native-to-native retarget: sample the visible
1602
- // presentation before replacement and substitute that exact value as
1603
- // frame zero. Explicit endpoints were already validated and are never
1604
- // silently corrected by native.
1605
- if (!entry.hasFrom) entry.keyframes.front().presentation = start;
1606
- }
1607
1243
  state.latest = entry.target;
1608
1244
  state.hasLatest = true;
1609
1245
  state.ownership = Ownership::Native;
@@ -1614,8 +1250,10 @@ int32_t createGroupAnimation(
1614
1250
  active.start = start;
1615
1251
  active.timing = timing;
1616
1252
  active.spring = spring;
1617
- active.keyframes = std::move(entry.keyframes);
1618
- active.durationMs = durationMs;
1253
+ if (kind == AnimationKind::Spring) {
1254
+ active.spring.initialVelocity = spring.initialVelocity;
1255
+ active.spring.inheritVelocity = false;
1256
+ }
1619
1257
  active.started = false;
1620
1258
  state.animation = std::move(active);
1621
1259
  }
@@ -1629,7 +1267,7 @@ int32_t animateTimingGroup(
1629
1267
  uint64_t controllerId,
1630
1268
  std::vector<GroupMotionEntry> entries,
1631
1269
  TimingAnimation animation,
1632
- GroupSuspensionPolicy suspensionPolicy,
1270
+ int32_t completionTag,
1633
1271
  double startedAtHintS) {
1634
1272
  (void)startedAtHintS;
1635
1273
  return createGroupAnimation(
@@ -1640,14 +1278,14 @@ int32_t animateTimingGroup(
1640
1278
  {},
1641
1279
  animation.durationMs,
1642
1280
  animation.reduceMotion,
1643
- suspensionPolicy);
1281
+ completionTag);
1644
1282
  }
1645
1283
 
1646
1284
  int32_t animateSpringGroup(
1647
1285
  uint64_t controllerId,
1648
1286
  std::vector<GroupMotionEntry> entries,
1649
1287
  SpringAnimation animation,
1650
- GroupSuspensionPolicy suspensionPolicy,
1288
+ int32_t completionTag,
1651
1289
  double startedAtHintS) {
1652
1290
  (void)startedAtHintS;
1653
1291
  return createGroupAnimation(
@@ -1658,26 +1296,7 @@ int32_t animateSpringGroup(
1658
1296
  animation,
1659
1297
  0,
1660
1298
  animation.reduceMotion,
1661
- suspensionPolicy);
1662
- }
1663
-
1664
- int32_t animateKeyframesGroup(
1665
- uint64_t controllerId,
1666
- std::vector<GroupMotionEntry> entries,
1667
- double durationMs,
1668
- int32_t reduceMotion,
1669
- GroupSuspensionPolicy suspensionPolicy,
1670
- double startedAtHintS) {
1671
- (void)startedAtHintS;
1672
- return createGroupAnimation(
1673
- controllerId,
1674
- std::move(entries),
1675
- AnimationKind::Keyframes,
1676
- {},
1677
- {},
1678
- durationMs,
1679
- reduceMotion,
1680
- suspensionPolicy);
1299
+ completionTag);
1681
1300
  }
1682
1301
 
1683
1302
  std::vector<DriverSnapshot> cancelAnimationGroup(
@@ -1695,26 +1314,19 @@ int32_t animateTiming(
1695
1314
  AnimationStart start,
1696
1315
  Presentation presentation,
1697
1316
  TimingAnimation animation,
1698
- AnimationValidationMode validationMode) {
1317
+ int32_t completionTag) {
1699
1318
  if (!NSThread.isMainThread) {
1700
1319
  // See setPresentation: blocking off-main risks a deadlock. 0 is the
1701
1320
  // documented rejection sentinel.
1702
1321
  return 0;
1703
1322
  }
1704
- const bool strictV2 =
1705
- validationMode == AnimationValidationMode::ProtocolV2;
1706
- const bool validAnimation = strictV2
1707
- ? isValidProtocolV2Timing(animation)
1708
- : std::isfinite(animation.durationMs) &&
1709
- std::isfinite(animation.controlPoint1X) &&
1710
- std::isfinite(animation.controlPoint1Y) &&
1711
- std::isfinite(animation.controlPoint2X) &&
1712
- std::isfinite(animation.controlPoint2Y) &&
1713
- isValidReduceMotionCode(animation.reduceMotion);
1714
- if (!strictV2 && validAnimation) {
1715
- animation.durationMs = std::max(0.0, animation.durationMs);
1716
- }
1717
- if (driverId == 0 || !isFiniteProtocolV2Presentation(presentation) ||
1323
+ if (!canonicalizePresentation(presentation) ||
1324
+ (start.hasInteractiveStart &&
1325
+ !canonicalizePresentation(start.interactiveStart))) {
1326
+ return 0;
1327
+ }
1328
+ const bool validAnimation = isValidTiming(animation);
1329
+ if (driverId == 0 || !isFinitePresentation(presentation) ||
1718
1330
  !validAnimation) {
1719
1331
  return 0;
1720
1332
  }
@@ -1722,7 +1334,7 @@ int32_t animateTiming(
1722
1334
  Presentation preflightStart;
1723
1335
  if (iterator == registry().end()) {
1724
1336
  if (!start.hasInteractiveStart ||
1725
- !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1337
+ !isFinitePresentation(start.interactiveStart)) {
1726
1338
  return 0;
1727
1339
  }
1728
1340
  preflightStart = start.interactiveStart;
@@ -1730,17 +1342,12 @@ int32_t animateTiming(
1730
1342
  if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1731
1343
  preflightStart = resolvedAnimationStart(iterator->second, start);
1732
1344
  }
1733
- if (!isFiniteProtocolV2Presentation(preflightStart) ||
1345
+ if (!isFinitePresentation(preflightStart) ||
1734
1346
  preflightStart.clip.curve != presentation.clip.curve) {
1735
1347
  return 0;
1736
1348
  }
1737
1349
  const bool reduced = shouldReduceMotion(animation.reduceMotion) ||
1738
1350
  animation.durationMs <= 0;
1739
- if (strictV2 && !reduced && iterator != registry().end() &&
1740
- !timingPlanPreservesLinearHostNormalization(
1741
- iterator->second, preflightStart, presentation, animation)) {
1742
- return 0;
1743
- }
1744
1351
  if (iterator == registry().end()) {
1745
1352
  iterator = registry().try_emplace(driverId).first;
1746
1353
  iterator->second.latest = start.interactiveStart;
@@ -1754,25 +1361,22 @@ int32_t animateTiming(
1754
1361
 
1755
1362
  if (reduced) {
1756
1363
  applyPresentation(state, presentation, false);
1757
- emitCompletion(driverId, state, animationId, true);
1364
+ emitStandaloneCompletion(
1365
+ driverId, animationId, completionTag, true);
1758
1366
  return animationId;
1759
1367
  }
1760
1368
 
1761
1369
  ActiveAnimation active{animationId};
1370
+ active.completionTag = completionTag;
1762
1371
  active.kind = AnimationKind::Timing;
1763
1372
  active.start = resolvedStart;
1764
1373
  active.timing = animation;
1765
- active.durationMs = animation.durationMs;
1766
- active.startedAt = CACurrentMediaTime();
1767
1374
  active.started = anyDisplayableView(state);
1768
1375
  state.animation = std::move(active);
1769
1376
  if (!state.animation->started) {
1770
- // Latch: no host view can display yet (animateTo raced the mount, or the
1771
- // host sits in a detached subtree awaiting presentation). Ownership stays
1772
- // Native — the pending animation owns rendering intent — and the first
1773
- // displayable registration/attach rebases startedAt and starts it. The
1774
- // non-zero id is still returned synchronously so the JS side does not
1775
- // treat this as rejection.
1377
+ // No host can display yet. Ownership stays Native and the first
1378
+ // displayable registration starts the full run. A non-zero ID still
1379
+ // distinguishes this pending run from rejection.
1776
1380
  return animationId;
1777
1381
  }
1778
1382
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -1797,26 +1401,18 @@ int32_t animateSpring(
1797
1401
  AnimationStart start,
1798
1402
  Presentation presentation,
1799
1403
  SpringAnimation animation,
1800
- AnimationValidationMode validationMode) {
1404
+ int32_t completionTag) {
1801
1405
  if (!NSThread.isMainThread) {
1802
1406
  // See setPresentation: blocking off-main risks a deadlock.
1803
1407
  return 0;
1804
1408
  }
1805
- const bool strictV2 =
1806
- validationMode == AnimationValidationMode::ProtocolV2;
1807
- const bool validAnimation = strictV2
1808
- ? isValidProtocolV2Spring(animation)
1809
- : std::isfinite(animation.mass) &&
1810
- std::isfinite(animation.stiffness) &&
1811
- std::isfinite(animation.damping) &&
1812
- std::isfinite(animation.initialVelocity) &&
1813
- isValidReduceMotionCode(animation.reduceMotion);
1814
- if (!strictV2 && validAnimation) {
1815
- animation.mass = std::max(0.0001, animation.mass);
1816
- animation.stiffness = std::max(0.0001, animation.stiffness);
1817
- animation.damping = std::max(0.0, animation.damping);
1818
- }
1819
- if (driverId == 0 || !isFiniteProtocolV2Presentation(presentation) ||
1409
+ if (!canonicalizePresentation(presentation) ||
1410
+ (start.hasInteractiveStart &&
1411
+ !canonicalizePresentation(start.interactiveStart))) {
1412
+ return 0;
1413
+ }
1414
+ const bool validAnimation = isValidSpring(animation);
1415
+ if (driverId == 0 || !isFinitePresentation(presentation) ||
1820
1416
  !validAnimation) {
1821
1417
  return 0;
1822
1418
  }
@@ -1824,7 +1420,7 @@ int32_t animateSpring(
1824
1420
  Presentation preflightStart;
1825
1421
  if (iterator == registry().end()) {
1826
1422
  if (!start.hasInteractiveStart ||
1827
- !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1423
+ !isFinitePresentation(start.interactiveStart)) {
1828
1424
  return 0;
1829
1425
  }
1830
1426
  preflightStart = start.interactiveStart;
@@ -1832,10 +1428,13 @@ int32_t animateSpring(
1832
1428
  if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1833
1429
  preflightStart = resolvedAnimationStart(iterator->second, start);
1834
1430
  }
1835
- if (!isFiniteProtocolV2Presentation(preflightStart) ||
1431
+ const double velocity = iterator == registry().end()
1432
+ ? animation.initialVelocity
1433
+ : resolvedSpringVelocity(iterator->second, presentation, animation);
1434
+ if (!isFinitePresentation(preflightStart) ||
1836
1435
  preflightStart.clip.curve != presentation.clip.curve ||
1837
- (strictV2 && !protocolV2SpringScaleIsProvablyPositive(
1838
- preflightStart, presentation, animation))) {
1436
+ !springScaleStaysPositive(
1437
+ preflightStart, presentation, animation, velocity)) {
1839
1438
  return 0;
1840
1439
  }
1841
1440
  if (iterator == registry().end()) {
@@ -1851,19 +1450,22 @@ int32_t animateSpring(
1851
1450
 
1852
1451
  if (shouldReduceMotion(animation.reduceMotion)) {
1853
1452
  applyPresentation(state, presentation, false);
1854
- emitCompletion(driverId, state, animationId, true);
1453
+ emitStandaloneCompletion(
1454
+ driverId, animationId, completionTag, true);
1855
1455
  return animationId;
1856
1456
  }
1857
1457
 
1858
1458
  ActiveAnimation active{animationId};
1459
+ active.completionTag = completionTag;
1859
1460
  active.kind = AnimationKind::Spring;
1860
1461
  active.start = resolvedStart;
1861
1462
  active.spring = animation;
1862
- active.startedAt = CACurrentMediaTime();
1463
+ active.spring.initialVelocity = velocity;
1464
+ active.spring.inheritVelocity = false;
1863
1465
  active.started = anyDisplayableView(state);
1864
1466
  state.animation = std::move(active);
1865
1467
  if (!state.animation->started) {
1866
- // Latched until the first displayable view — see animateTiming.
1468
+ // Pending until the first displayable view — see animateTiming.
1867
1469
  return animationId;
1868
1470
  }
1869
1471
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -1875,7 +1477,7 @@ int32_t animateSpring(
1875
1477
  #endif
1876
1478
  for (const ViewKey key : state.views) {
1877
1479
  if ([viewForKey(key) smoothClipAnimateSpring:presentation
1878
- animation:animation
1480
+ animation:state.animation->spring
1879
1481
  animationId:animationId]) {
1880
1482
  state.animation->participants.insert(key);
1881
1483
  }
@@ -1883,110 +1485,6 @@ int32_t animateSpring(
1883
1485
  return animationId;
1884
1486
  }
1885
1487
 
1886
- int32_t animateKeyframes(
1887
- uint64_t driverId,
1888
- AnimationStart start,
1889
- Presentation presentation,
1890
- double durationMs,
1891
- std::vector<Keyframe> keyframes,
1892
- int32_t reduceMotion,
1893
- AnimationValidationMode validationMode) {
1894
- if (!NSThread.isMainThread) {
1895
- // See setPresentation: blocking off-main risks a deadlock.
1896
- return 0;
1897
- }
1898
- const bool strictV2 =
1899
- validationMode == AnimationValidationMode::ProtocolV2;
1900
- const bool finiteDuration = std::isfinite(durationMs);
1901
- if (!strictV2 && finiteDuration) durationMs = std::max(0.0, durationMs);
1902
- if (driverId == 0 || !isFiniteProtocolV2Presentation(presentation) ||
1903
- !finiteDuration || (strictV2 && durationMs < 0) ||
1904
- !isValidReduceMotionCode(reduceMotion)) {
1905
- return 0;
1906
- }
1907
- auto iterator = registry().find(driverId);
1908
- Presentation preflightStart;
1909
- if (iterator == registry().end()) {
1910
- if (!start.hasInteractiveStart ||
1911
- !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1912
- return 0;
1913
- }
1914
- preflightStart = start.interactiveStart;
1915
- } else {
1916
- if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1917
- preflightStart = resolvedAnimationStart(iterator->second, start);
1918
- }
1919
- bool validKeyframes = keyframes.size() >= 2 &&
1920
- keyframes.front().offset == 0 && keyframes.back().offset == 1;
1921
- double previousOffset = -1;
1922
- for (const Keyframe &keyframe : keyframes) {
1923
- validKeyframes = validKeyframes && std::isfinite(keyframe.offset) &&
1924
- keyframe.offset > previousOffset && keyframe.offset >= 0 &&
1925
- keyframe.offset <= 1 &&
1926
- isFiniteProtocolV2Presentation(keyframe.presentation);
1927
- previousOffset = keyframe.offset;
1928
- }
1929
- if (strictV2) {
1930
- validKeyframes = isValidProtocolV2Keyframes(
1931
- keyframes, preflightStart, presentation, start.hasInteractiveStart);
1932
- }
1933
- if (!isFiniteProtocolV2Presentation(preflightStart) || !validKeyframes) {
1934
- return 0;
1935
- }
1936
- const bool reduced = shouldReduceMotion(reduceMotion) || durationMs <= 0;
1937
- if (strictV2 && !reduced && iterator != registry().end() &&
1938
- !keyframePlanPreservesLinearHostNormalization(
1939
- iterator->second, preflightStart, keyframes)) {
1940
- return 0;
1941
- }
1942
- if (iterator == registry().end()) {
1943
- iterator = registry().try_emplace(driverId).first;
1944
- iterator->second.latest = start.interactiveStart;
1945
- iterator->second.hasLatest = true;
1946
- }
1947
- auto &state = iterator->second;
1948
- state.destroyed = false;
1949
- const int32_t animationId = allocateAnimationId();
1950
- const Presentation resolvedStart = preflightStart;
1951
- prepareAnimation(driverId, state, start, presentation);
1952
- if (reduced) {
1953
- applyPresentation(state, presentation, false);
1954
- emitCompletion(driverId, state, animationId, true);
1955
- return animationId;
1956
- }
1957
-
1958
- // Frame zero is a placeholder when the caller omits `from`. Resolve it from
1959
- // the presentation layer/native-visible state so native-to-native retargets
1960
- // cannot jump back to a stale compiled sample. With an explicit `from`,
1961
- // protocol validation requires the same value, so this assignment is a
1962
- // harmless canonicalization in both cases.
1963
- if (strictV2 && !start.hasInteractiveStart) {
1964
- keyframes.front().presentation = resolvedStart;
1965
- }
1966
-
1967
- ActiveAnimation active{animationId};
1968
- active.kind = AnimationKind::Keyframes;
1969
- active.start = resolvedStart;
1970
- active.keyframes = keyframes;
1971
- active.durationMs = durationMs;
1972
- active.startedAt = CACurrentMediaTime();
1973
- active.started = anyDisplayableView(state);
1974
- state.animation = std::move(active);
1975
- if (!state.animation->started) {
1976
- // Latched until the first displayable view — see animateTiming.
1977
- return animationId;
1978
- }
1979
- for (const ViewKey key : state.views) {
1980
- if ([viewForKey(key) smoothClipAnimateKeyframes:presentation
1981
- keyframes:keyframes
1982
- durationMs:durationMs
1983
- animationId:animationId]) {
1984
- state.animation->participants.insert(key);
1985
- }
1986
- }
1987
- return animationId;
1988
- }
1989
-
1990
1488
  int32_t rejectAnimation(uint64_t driverId) {
1991
1489
  if (!NSThread.isMainThread) {
1992
1490
  // See setPresentation: blocking off-main risks a deadlock.
@@ -1994,7 +1492,7 @@ int32_t rejectAnimation(uint64_t driverId) {
1994
1492
  } auto iterator = registry().find(driverId);
1995
1493
  if (iterator == registry().end()) return 0;
1996
1494
  const int32_t animationId = allocateAnimationId();
1997
- emitStandaloneCompletion(driverId, animationId, false);
1495
+ emitStandaloneCompletion(driverId, animationId, 0, false);
1998
1496
  return animationId;
1999
1497
  }
2000
1498
 
@@ -2037,7 +1535,7 @@ CancelResult cancelAnimation(
2037
1535
  #endif
2038
1536
  if (useTarget) {
2039
1537
  const Presentation target = state.latest;
2040
- // A latch never dispatched to its views, so every participant still has
1538
+ // A pending run never dispatched to its view, so the host still has
2041
1539
  // _activeAnimationId == 0 and smoothClipCancelAnimationUsingTarget
2042
1540
  // early-returns without writing anything: state.latest would report the
2043
1541
  // target while every layer still showed the pre-animation geometry, and
@@ -2045,15 +1543,15 @@ CancelResult cancelAnimation(
2045
1543
  // unconditionally — match that. Read `started` before cancelActive, which
2046
1544
  // resets state.animation.
2047
1545
  //
2048
- // Gated on the latch case so a started animation is not written twice
1546
+ // Gated on the pending case so a started animation is not written twice
2049
1547
  // (smoothClipCancelAnimationUsingTarget already applied the target there),
2050
1548
  // and applied WITHOUT a velocity sample: a jump to the target is not
2051
1549
  // interactive motion, and the cancel-to-target path deliberately keeps it
2052
1550
  // out of the 'inherit' history on both platforms — Android's cancel
2053
1551
  // fan-out records nothing either.
2054
- const bool wasLatched = !state.animation->started;
1552
+ const bool wasPending = !state.animation->started;
2055
1553
  cancelActive(driverId, state, true);
2056
- if (wasLatched) {
1554
+ if (wasPending) {
2057
1555
  // state.latest already holds the target (set at animate time); only the
2058
1556
  // view fan-out is missing.
2059
1557
  for (const ViewKey key : state.views) {
@@ -2107,11 +1605,8 @@ void viewAnimationDidStop(
2107
1605
  auto &state = iterator->second;
2108
1606
  if (!state.animation.has_value() ||
2109
1607
  state.animation->animationId != animationId) return;
2110
- // A delegate from the frozen run can still report a stop for this id after
2111
- // the animation has been re-latched. Draining participants there could
2112
- // complete an animation that is waiting to resume. Only a running animation
2113
- // may be completed by its installed delegates; a latch is completed by
2114
- // replacement, cancel or destroy.
1608
+ // A pre-ready run has no installed delegate; it completes only through
1609
+ // replacement, cancellation, host loss, or controller destruction.
2115
1610
  if (!state.animation->started) return;
2116
1611
 
2117
1612
  const ViewKey key = keyForView(view);