react-native-smooth-clip-view 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +132 -349
  2. package/android/build.gradle +8 -4
  3. package/android/proguard-rules.pro +3 -3
  4. package/android/src/main/cpp/SmoothClipAndroid.h +13 -6
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +605 -123
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +975 -209
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +192 -118
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +22 -4
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +81 -56
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +449 -240
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +150 -38
  12. package/cpp/SmoothClipAnimationCurve.h +317 -397
  13. package/cpp/SmoothClipRegistry.h +84 -20
  14. package/cpp/SmoothClipSharedGeometry.h +131 -22
  15. package/cpp/SmoothClipVelocityTracker.h +16 -12
  16. package/ios/SmoothClipGeometry.h +188 -21
  17. package/ios/SmoothClipRegistry.mm +869 -368
  18. package/ios/SmoothClipRegistrySnapshot.h +13 -0
  19. package/ios/SmoothClipTurboModule.cpp +513 -156
  20. package/ios/SmoothClipTurboModule.h +123 -56
  21. package/ios/SmoothClipView.mm +1074 -449
  22. package/ios/SmoothClipViewRegistry.h +3 -7
  23. package/ios/reactnativesmoothclipview.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  24. package/ios/reactnativesmoothclipview.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  25. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  26. package/lib/module/SmoothClipView.ios.js +94 -11
  27. package/lib/module/SmoothClipView.ios.js.map +1 -1
  28. package/lib/module/SmoothClipView.js +1 -46
  29. package/lib/module/SmoothClipView.js.map +1 -1
  30. package/lib/module/SmoothClipViewNativeComponent.ts +32 -12
  31. package/lib/module/capabilities.js +4 -0
  32. package/lib/module/capabilities.js.map +1 -0
  33. package/lib/module/capabilities.native.js +18 -0
  34. package/lib/module/capabilities.native.js.map +1 -0
  35. package/lib/module/capabilityTypes.js +2 -0
  36. package/lib/module/capabilityTypes.js.map +1 -0
  37. package/lib/module/controllerInternals.js +28 -0
  38. package/lib/module/controllerInternals.js.map +1 -0
  39. package/lib/module/controllerLifecycle.js +9 -0
  40. package/lib/module/controllerLifecycle.js.map +1 -0
  41. package/lib/module/controllerTypes.js +4 -0
  42. package/lib/module/controllerTypes.js.map +1 -0
  43. package/lib/module/controllers.android.js +4 -0
  44. package/lib/module/controllers.android.js.map +1 -0
  45. package/lib/module/controllers.ios.js +4 -0
  46. package/lib/module/controllers.ios.js.map +1 -0
  47. package/lib/module/controllers.js +4 -0
  48. package/lib/module/controllers.js.map +1 -0
  49. package/lib/module/controllers.native.js +89 -0
  50. package/lib/module/controllers.native.js.map +1 -0
  51. package/lib/module/geometry.js +180 -30
  52. package/lib/module/geometry.js.map +1 -1
  53. package/lib/module/groupTypes.js +4 -0
  54. package/lib/module/groupTypes.js.map +1 -0
  55. package/lib/module/groups.android.js +4 -0
  56. package/lib/module/groups.android.js.map +1 -0
  57. package/lib/module/groups.ios.js +4 -0
  58. package/lib/module/groups.ios.js.map +1 -0
  59. package/lib/module/groups.js +4 -0
  60. package/lib/module/groups.js.map +1 -0
  61. package/lib/module/groups.native.js +399 -0
  62. package/lib/module/groups.native.js.map +1 -0
  63. package/lib/module/ids.js +8 -0
  64. package/lib/module/ids.js.map +1 -0
  65. package/lib/module/index.js +4 -3
  66. package/lib/module/index.js.map +1 -1
  67. package/lib/module/nativeCompletion.js +37 -16
  68. package/lib/module/nativeCompletion.js.map +1 -1
  69. package/lib/module/presentationCodec.js +81 -0
  70. package/lib/module/presentationCodec.js.map +1 -0
  71. package/lib/typescript/src/NativeSmoothClipModule.d.ts +20 -4
  72. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  73. package/lib/typescript/src/SmoothClipView.d.ts +2 -10
  74. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  75. package/lib/typescript/src/SmoothClipView.ios.d.ts +139 -4
  76. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  77. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +16 -3
  78. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  79. package/lib/typescript/src/capabilities.d.ts +3 -0
  80. package/lib/typescript/src/capabilities.d.ts.map +1 -0
  81. package/lib/typescript/src/capabilities.native.d.ts +3 -0
  82. package/lib/typescript/src/capabilities.native.d.ts.map +1 -0
  83. package/lib/typescript/src/capabilityTypes.d.ts +4 -0
  84. package/lib/typescript/src/capabilityTypes.d.ts.map +1 -0
  85. package/lib/typescript/src/controllerInternals.d.ts +14 -0
  86. package/lib/typescript/src/controllerInternals.d.ts.map +1 -0
  87. package/lib/typescript/src/controllerLifecycle.d.ts +3 -0
  88. package/lib/typescript/src/controllerLifecycle.d.ts.map +1 -0
  89. package/lib/typescript/src/controllerTypes.d.ts +62 -0
  90. package/lib/typescript/src/controllerTypes.d.ts.map +1 -0
  91. package/lib/typescript/src/controllers.android.d.ts +2 -0
  92. package/lib/typescript/src/controllers.android.d.ts.map +1 -0
  93. package/lib/typescript/src/controllers.d.ts +2 -0
  94. package/lib/typescript/src/controllers.d.ts.map +1 -0
  95. package/lib/typescript/src/controllers.ios.d.ts +2 -0
  96. package/lib/typescript/src/controllers.ios.d.ts.map +1 -0
  97. package/lib/typescript/src/controllers.native.d.ts +3 -0
  98. package/lib/typescript/src/controllers.native.d.ts.map +1 -0
  99. package/lib/typescript/src/geometry.d.ts +49 -5
  100. package/lib/typescript/src/geometry.d.ts.map +1 -1
  101. package/lib/typescript/src/groupTypes.d.ts +31 -0
  102. package/lib/typescript/src/groupTypes.d.ts.map +1 -0
  103. package/lib/typescript/src/groups.android.d.ts +2 -0
  104. package/lib/typescript/src/groups.android.d.ts.map +1 -0
  105. package/lib/typescript/src/groups.d.ts +2 -0
  106. package/lib/typescript/src/groups.d.ts.map +1 -0
  107. package/lib/typescript/src/groups.ios.d.ts +2 -0
  108. package/lib/typescript/src/groups.ios.d.ts.map +1 -0
  109. package/lib/typescript/src/groups.native.d.ts +3 -0
  110. package/lib/typescript/src/groups.native.d.ts.map +1 -0
  111. package/lib/typescript/src/ids.d.ts +2 -0
  112. package/lib/typescript/src/ids.d.ts.map +1 -0
  113. package/lib/typescript/src/index.d.ts +8 -5
  114. package/lib/typescript/src/index.d.ts.map +1 -1
  115. package/lib/typescript/src/nativeCompletion.d.ts +16 -6
  116. package/lib/typescript/src/nativeCompletion.d.ts.map +1 -1
  117. package/lib/typescript/src/presentationCodec.d.ts +10 -0
  118. package/lib/typescript/src/presentationCodec.d.ts.map +1 -0
  119. package/package.json +17 -13
  120. package/src/NativeSmoothClipModule.ts +80 -56
  121. package/src/SmoothClipView.ios.tsx +142 -16
  122. package/src/SmoothClipView.tsx +6 -76
  123. package/src/SmoothClipViewNativeComponent.ts +32 -12
  124. package/src/capabilities.native.ts +19 -0
  125. package/src/capabilities.ts +2 -0
  126. package/src/capabilityTypes.ts +3 -0
  127. package/src/controllerInternals.ts +52 -0
  128. package/src/controllerLifecycle.ts +6 -0
  129. package/src/controllerTypes.ts +87 -0
  130. package/src/controllers.android.ts +1 -0
  131. package/src/controllers.ios.ts +1 -0
  132. package/src/controllers.native.ts +104 -0
  133. package/src/controllers.ts +1 -0
  134. package/src/geometry.ts +293 -45
  135. package/src/groupTypes.ts +53 -0
  136. package/src/groups.android.ts +1 -0
  137. package/src/groups.ios.ts +1 -0
  138. package/src/groups.native.ts +604 -0
  139. package/src/groups.ts +1 -0
  140. package/src/ids.ts +6 -0
  141. package/src/index.ts +32 -12
  142. package/src/nativeCompletion.ts +67 -32
  143. package/src/presentationCodec.ts +134 -0
  144. package/lib/module/SmoothClipView.web.js +0 -54
  145. package/lib/module/SmoothClipView.web.js.map +0 -1
  146. package/lib/module/driverState.js +0 -110
  147. package/lib/module/driverState.js.map +0 -1
  148. package/lib/module/driverTypes.js +0 -4
  149. package/lib/module/driverTypes.js.map +0 -1
  150. package/lib/module/drivers.android.js +0 -6
  151. package/lib/module/drivers.android.js.map +0 -1
  152. package/lib/module/drivers.ios.js +0 -6
  153. package/lib/module/drivers.ios.js.map +0 -1
  154. package/lib/module/drivers.js +0 -284
  155. package/lib/module/drivers.js.map +0 -1
  156. package/lib/module/drivers.native.js +0 -556
  157. package/lib/module/drivers.native.js.map +0 -1
  158. package/lib/module/index.web.js +0 -8
  159. package/lib/module/index.web.js.map +0 -1
  160. package/lib/module/reactRequests.js +0 -56
  161. package/lib/module/reactRequests.js.map +0 -1
  162. package/lib/module/webClipPath.js +0 -25
  163. package/lib/module/webClipPath.js.map +0 -1
  164. package/lib/typescript/src/SmoothClipView.web.d.ts +0 -10
  165. package/lib/typescript/src/SmoothClipView.web.d.ts.map +0 -1
  166. package/lib/typescript/src/driverState.d.ts +0 -31
  167. package/lib/typescript/src/driverState.d.ts.map +0 -1
  168. package/lib/typescript/src/driverTypes.d.ts +0 -107
  169. package/lib/typescript/src/driverTypes.d.ts.map +0 -1
  170. package/lib/typescript/src/drivers.android.d.ts +0 -3
  171. package/lib/typescript/src/drivers.android.d.ts.map +0 -1
  172. package/lib/typescript/src/drivers.d.ts +0 -5
  173. package/lib/typescript/src/drivers.d.ts.map +0 -1
  174. package/lib/typescript/src/drivers.ios.d.ts +0 -3
  175. package/lib/typescript/src/drivers.ios.d.ts.map +0 -1
  176. package/lib/typescript/src/drivers.native.d.ts +0 -5
  177. package/lib/typescript/src/drivers.native.d.ts.map +0 -1
  178. package/lib/typescript/src/index.web.d.ts +0 -9
  179. package/lib/typescript/src/index.web.d.ts.map +0 -1
  180. package/lib/typescript/src/reactRequests.d.ts +0 -9
  181. package/lib/typescript/src/reactRequests.d.ts.map +0 -1
  182. package/lib/typescript/src/webClipPath.d.ts +0 -7
  183. package/lib/typescript/src/webClipPath.d.ts.map +0 -1
  184. package/src/SmoothClipView.web.tsx +0 -66
  185. package/src/driverState.ts +0 -158
  186. package/src/driverTypes.ts +0 -140
  187. package/src/drivers.android.ts +0 -8
  188. package/src/drivers.ios.ts +0 -8
  189. package/src/drivers.native.ts +0 -899
  190. package/src/drivers.ts +0 -427
  191. package/src/index.web.ts +0 -23
  192. package/src/reactRequests.ts +0 -76
  193. package/src/webClipPath.ts +0 -30
@@ -21,6 +21,7 @@
21
21
 
22
22
  #include "SmoothClipAnimationId.h"
23
23
  #include "SmoothClipAnimationCurve.h"
24
+ #include "SmoothClipSharedGeometry.h"
24
25
 
25
26
  @interface SmoothClipView (Registry)
26
27
  - (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation;
@@ -31,18 +32,22 @@
31
32
  - (BOOL)smoothClipAnimateTiming:(smoothclip::Presentation)presentation
32
33
  animation:(smoothclip::TimingAnimation)animation
33
34
  animationId:(int32_t)animationId;
35
+ - (BOOL)smoothClipAnimateTiming:(smoothclip::Presentation)presentation
36
+ animation:(smoothclip::TimingAnimation)animation
37
+ animationId:(int32_t)animationId
38
+ sharedBeginTime:(CFTimeInterval)sharedBeginTime;
34
39
  - (BOOL)smoothClipAnimateSpring:(smoothclip::Presentation)presentation
35
40
  animation:(smoothclip::SpringAnimation)animation
36
41
  animationId:(int32_t)animationId;
37
- - (BOOL)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
38
- keyframes:(const std::vector<smoothclip::Keyframe> &)keyframes
39
- durationMs:(double)durationMs
40
- animationId:(int32_t)animationId;
42
+ - (BOOL)smoothClipAnimateSpring:(smoothclip::Presentation)presentation
43
+ animation:(smoothclip::SpringAnimation)animation
44
+ animationId:(int32_t)animationId
45
+ sharedBeginTime:(CFTimeInterval)sharedBeginTime;
41
46
  - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
42
47
  - (BOOL)smoothClipIsJoinable;
43
48
  - (BOOL)smoothClipCanDisplay;
44
- - (BOOL)smoothClipHasPendingInstall;
45
- - (double)smoothClipSpringContinuationVelocity;
49
+ - (double)smoothClipInheritedVelocityToPresentation:
50
+ (smoothclip::Presentation)presentation;
46
51
  - (void)smoothClipClearVelocitySamples;
47
52
  @end
48
53
 
@@ -52,15 +57,15 @@ namespace {
52
57
  using ViewKey = uintptr_t;
53
58
 
54
59
  enum class Ownership { Interactive, Native };
55
- enum class AnimationKind { Timing, Spring, Keyframes };
60
+ enum class AnimationKind { Timing, Spring };
56
61
 
57
62
  struct ActiveAnimation {
58
63
  int32_t animationId;
64
+ int32_t completionTag = 0;
65
+ // Zero identifies a single-driver animation. A non-zero value binds
66
+ // the driver to an immutable group whose completion is aggregated once.
67
+ int32_t groupId = 0;
59
68
  std::unordered_set<ViewKey> participants;
60
- // Hosts that installed this id and temporarily lost displayability. They
61
- // may rejoin the same remainder; completion is false if any remain
62
- // unresolved when the run ends or unregister.
63
- std::unordered_set<ViewKey> suspendedParticipants;
64
69
  bool finished = true;
65
70
  AnimationKind kind = AnimationKind::Timing;
66
71
  // Geometry the transition started from. Used when a view must join the
@@ -68,16 +73,8 @@ struct ActiveAnimation {
68
73
  Presentation start{{0, 0, 0, 0, 0}, 0, 0};
69
74
  TimingAnimation timing{};
70
75
  SpringAnimation spring{};
71
- std::vector<Keyframe> keyframes;
72
- double durationMs = 0;
73
- CFTimeInterval startedAt = 0;
74
- // False while the animation is latched. Two ways in: built before any host
75
- // view could display (pre-registration, or a host in a detached subtree), or
76
- // re-latched by unregisterView when the last DISPLAYABLE host left
77
- // mid-flight. Started by whichever registration/displayability update or
78
- // joinActiveAnimation first finds a host that can produce a frame; each
79
- // rebases startedAt through startLatchedAnimation. startedAt is only
80
- // 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.
81
78
  bool started = false;
82
79
  };
83
80
 
@@ -98,11 +95,31 @@ struct CompletionSink {
98
95
  CompletionCallback callback;
99
96
  };
100
97
 
98
+ struct GroupCompletionSink {
99
+ const void *owner = nullptr;
100
+ GroupCompletionCallback callback;
101
+ };
102
+
103
+ struct GroupState {
104
+ uint64_t controllerId;
105
+ int32_t groupId;
106
+ int32_t completionTag;
107
+ std::vector<uint64_t> driverIds;
108
+ std::unordered_set<uint64_t> remainingDriverIds;
109
+ bool finished = true;
110
+ bool mutating = false;
111
+ };
112
+
101
113
  std::unordered_map<uint64_t, DriverState> &registry() {
102
114
  static std::unordered_map<uint64_t, DriverState> value;
103
115
  return value;
104
116
  }
105
117
 
118
+ std::unordered_map<int32_t, GroupState> &groupRegistry() {
119
+ static std::unordered_map<int32_t, GroupState> value;
120
+ return value;
121
+ }
122
+
106
123
  // The transitions must be observed for the whole process lifetime, not per
107
124
  // view: the state below is process-global, and a notification delivered while
108
125
  // no SmoothClipView happens to be alive (the last host unmounted during
@@ -143,6 +160,11 @@ CompletionSink &completionSink() {
143
160
  return sink;
144
161
  }
145
162
 
163
+ GroupCompletionSink &groupCompletionSink() {
164
+ static GroupCompletionSink sink;
165
+ return sink;
166
+ }
167
+
146
168
  // The sink is written from the TurboModule ctor/dtor on the JS thread and
147
169
  // invoked on main. Invoking under the lock also keeps the owning module
148
170
  // alive for the duration of the callback.
@@ -151,6 +173,19 @@ std::mutex &completionSinkMutex() {
151
173
  return value;
152
174
  }
153
175
 
176
+
177
+ void groupMemberCompleted(
178
+ int32_t groupId,
179
+ uint64_t driverId,
180
+ bool finished);
181
+ std::vector<DriverSnapshot> cancelGroupInternal(
182
+ int32_t groupId,
183
+ GroupCancelBehavior behavior,
184
+ bool completionFinished);
185
+ void tryStartGroup(int32_t groupId);
186
+ void finishGroupForHostLoss(int32_t groupId);
187
+ DriverSnapshot snapshotForDriver(uint64_t driverId);
188
+
154
189
  Presentation unavailablePresentation() {
155
190
  // Non-finite geometry makes the JS side fall back to its current value
156
191
  // instead of applying zeros.
@@ -183,11 +218,22 @@ void emitCompletion(
183
218
  DriverState &state,
184
219
  int32_t animationId,
185
220
  bool finished) {
221
+ const int32_t groupId = state.animation.has_value()
222
+ ? state.animation->groupId
223
+ : 0;
224
+ const int32_t completionTag = state.animation.has_value()
225
+ ? state.animation->completionTag
226
+ : 0;
186
227
  state.animation.reset();
228
+ if (groupId != 0) {
229
+ groupMemberCompleted(groupId, driverId, finished);
230
+ return;
231
+ }
187
232
  {
188
233
  std::lock_guard<std::mutex> lock(completionSinkMutex());
189
234
  if (completionSink().callback) {
190
- completionSink().callback(driverId, animationId, finished);
235
+ completionSink().callback(
236
+ driverId, animationId, completionTag, finished);
191
237
  }
192
238
  }
193
239
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -198,14 +244,62 @@ void emitCompletion(
198
244
  #endif
199
245
  }
200
246
 
247
+ void emitGroupCompletion(
248
+ uint64_t controllerId,
249
+ int32_t groupId,
250
+ int32_t completionTag,
251
+ bool finished,
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
+ }
258
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
259
+ if (groupCompletionSink().callback) {
260
+ groupCompletionSink().callback(
261
+ controllerId,
262
+ groupId,
263
+ completionTag,
264
+ finished,
265
+ std::move(snapshots));
266
+ }
267
+ }
268
+
269
+ void groupMemberCompleted(
270
+ int32_t groupId,
271
+ uint64_t driverId,
272
+ bool finished) {
273
+ auto iterator = groupRegistry().find(groupId);
274
+ if (iterator == groupRegistry().end()) return;
275
+ GroupState &group = iterator->second;
276
+ if (group.remainingDriverIds.erase(driverId) == 0) return;
277
+ group.finished = group.finished && finished;
278
+ if (!group.remainingDriverIds.empty() || group.mutating) return;
279
+
280
+ const uint64_t controllerId = group.controllerId;
281
+ const int32_t completionTag = group.completionTag;
282
+ const bool aggregateFinished = group.finished;
283
+ const std::vector<uint64_t> driverIds = group.driverIds;
284
+ groupRegistry().erase(iterator);
285
+ emitGroupCompletion(
286
+ controllerId,
287
+ groupId,
288
+ completionTag,
289
+ aggregateFinished,
290
+ driverIds);
291
+ }
292
+
201
293
  void emitStandaloneCompletion(
202
294
  uint64_t driverId,
203
295
  int32_t animationId,
296
+ int32_t completionTag,
204
297
  bool finished) {
205
298
  {
206
299
  std::lock_guard<std::mutex> lock(completionSinkMutex());
207
300
  if (completionSink().callback) {
208
- completionSink().callback(driverId, animationId, finished);
301
+ completionSink().callback(
302
+ driverId, animationId, completionTag, finished);
209
303
  }
210
304
  }
211
305
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -219,7 +313,7 @@ void emitStandaloneCompletion(
219
313
  Presentation canonicalFrozenPresentation(
220
314
  DriverState &state,
221
315
  SmoothClipView *preferred = nil) {
222
- // A latched animation never rendered, so state.latest already holds its
316
+ // A pre-ready animation never rendered, so state.latest already holds its
223
317
  // target; freezing there would jump the clip. Freeze at the start instead.
224
318
  Presentation canonical =
225
319
  state.animation.has_value() && !state.animation->started
@@ -241,13 +335,12 @@ Presentation canonicalFrozenPresentation(
241
335
  canonical = frozen;
242
336
  hasCanonical = true;
243
337
  } else if (!hasParticipantFallback && state.animation.has_value() &&
244
- (state.animation->participants.count(key) > 0 ||
245
- state.animation->suspendedParticipants.count(key) > 0)) {
338
+ state.animation->participants.count(key) > 0) {
246
339
  // An installed participant whose HOST just lost its size still holds
247
340
  // real mid-flight geometry on its clip layer (host bounds do not feed
248
341
  // the clip container). Without this fallback, a sole running host
249
- // resized to zero froze the re-latch at state.latest — the target —
250
- // 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.
251
344
  hasParticipantFallback = true;
252
345
  participantFallback = frozen;
253
346
  }
@@ -258,11 +351,142 @@ Presentation canonicalFrozenPresentation(
258
351
  return canonical;
259
352
  }
260
353
 
354
+ Presentation canonicalVisiblePresentation(const DriverState &state) {
355
+ if (state.animation.has_value() && !state.animation->started) {
356
+ return state.animation->start;
357
+ }
358
+ for (const ViewKey key : state.views) {
359
+ SmoothClipView *candidate = viewForKey(key);
360
+ if ([candidate smoothClipIsJoinable]) {
361
+ return [candidate smoothClipCurrentPresentation];
362
+ }
363
+ }
364
+ return state.latest;
365
+ }
366
+
367
+ bool driverReady(const DriverState &state) {
368
+ if (state.destroyed) return false;
369
+ for (const ViewKey key : state.views) {
370
+ if ([viewForKey(key) smoothClipCanDisplay]) return true;
371
+ }
372
+ return false;
373
+ }
374
+
375
+ double resolvedSpringVelocity(
376
+ const DriverState &state,
377
+ const Presentation &target,
378
+ const SpringAnimation &spring) {
379
+ if (!spring.inheritVelocity) return spring.initialVelocity;
380
+ for (const ViewKey key : state.views) {
381
+ SmoothClipView *view = viewForKey(key);
382
+ if ([view smoothClipIsJoinable]) {
383
+ return [view smoothClipInheritedVelocityToPresentation:target];
384
+ }
385
+ }
386
+ return 0;
387
+ }
388
+
389
+ DriverSnapshot snapshotForDriver(uint64_t driverId) {
390
+ const auto iterator = registry().find(driverId);
391
+ if (iterator == registry().end() || iterator->second.destroyed) {
392
+ return {driverId, unavailablePresentation(), false};
393
+ }
394
+ return {
395
+ driverId,
396
+ canonicalVisiblePresentation(iterator->second),
397
+ driverReady(iterator->second),
398
+ };
399
+ }
400
+
401
+ std::vector<DriverSnapshot> cancelGroupInternal(
402
+ int32_t groupId,
403
+ GroupCancelBehavior behavior,
404
+ bool completionFinished) {
405
+ auto groupIterator = groupRegistry().find(groupId);
406
+ if (groupIterator == groupRegistry().end()) return {};
407
+ GroupState &group = groupIterator->second;
408
+ if (group.mutating) return {};
409
+ group.mutating = true;
410
+
411
+ const std::vector<uint64_t> driverIds = group.driverIds;
412
+ std::vector<DriverSnapshot> snapshots;
413
+ snapshots.reserve(driverIds.size());
414
+ for (const uint64_t driverId : driverIds) {
415
+ auto stateIterator = registry().find(driverId);
416
+ if (stateIterator == registry().end()) {
417
+ snapshots.push_back({driverId, unavailablePresentation(), false});
418
+ groupMemberCompleted(groupId, driverId, false);
419
+ continue;
420
+ }
421
+ DriverState &state = stateIterator->second;
422
+ if (!state.animation.has_value() ||
423
+ state.animation->groupId != groupId) {
424
+ snapshots.push_back(snapshotForDriver(driverId));
425
+ groupMemberCompleted(groupId, driverId, false);
426
+ continue;
427
+ }
428
+
429
+ const int32_t animationId = state.animation->animationId;
430
+ Presentation resolved;
431
+ if (behavior == GroupCancelBehavior::Finish) {
432
+ resolved = state.latest;
433
+ const bool wasPending = !state.animation->started;
434
+ for (const ViewKey key : state.views) {
435
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
436
+ }
437
+ if (wasPending) {
438
+ for (const ViewKey key : state.views) {
439
+ [viewForKey(key) smoothClipApplyPresentation:resolved
440
+ recordVelocitySample:NO];
441
+ }
442
+ }
443
+ } else {
444
+ resolved = canonicalFrozenPresentation(state);
445
+ }
446
+ state.latest = resolved;
447
+ state.hasLatest = true;
448
+ state.ownership = Ownership::Interactive;
449
+ const bool ready = driverReady(state);
450
+ emitCompletion(
451
+ driverId, state, animationId, completionFinished);
452
+ snapshots.push_back({driverId, resolved, ready});
453
+ }
454
+
455
+ groupIterator = groupRegistry().find(groupId);
456
+ if (groupIterator != groupRegistry().end()) {
457
+ GroupState &remainingGroup = groupIterator->second;
458
+ remainingGroup.mutating = false;
459
+ if (remainingGroup.remainingDriverIds.empty()) {
460
+ const uint64_t controllerId = remainingGroup.controllerId;
461
+ const int32_t completionTag = remainingGroup.completionTag;
462
+ const bool finished = remainingGroup.finished;
463
+ const std::vector<uint64_t> completedDrivers =
464
+ remainingGroup.driverIds;
465
+ groupRegistry().erase(groupIterator);
466
+ emitGroupCompletion(
467
+ controllerId,
468
+ groupId,
469
+ completionTag,
470
+ finished,
471
+ completedDrivers);
472
+ }
473
+ }
474
+ return snapshots;
475
+ }
476
+
261
477
  void cancelActive(
262
478
  uint64_t driverId,
263
479
  DriverState &state,
264
480
  bool useTarget) {
265
481
  if (!state.animation.has_value()) return;
482
+ if (state.animation->groupId != 0) {
483
+ cancelGroupInternal(
484
+ state.animation->groupId,
485
+ useTarget ? GroupCancelBehavior::Finish
486
+ : GroupCancelBehavior::Freeze,
487
+ false);
488
+ return;
489
+ }
266
490
  const int32_t animationId = state.animation->animationId;
267
491
  // Deferred views are deliberately not completion participants, but they do
268
492
  // hold a pending install and requested target. Cancellation must still reach
@@ -273,14 +497,28 @@ void cancelActive(
273
497
  emitCompletion(driverId, state, animationId, false);
274
498
  }
275
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
+
276
515
  void finishIfNoInstalledParticipants(uint64_t driverId, DriverState &state) {
277
516
  if (!state.animation.has_value() || !state.animation->started ||
278
517
  !state.animation->participants.empty()) {
279
518
  return;
280
519
  }
281
520
  const int32_t animationId = state.animation->animationId;
282
- const bool finished = state.animation->finished &&
283
- state.animation->suspendedParticipants.empty();
521
+ const bool finished = state.animation->finished;
284
522
  // A detached/unlaid-out view can have a requested animation without any CA
285
523
  // delegate capable of completing it. Once every installed animation ends,
286
524
  // make those deferred views statically correct and invalidate their pending
@@ -296,6 +534,7 @@ void applyPresentation(
296
534
  DriverState &state,
297
535
  Presentation presentation,
298
536
  bool recordVelocitySample = true) {
537
+ if (!canonicalizePresentation(presentation)) return;
299
538
  state.latest = presentation;
300
539
  state.hasLatest = true;
301
540
  state.ownership = Ownership::Interactive;
@@ -310,8 +549,10 @@ void prepareAnimation(
310
549
  DriverState &state,
311
550
  AnimationStart start,
312
551
  Presentation target) {
313
- const bool acceptsInteractiveStart =
314
- start.hasInteractiveStart && state.ownership == Ownership::Interactive;
552
+ // An explicit `from` is part of the new animation's start transaction. It
553
+ // intentionally overrides a sampled native frame even when this operation
554
+ // is replacing another native-owned animation.
555
+ const bool acceptsInteractiveStart = start.hasInteractiveStart;
315
556
  cancelActive(driverId, state, false);
316
557
  if (acceptsInteractiveStart) {
317
558
  applyPresentation(state, start.interactiveStart);
@@ -326,72 +567,42 @@ void prepareAnimation(
326
567
  Presentation resolvedAnimationStart(
327
568
  const DriverState &state,
328
569
  const AnimationStart &start) {
329
- if (start.hasInteractiveStart && state.ownership == Ownership::Interactive) {
570
+ if (start.hasInteractiveStart) {
330
571
  return start.interactiveStart;
331
572
  }
332
- // 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):
333
574
  // it never rendered, and state.latest is its target — start from where the
334
- // clip actually is, the latch's own start.
575
+ // clip actually is, the pending run's own start.
335
576
  if (state.animation.has_value() && !state.animation->started) {
336
577
  return state.animation->start;
337
578
  }
338
- return state.latest;
579
+ return state.animation.has_value()
580
+ ? canonicalVisiblePresentation(state)
581
+ : state.latest;
339
582
  }
340
583
 
341
- // Installs the remaining part of the active animation on a view that joins
342
- // mid-flight, starting from `visible`. Shared by registerView (mount during
343
- // animation) and joinActiveAnimation (first layout after such a mount).
344
- void dispatchActiveAnimationJoin(
584
+ // Installs a run that waited for its host to become displayable.
585
+ void installPendingAnimation(
345
586
  DriverState &state,
346
587
  SmoothClipView *view,
347
588
  ViewKey key,
348
589
  const Presentation &visible,
349
- SmoothClipView *reference = nil) {
590
+ CFTimeInterval sharedBeginTime = 0) {
350
591
  auto &active = *state.animation;
351
- const double elapsedMs = (CACurrentMediaTime() - active.startedAt) * 1000.0;
352
- const double remainingMs = MAX(0, active.durationMs - elapsedMs);
353
592
  BOOL installed = NO;
354
593
  if (active.kind == AnimationKind::Timing) {
355
- const double progress = active.durationMs <= 0
356
- ? 1
357
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
358
- TimingAnimation timing = timingRemainder(active.timing, progress).animation;
359
594
  installed = [view smoothClipAnimateTiming:state.latest
360
- animation:timing
361
- animationId:active.animationId];
595
+ animation:active.timing
596
+ animationId:active.animationId
597
+ sharedBeginTime:sharedBeginTime];
362
598
  } else if (active.kind == AnimationKind::Spring) {
363
- SpringAnimation spring = active.spring;
364
- if (reference != nil) {
365
- // A late host must join the spring's CURRENT physical state. Reusing the
366
- // original launch velocity (or resolving `inherit` from the joining
367
- // host's unrelated samples) creates a different spring at the seam.
368
- spring.inheritVelocity = false;
369
- spring.initialVelocity =
370
- [reference smoothClipSpringContinuationVelocity];
371
- }
372
599
  installed = [view smoothClipAnimateSpring:state.latest
373
- animation:spring
374
- animationId:active.animationId];
375
- } else {
376
- const double progress = active.durationMs <= 0
377
- ? 1
378
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
379
- std::vector<Keyframe> remaining;
380
- remaining.push_back({0, visible});
381
- for (const Keyframe &frame : active.keyframes) {
382
- if (frame.offset <= progress) continue;
383
- const double offset = (frame.offset - progress) / (1 - progress);
384
- remaining.push_back({offset, frame.presentation});
385
- }
386
- if (remaining.size() == 1) remaining.push_back({1, state.latest});
387
- installed = [view smoothClipAnimateKeyframes:state.latest
388
- keyframes:remaining
389
- durationMs:remainingMs
390
- animationId:active.animationId];
600
+ animation:active.spring
601
+ animationId:active.animationId
602
+ sharedBeginTime:sharedBeginTime];
391
603
  }
392
604
  if (installed) {
393
605
  active.participants.insert(key);
394
- active.suspendedParticipants.erase(key);
395
606
  }
396
607
  }
397
608
 
@@ -407,85 +618,11 @@ bool anyDisplayableView(const DriverState &state) {
407
618
  return false;
408
619
  }
409
620
 
410
- void relatchActiveAnimation(
411
- uint64_t driverId,
412
- DriverState &state,
413
- SmoothClipView *preferred = nil) {
414
- if (!state.animation.has_value() || !state.animation->started) return;
415
- auto &active = *state.animation;
416
- double springVelocity = 0;
417
- if (active.kind == AnimationKind::Spring) {
418
- SmoothClipView *source = preferred;
419
- if (source == nil ||
420
- active.participants.count(keyForView(source)) == 0) {
421
- source = active.participants.empty()
422
- ? nil
423
- : viewForKey(*active.participants.begin());
424
- }
425
- if (source != nil) {
426
- springVelocity = [source smoothClipSpringContinuationVelocity];
427
- }
428
- }
429
- const Presentation frozen = canonicalFrozenPresentation(state, preferred);
430
- // Captured before the migration below: only hosts that were ALREADY
431
- // suspended are unresolved. The active participants being frozen right now
432
- // rendered every frame up to this instant.
433
- const bool hadUnresolvedSuspended = !active.suspendedParticipants.empty();
434
- active.suspendedParticipants.insert(
435
- active.participants.begin(), active.participants.end());
436
- active.participants.clear();
437
-
438
- const double elapsedMs = MAX(
439
- 0, (CACurrentMediaTime() - active.startedAt) * 1000.0);
440
- const double progress = active.durationMs <= 0
441
- ? 1
442
- : MIN(1, MAX(0, elapsedMs / active.durationMs));
443
- if (active.kind == AnimationKind::Timing) {
444
- active.timing = timingRemainder(active.timing, progress).animation;
445
- active.durationMs = active.timing.durationMs;
446
- } else if (active.kind == AnimationKind::Keyframes) {
447
- const KeyframeContinuation continuation = keyframeContinuation(
448
- active.keyframes,
449
- frozen,
450
- state.latest,
451
- active.durationMs,
452
- progress);
453
- active.keyframes = continuation.frames;
454
- active.durationMs = continuation.durationMs;
455
- } else {
456
- active.spring.inheritVelocity = false;
457
- active.spring.initialVelocity = springVelocity;
458
- }
459
-
460
- if (state.destroyed ||
461
- (active.kind != AnimationKind::Spring && active.durationMs <= 0)) {
462
- // A residual trimmed to zero means the curve fully elapsed while a host
463
- // displayed every frame — only the asynchronous didStop was outstanding
464
- // when this freeze arrived. Report that run honestly instead of stamping
465
- // the freeze-path false; hosts that were suspended before the freeze are
466
- // unresolved and still poison it.
467
- const bool ranToEnd = !state.destroyed && progress >= 1 &&
468
- active.finished && !hadUnresolvedSuspended;
469
- // Deferred peers hold pending installs for this id; invalidate them and
470
- // finalize statically at the target, exactly as the didStop completion
471
- // path does. Frozen participants already have no animation to cancel.
472
- for (const ViewKey key : state.views) {
473
- [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
474
- }
475
- state.ownership = Ownership::Interactive;
476
- emitCompletion(driverId, state, active.animationId, ranToEnd);
621
+ void startPendingAnimation(DriverState &state) {
622
+ if (state.animation->groupId != 0) {
623
+ tryStartGroup(state.animation->groupId);
477
624
  return;
478
625
  }
479
- active.start = frozen;
480
- active.started = false;
481
- }
482
-
483
- // Starts a latched animation: rebases the clock so no progress was burned
484
- // while no view could display, then dispatches the join to every registered
485
- // view. Views that still cannot display defer their install per-view and
486
- // resume via a later positive displayability update.
487
- void startLatchedAnimation(DriverState &state) {
488
- state.animation->startedAt = CACurrentMediaTime();
489
626
  state.animation->started = true;
490
627
  // Participants describe live CA delegates, not registered views. Anything
491
628
  // left from the frozen run can only produce a stale callback for that run.
@@ -494,10 +631,55 @@ void startLatchedAnimation(DriverState &state) {
494
631
  for (const ViewKey key : state.views) {
495
632
  SmoothClipView *participant = viewForKey(key);
496
633
  [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
497
- dispatchActiveAnimationJoin(state, participant, key, start);
634
+ installPendingAnimation(state, participant, key, start);
498
635
  }
499
636
  }
500
637
 
638
+ void tryStartGroup(int32_t groupId) {
639
+ auto groupIterator = groupRegistry().find(groupId);
640
+ if (groupIterator == groupRegistry().end() ||
641
+ groupIterator->second.mutating) {
642
+ return;
643
+ }
644
+ const std::vector<uint64_t> driverIds = groupIterator->second.driverIds;
645
+ for (const uint64_t driverId : driverIds) {
646
+ const auto stateIterator = registry().find(driverId);
647
+ if (stateIterator == registry().end() ||
648
+ !stateIterator->second.animation.has_value() ||
649
+ stateIterator->second.animation->groupId != groupId ||
650
+ !driverReady(stateIterator->second)) {
651
+ return;
652
+ }
653
+ }
654
+
655
+ const CFTimeInterval sharedBeginTime = CACurrentMediaTime();
656
+ [CATransaction begin];
657
+ [CATransaction setDisableActions:YES];
658
+ for (const uint64_t driverId : driverIds) {
659
+ DriverState &state = registry().find(driverId)->second;
660
+ ActiveAnimation &active = *state.animation;
661
+ active.started = true;
662
+ active.participants.clear();
663
+ const Presentation start = active.start;
664
+ for (const ViewKey key : state.views) {
665
+ SmoothClipView *participant = viewForKey(key);
666
+ [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
667
+ installPendingAnimation(
668
+ state, participant, key, start, sharedBeginTime);
669
+ }
670
+ }
671
+ [CATransaction commit];
672
+ }
673
+
674
+ void finishGroupForHostLoss(int32_t groupId) {
675
+ auto groupIterator = groupRegistry().find(groupId);
676
+ if (groupIterator == groupRegistry().end() ||
677
+ groupIterator->second.mutating) {
678
+ return;
679
+ }
680
+ cancelGroupInternal(groupId, GroupCancelBehavior::Freeze, false);
681
+ }
682
+
501
683
  } // namespace
502
684
 
503
685
  bool applicationIsActive() {
@@ -508,8 +690,19 @@ void applicationWillResignActive() {
508
690
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
509
691
  if (!applicationActiveState()) return;
510
692
  applicationActiveState() = false;
693
+ std::vector<int32_t> groupIds;
694
+ groupIds.reserve(groupRegistry().size());
695
+ for (const auto &[groupId, group] : groupRegistry()) {
696
+ (void)group;
697
+ groupIds.push_back(groupId);
698
+ }
699
+ for (const int32_t groupId : groupIds) {
700
+ finishGroupForHostLoss(groupId);
701
+ }
511
702
  for (auto &[driverId, state] : registry()) {
512
- relatchActiveAnimation(driverId, state);
703
+ if (!state.animation.has_value() || state.animation->groupId == 0) {
704
+ finishStandaloneAtTarget(driverId, state);
705
+ }
513
706
  }
514
707
  }
515
708
 
@@ -517,11 +710,20 @@ void applicationDidBecomeActive() {
517
710
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
518
711
  if (applicationActiveState()) return;
519
712
  applicationActiveState() = true;
713
+ std::vector<int32_t> groupIds;
714
+ groupIds.reserve(groupRegistry().size());
715
+ for (const auto &[groupId, group] : groupRegistry()) {
716
+ (void)group;
717
+ groupIds.push_back(groupId);
718
+ }
719
+ for (const int32_t groupId : groupIds) {
720
+ tryStartGroup(groupId);
721
+ }
520
722
  for (auto &[driverId, state] : registry()) {
521
723
  (void)driverId;
522
724
  if (state.animation.has_value() && !state.animation->started &&
523
- anyDisplayableView(state)) {
524
- startLatchedAnimation(state);
725
+ state.animation->groupId == 0 && anyDisplayableView(state)) {
726
+ startPendingAnimation(state);
525
727
  }
526
728
  }
527
729
  }
@@ -541,13 +743,37 @@ void clearCompletionCallback(const void *owner) {
541
743
  completionSink().callback = nullptr;
542
744
  }
543
745
 
746
+ void setGroupCompletionCallback(
747
+ const void *owner,
748
+ GroupCompletionCallback callback) {
749
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
750
+ groupCompletionSink().owner = owner;
751
+ groupCompletionSink().callback = std::move(callback);
752
+ }
753
+
754
+ void clearGroupCompletionCallback(const void *owner) {
755
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
756
+ if (groupCompletionSink().owner != owner) return;
757
+ groupCompletionSink().owner = nullptr;
758
+ groupCompletionSink().callback = nullptr;
759
+ }
760
+
544
761
  void registerView(
545
762
  uint64_t driverId,
546
763
  SmoothClipView *view,
547
764
  Presentation initialPresentation) {
548
765
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
766
+ if (!canonicalizePresentation(initialPresentation)) return;
549
767
  auto &state = registry()[driverId];
550
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
+ }
551
777
  // Seed canonical geometry from the mounting view's initial props BEFORE
552
778
  // sampling `visible`; otherwise the first registration captures the default
553
779
  // empty {0,0,0,0} rect, seeding the view (and the presentation layer the
@@ -557,57 +783,34 @@ void registerView(
557
783
  state.latest = initialPresentation;
558
784
  state.hasLatest = true;
559
785
  }
560
- const bool hasAnimation = state.animation.has_value();
561
- const bool hasLatch = hasAnimation && !state.animation->started;
562
- // 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
563
789
  // registration from a detached subtree (transparentModal before its view
564
- // controller is presented) keeps the latch held; the positive
790
+ // controller is presented) keeps it waiting; a positive
565
791
  // displayability update starts it inside the attach commit.
566
- const bool startsLatch = hasLatch && [view smoothClipCanDisplay];
567
- const bool shouldReplay =
568
- hasAnimation && state.animation->started && !state.views.empty();
792
+ const bool startsPendingRun =
793
+ hasPendingRun && [view smoothClipCanDisplay];
569
794
  Presentation visible = state.latest;
570
- SmoothClipView *reference = nil;
571
- if (shouldReplay) {
572
- // A registered peer that never laid out reports zero geometry; joining
573
- // from it would visibly collapse the clip. Prefer the first laid-out
574
- // peer and fall back to the animation's start.
575
- for (const ViewKey candidate : state.animation->participants) {
576
- SmoothClipView *candidateView = viewForKey(candidate);
577
- if ([candidateView smoothClipIsJoinable]) {
578
- reference = candidateView;
579
- break;
580
- }
581
- }
582
- visible = reference != nil
583
- ? [reference smoothClipCurrentPresentation]
584
- : state.animation->start;
585
- } else if (hasLatch) {
586
- // 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
587
797
  // exactly the jump this path exists to avoid.
588
798
  visible = state.animation->start;
589
799
  }
590
- const ViewKey key = keyForView(view);
591
- if (std::find(state.views.begin(), state.views.end(), key) ==
592
- state.views.end()) {
800
+ if (existing == state.views.end()) {
593
801
  state.views.push_back(key);
594
802
  }
595
803
  [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
596
- if (startsLatch) {
597
- // Rebase the join clock and dispatch to every registered view. Only views
598
- // that actually install their CA groups become completion participants.
599
- startLatchedAnimation(state);
600
- return;
804
+ if (startsPendingRun) {
805
+ startPendingAnimation(state);
601
806
  }
602
- if (!shouldReplay) return;
603
- dispatchActiveAnimationJoin(state, view, key, visible, reference);
604
807
  }
605
808
 
606
809
  void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
607
810
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
608
811
  auto iterator = registry().find(driverId);
609
812
  if (iterator == registry().end()) return;
610
- auto &state = iterator->second;
813
+ DriverState &state = iterator->second;
611
814
  if (!state.animation.has_value()) return;
612
815
  const ViewKey key = keyForView(view);
613
816
  if (std::find(state.views.begin(), state.views.end(), key) ==
@@ -615,116 +818,39 @@ void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
615
818
  return;
616
819
  }
617
820
  if (![view smoothClipCanDisplay]) {
618
- if (!state.animation->started) return;
619
- if (state.animation->participants.count(key) == 0) return;
620
- if (!anyDisplayableView(state)) {
621
- relatchActiveAnimation(driverId, state, view);
622
- 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);
623
825
  }
624
- [view smoothClipFreezePresentation];
625
- state.animation->participants.erase(key);
626
- state.animation->suspendedParticipants.insert(key);
627
826
  return;
628
827
  }
629
828
  if (!state.animation->started) {
630
- startLatchedAnimation(state);
631
- return;
632
- }
633
- // A deferred or suspended host joins from the registry's rebased remainder.
634
- if ([view smoothClipHasPendingInstall] ||
635
- state.animation->suspendedParticipants.count(key) > 0) {
636
- joinActiveAnimation(driverId, view);
637
- }
638
- }
639
-
640
- bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
641
- NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
642
- auto iterator = registry().find(driverId);
643
- if (iterator == registry().end()) {
644
- return false;
645
- }
646
- auto &state = iterator->second;
647
- if (!state.animation.has_value() || state.views.empty()) {
648
- return false;
649
- }
650
- const ViewKey key = keyForView(view);
651
- if (std::find(state.views.begin(), state.views.end(), key) ==
652
- state.views.end()) {
653
- return false;
654
- }
655
- // A pending install used to imply the animation was dispatched, which
656
- // implied started. Re-latching on displayability broke that: when the last
657
- // displayable host leaves, a peer that deferred its install keeps
658
- // `_pendingAnimationInstall` while the animation goes back to un-started, and
659
- // its first layout lands here. Start the latch — returning false would make
660
- // the caller clear `_activeAnimationId` and apply the requested geometry,
661
- // snapping this view straight to the target. A *join* must still never
662
- // compute elapsed time against an un-rebased latch clock, so this returns
663
- // instead of falling through.
664
- if (!state.animation->started) {
665
- if (![view smoothClipCanDisplay]) return false;
666
- startLatchedAnimation(state);
667
- return true;
668
- }
669
- // Join from a laid-out peer's live presentation, exactly as a view that had
670
- // registered at this moment would. When the joining view is the only
671
- // registered host, its own layer never received geometry (the install was
672
- // deferred for lack of layout), so fall back to the animation's start.
673
- SmoothClipView *reference = nil;
674
- for (const ViewKey candidate : state.animation->participants) {
675
- if (candidate != key && [viewForKey(candidate) smoothClipIsJoinable]) {
676
- reference = viewForKey(candidate);
677
- break;
829
+ if (state.animation->groupId != 0) {
830
+ tryStartGroup(state.animation->groupId);
831
+ } else {
832
+ startPendingAnimation(state);
678
833
  }
679
834
  }
680
- const Presentation visible = reference != nil
681
- ? [reference smoothClipCurrentPresentation]
682
- : state.animation->start;
683
- [view smoothClipApplyPresentation:visible recordVelocitySample:NO];
684
- dispatchActiveAnimationJoin(state, view, key, visible, reference);
685
- return true;
686
835
  }
687
836
 
688
837
  void unregisterView(uint64_t driverId, SmoothClipView *view) {
689
838
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
690
839
  auto iterator = registry().find(driverId);
691
840
  if (iterator == registry().end()) return;
692
-
693
- auto &state = iterator->second;
841
+ DriverState &state = iterator->second;
694
842
  const ViewKey key = keyForView(view);
695
- bool installed = false;
696
843
  if (state.animation.has_value()) {
697
- installed = state.animation->participants.count(key) > 0 ||
698
- state.animation->suspendedParticipants.count(key) > 0;
699
- if (state.animation->started &&
700
- state.animation->participants.count(key) > 0) {
701
- bool otherDisplayable = false;
702
- for (const ViewKey candidate : state.views) {
703
- if (candidate != key && [viewForKey(candidate) smoothClipCanDisplay]) {
704
- otherDisplayable = true;
705
- break;
706
- }
707
- }
708
- if (!otherDisplayable) {
709
- relatchActiveAnimation(driverId, state, view);
710
- }
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);
711
848
  }
712
849
  }
713
-
714
850
  state.views.erase(
715
851
  std::remove(state.views.begin(), state.views.end(), key),
716
852
  state.views.end());
717
- if (state.animation.has_value()) {
718
- state.animation->participants.erase(key);
719
- state.animation->suspendedParticipants.erase(key);
720
- if (installed) state.animation->finished = false;
721
- if (state.animation->started && state.animation->participants.empty()) {
722
- finishIfNoInstalledParticipants(driverId, state);
723
- }
724
- }
725
- if (state.destroyed && state.views.empty()) {
726
- registry().erase(iterator);
727
- }
853
+ if (state.destroyed && state.views.empty()) registry().erase(iterator);
728
854
  }
729
855
 
730
856
  void setPresentation(
@@ -733,6 +859,7 @@ void setPresentation(
733
859
  bool takeOwnership,
734
860
  bool overridePendingAnimation,
735
861
  bool recordVelocity) {
862
+ if (!canonicalizePresentation(presentation)) return;
736
863
  // CALayer writes require the main thread (not main-queue drain context).
737
864
  if (!NSThread.isMainThread) {
738
865
  // Never block an off-main caller: a synchronous hop can deadlock against
@@ -784,7 +911,7 @@ void setPresentation(
784
911
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
785
912
  if (signpostsEnabled) {
786
913
  os_signpost_interval_end(
787
- signpostLog(), identifier, "lookup-fanout", "ignored=held-latch");
914
+ signpostLog(), identifier, "lookup-fanout", "ignored=pending-run");
788
915
  }
789
916
  #endif
790
917
  return;
@@ -806,7 +933,8 @@ Presentation beginInteraction(uint64_t driverId) {
806
933
  // See setPresentation: blocking off-main risks a deadlock, so the call
807
934
  // fails defined and the JS side keeps its current value.
808
935
  return unavailablePresentation();
809
- } auto iterator = registry().find(driverId);
936
+ }
937
+ auto iterator = registry().find(driverId);
810
938
  if (iterator == registry().end()) return unavailablePresentation();
811
939
  auto &state = iterator->second;
812
940
  state.destroyed = false;
@@ -815,6 +943,16 @@ Presentation beginInteraction(uint64_t driverId) {
815
943
  return state.latest;
816
944
  }
817
945
 
946
+ if (state.animation->groupId != 0) {
947
+ const int32_t groupId = state.animation->groupId;
948
+ cancelGroupInternal(
949
+ groupId, GroupCancelBehavior::Freeze, false);
950
+ const auto current = registry().find(driverId);
951
+ return current == registry().end()
952
+ ? unavailablePresentation()
953
+ : current->second.latest;
954
+ }
955
+
818
956
  const int32_t animationId = state.animation->animationId;
819
957
  const Presentation canonical = canonicalFrozenPresentation(state);
820
958
  state.latest = canonical;
@@ -824,19 +962,393 @@ Presentation beginInteraction(uint64_t driverId) {
824
962
  return canonical;
825
963
  }
826
964
 
965
+ Presentation snapshotCurrent(uint64_t driverId) {
966
+ if (!NSThread.isMainThread) return unavailablePresentation();
967
+ const auto iterator = registry().find(driverId);
968
+ if (iterator == registry().end() || iterator->second.destroyed) {
969
+ return unavailablePresentation();
970
+ }
971
+ return canonicalVisiblePresentation(iterator->second);
972
+ }
973
+
974
+ std::vector<DriverSnapshot> snapshotGroup(
975
+ const std::vector<uint64_t> &driverIds) {
976
+ if (!NSThread.isMainThread) return {};
977
+ std::vector<DriverSnapshot> snapshots;
978
+ snapshots.reserve(driverIds.size());
979
+ for (const uint64_t driverId : driverIds) {
980
+ snapshots.push_back(snapshotForDriver(driverId));
981
+ }
982
+ return snapshots;
983
+ }
984
+
985
+ std::vector<DriverSnapshot> beginGroupInteraction(
986
+ const std::vector<uint64_t> &driverIds) {
987
+ if (!NSThread.isMainThread) return {};
988
+ std::unordered_set<uint64_t> unique;
989
+ for (const uint64_t driverId : driverIds) {
990
+ const auto iterator = registry().find(driverId);
991
+ if (driverId == 0 || !unique.insert(driverId).second ||
992
+ iterator == registry().end() || iterator->second.destroyed) {
993
+ std::vector<DriverSnapshot> unavailable;
994
+ unavailable.reserve(driverIds.size());
995
+ for (const uint64_t requested : driverIds) {
996
+ unavailable.push_back(
997
+ {requested, unavailablePresentation(), false});
998
+ }
999
+ return unavailable;
1000
+ }
1001
+ }
1002
+
1003
+ std::unordered_set<int32_t> cancelledGroups;
1004
+ for (const uint64_t driverId : driverIds) {
1005
+ DriverState &state = registry().find(driverId)->second;
1006
+ if (!state.animation.has_value()) continue;
1007
+ const int32_t groupId = state.animation->groupId;
1008
+ if (groupId != 0) {
1009
+ if (cancelledGroups.insert(groupId).second) {
1010
+ cancelGroupInternal(
1011
+ groupId, GroupCancelBehavior::Freeze, false);
1012
+ }
1013
+ continue;
1014
+ }
1015
+ const int32_t animationId = state.animation->animationId;
1016
+ const Presentation frozen = canonicalFrozenPresentation(state);
1017
+ state.latest = frozen;
1018
+ state.hasLatest = true;
1019
+ state.ownership = Ownership::Interactive;
1020
+ emitCompletion(driverId, state, animationId, false);
1021
+ }
1022
+
1023
+ std::vector<DriverSnapshot> snapshots;
1024
+ snapshots.reserve(driverIds.size());
1025
+ for (const uint64_t driverId : driverIds) {
1026
+ DriverState &state = registry().find(driverId)->second;
1027
+ state.destroyed = false;
1028
+ state.ownership = Ownership::Interactive;
1029
+ snapshots.push_back(
1030
+ {driverId, state.latest, driverReady(state)});
1031
+ }
1032
+ return snapshots;
1033
+ }
1034
+
1035
+ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
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
+ }
1041
+ std::unordered_set<uint64_t> unique;
1042
+ for (const BatchEntry &entry : canonicalEntries) {
1043
+ const auto iterator = registry().find(entry.driverId);
1044
+ if (entry.driverId == 0 || !unique.insert(entry.driverId).second ||
1045
+ iterator == registry().end() || iterator->second.destroyed ||
1046
+ !isFinitePresentation(entry.presentation)) {
1047
+ return false;
1048
+ }
1049
+ }
1050
+
1051
+ std::unordered_set<int32_t> cancelledGroups;
1052
+ for (const BatchEntry &entry : canonicalEntries) {
1053
+ DriverState &state = registry().find(entry.driverId)->second;
1054
+ if (!state.animation.has_value()) continue;
1055
+ const int32_t groupId = state.animation->groupId;
1056
+ if (groupId != 0) {
1057
+ if (cancelledGroups.insert(groupId).second) {
1058
+ cancelGroupInternal(
1059
+ groupId, GroupCancelBehavior::Freeze, false);
1060
+ }
1061
+ continue;
1062
+ }
1063
+ const int32_t animationId = state.animation->animationId;
1064
+ const Presentation frozen = canonicalFrozenPresentation(state);
1065
+ state.latest = frozen;
1066
+ state.hasLatest = true;
1067
+ state.ownership = Ownership::Interactive;
1068
+ emitCompletion(entry.driverId, state, animationId, false);
1069
+ }
1070
+
1071
+ [CATransaction begin];
1072
+ [CATransaction setDisableActions:YES];
1073
+ for (const BatchEntry &entry : canonicalEntries) {
1074
+ applyPresentation(
1075
+ registry().find(entry.driverId)->second, entry.presentation, true);
1076
+ }
1077
+ [CATransaction commit];
1078
+ return true;
1079
+ }
1080
+
1081
+ namespace {
1082
+
1083
+ bool preflightGroupEntries(
1084
+ std::vector<GroupMotionEntry> &entries,
1085
+ AnimationKind kind,
1086
+ const TimingAnimation &timing,
1087
+ const SpringAnimation &spring,
1088
+ double durationMs,
1089
+ int32_t reduceMotion,
1090
+ bool &reduced,
1091
+ std::vector<Presentation> &resolvedStarts) {
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
+ }
1099
+ if (kind == AnimationKind::Timing &&
1100
+ !isValidTiming(timing)) {
1101
+ return false;
1102
+ }
1103
+ if (kind == AnimationKind::Spring &&
1104
+ (!isValidSpring(spring) || spring.inheritVelocity)) {
1105
+ return false;
1106
+ }
1107
+ reduced = shouldReduceMotion(reduceMotion) ||
1108
+ (kind != AnimationKind::Spring && durationMs <= 0);
1109
+ std::unordered_set<uint64_t> unique;
1110
+ resolvedStarts.clear();
1111
+ resolvedStarts.reserve(entries.size());
1112
+ for (const GroupMotionEntry &entry : entries) {
1113
+ if (entry.driverId == 0 || !unique.insert(entry.driverId).second) {
1114
+ return false;
1115
+ }
1116
+ const auto iterator = registry().find(entry.driverId);
1117
+ if ((iterator == registry().end() || iterator->second.destroyed ||
1118
+ !iterator->second.hasLatest) && !entry.hasFrom) {
1119
+ return false;
1120
+ }
1121
+ const Presentation resolvedStart = entry.hasFrom
1122
+ ? entry.from
1123
+ : canonicalVisiblePresentation(iterator->second);
1124
+ if (!isFinitePresentation(resolvedStart) ||
1125
+ !isFinitePresentation(entry.target) ||
1126
+ !isAutonomousUniformCircular(resolvedStart) ||
1127
+ !isAutonomousUniformCircular(entry.target)) {
1128
+ return false;
1129
+ }
1130
+ if (kind == AnimationKind::Spring) {
1131
+ const double velocity = spring.initialVelocity;
1132
+ if (!springScaleStaysPositive(
1133
+ resolvedStart, entry.target, spring, velocity)) {
1134
+ return false;
1135
+ }
1136
+ }
1137
+ resolvedStarts.push_back(resolvedStart);
1138
+ }
1139
+ return true;
1140
+ }
1141
+
1142
+ void freezeReplacedAnimations(
1143
+ const std::vector<GroupMotionEntry> &entries) {
1144
+ std::unordered_set<int32_t> cancelledGroups;
1145
+ for (const GroupMotionEntry &entry : entries) {
1146
+ auto iterator = registry().find(entry.driverId);
1147
+ if (iterator == registry().end() ||
1148
+ !iterator->second.animation.has_value()) {
1149
+ continue;
1150
+ }
1151
+ DriverState &state = iterator->second;
1152
+ const int32_t oldGroupId = state.animation->groupId;
1153
+ if (oldGroupId != 0) {
1154
+ if (cancelledGroups.insert(oldGroupId).second) {
1155
+ cancelGroupInternal(
1156
+ oldGroupId, GroupCancelBehavior::Freeze, false);
1157
+ }
1158
+ continue;
1159
+ }
1160
+ const int32_t animationId = state.animation->animationId;
1161
+ const Presentation frozen = canonicalFrozenPresentation(state);
1162
+ state.latest = frozen;
1163
+ state.hasLatest = true;
1164
+ state.ownership = Ownership::Interactive;
1165
+ emitCompletion(entry.driverId, state, animationId, false);
1166
+ }
1167
+ }
1168
+
1169
+ int32_t createGroupAnimation(
1170
+ uint64_t controllerId,
1171
+ std::vector<GroupMotionEntry> entries,
1172
+ AnimationKind kind,
1173
+ TimingAnimation timing,
1174
+ SpringAnimation spring,
1175
+ double durationMs,
1176
+ int32_t reduceMotion,
1177
+ int32_t completionTag) {
1178
+ std::vector<Presentation> resolvedStarts;
1179
+ bool reduced = false;
1180
+ if (!NSThread.isMainThread || controllerId == 0 ||
1181
+ !preflightGroupEntries(
1182
+ entries,
1183
+ kind,
1184
+ timing,
1185
+ spring,
1186
+ durationMs,
1187
+ reduceMotion,
1188
+ reduced,
1189
+ resolvedStarts)) {
1190
+ return 0;
1191
+ }
1192
+
1193
+ // No state changes occur before every entry is known to be viable. Once
1194
+ // preflight succeeds, replacement freezes each overlapping group at most
1195
+ // once and reports its stale completion before installing the new epoch.
1196
+ freezeReplacedAnimations(entries);
1197
+ const int32_t groupId = allocateAnimationId();
1198
+ std::vector<uint64_t> driverIds;
1199
+ driverIds.reserve(entries.size());
1200
+ for (const GroupMotionEntry &entry : entries) {
1201
+ driverIds.push_back(entry.driverId);
1202
+ }
1203
+
1204
+ if (reduced) {
1205
+ [CATransaction begin];
1206
+ [CATransaction setDisableActions:YES];
1207
+ for (const GroupMotionEntry &entry : entries) {
1208
+ auto iterator = registry().find(entry.driverId);
1209
+ if (iterator == registry().end()) {
1210
+ iterator = registry().try_emplace(entry.driverId).first;
1211
+ }
1212
+ DriverState &state = iterator->second;
1213
+ state.destroyed = false;
1214
+ applyPresentation(state, entry.target, false);
1215
+ }
1216
+ [CATransaction commit];
1217
+ emitGroupCompletion(
1218
+ controllerId, groupId, completionTag, true, driverIds);
1219
+ return groupId;
1220
+ }
1221
+
1222
+ GroupState group{
1223
+ controllerId,
1224
+ groupId,
1225
+ completionTag,
1226
+ driverIds,
1227
+ std::unordered_set<uint64_t>(driverIds.begin(), driverIds.end()),
1228
+ };
1229
+ groupRegistry().emplace(groupId, std::move(group));
1230
+
1231
+ for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1232
+ GroupMotionEntry &entry = entries[entryIndex];
1233
+ auto iterator = registry().find(entry.driverId);
1234
+ if (iterator == registry().end()) {
1235
+ iterator = registry().try_emplace(entry.driverId).first;
1236
+ }
1237
+ DriverState &state = iterator->second;
1238
+ state.destroyed = false;
1239
+ if (entry.hasFrom) {
1240
+ applyPresentation(state, entry.from, true);
1241
+ }
1242
+ const Presentation start = resolvedStarts[entryIndex];
1243
+ state.latest = entry.target;
1244
+ state.hasLatest = true;
1245
+ state.ownership = Ownership::Native;
1246
+
1247
+ ActiveAnimation active{groupId};
1248
+ active.groupId = groupId;
1249
+ active.kind = kind;
1250
+ active.start = start;
1251
+ active.timing = timing;
1252
+ active.spring = spring;
1253
+ if (kind == AnimationKind::Spring) {
1254
+ active.spring.initialVelocity = spring.initialVelocity;
1255
+ active.spring.inheritVelocity = false;
1256
+ }
1257
+ active.started = false;
1258
+ state.animation = std::move(active);
1259
+ }
1260
+ tryStartGroup(groupId);
1261
+ return groupId;
1262
+ }
1263
+
1264
+ } // namespace
1265
+
1266
+ int32_t animateTimingGroup(
1267
+ uint64_t controllerId,
1268
+ std::vector<GroupMotionEntry> entries,
1269
+ TimingAnimation animation,
1270
+ int32_t completionTag,
1271
+ double startedAtHintS) {
1272
+ (void)startedAtHintS;
1273
+ return createGroupAnimation(
1274
+ controllerId,
1275
+ std::move(entries),
1276
+ AnimationKind::Timing,
1277
+ animation,
1278
+ {},
1279
+ animation.durationMs,
1280
+ animation.reduceMotion,
1281
+ completionTag);
1282
+ }
1283
+
1284
+ int32_t animateSpringGroup(
1285
+ uint64_t controllerId,
1286
+ std::vector<GroupMotionEntry> entries,
1287
+ SpringAnimation animation,
1288
+ int32_t completionTag,
1289
+ double startedAtHintS) {
1290
+ (void)startedAtHintS;
1291
+ return createGroupAnimation(
1292
+ controllerId,
1293
+ std::move(entries),
1294
+ AnimationKind::Spring,
1295
+ {},
1296
+ animation,
1297
+ 0,
1298
+ animation.reduceMotion,
1299
+ completionTag);
1300
+ }
1301
+
1302
+ std::vector<DriverSnapshot> cancelAnimationGroup(
1303
+ int32_t groupId,
1304
+ GroupCancelBehavior behavior) {
1305
+ if (!NSThread.isMainThread || groupId <= 0) return {};
1306
+ return cancelGroupInternal(
1307
+ groupId,
1308
+ behavior,
1309
+ behavior == GroupCancelBehavior::Finish);
1310
+ }
1311
+
827
1312
  int32_t animateTiming(
828
1313
  uint64_t driverId,
829
1314
  AnimationStart start,
830
1315
  Presentation presentation,
831
- TimingAnimation animation) {
1316
+ TimingAnimation animation,
1317
+ int32_t completionTag) {
832
1318
  if (!NSThread.isMainThread) {
833
1319
  // See setPresentation: blocking off-main risks a deadlock. 0 is the
834
1320
  // documented rejection sentinel.
835
1321
  return 0;
836
1322
  }
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) ||
1330
+ !validAnimation) {
1331
+ return 0;
1332
+ }
837
1333
  auto iterator = registry().find(driverId);
1334
+ Presentation preflightStart;
1335
+ if (iterator == registry().end()) {
1336
+ if (!start.hasInteractiveStart ||
1337
+ !isFinitePresentation(start.interactiveStart)) {
1338
+ return 0;
1339
+ }
1340
+ preflightStart = start.interactiveStart;
1341
+ } else {
1342
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1343
+ preflightStart = resolvedAnimationStart(iterator->second, start);
1344
+ }
1345
+ if (!isFinitePresentation(preflightStart) ||
1346
+ preflightStart.clip.curve != presentation.clip.curve) {
1347
+ return 0;
1348
+ }
1349
+ const bool reduced = shouldReduceMotion(animation.reduceMotion) ||
1350
+ animation.durationMs <= 0;
838
1351
  if (iterator == registry().end()) {
839
- if (!start.hasInteractiveStart) return 0;
840
1352
  iterator = registry().try_emplace(driverId).first;
841
1353
  iterator->second.latest = start.interactiveStart;
842
1354
  iterator->second.hasLatest = true;
@@ -844,30 +1356,27 @@ int32_t animateTiming(
844
1356
  auto &state = iterator->second;
845
1357
  state.destroyed = false;
846
1358
  const int32_t animationId = allocateAnimationId();
847
- const Presentation resolvedStart = resolvedAnimationStart(state, start);
1359
+ const Presentation resolvedStart = preflightStart;
848
1360
  prepareAnimation(driverId, state, start, presentation);
849
1361
 
850
- if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
1362
+ if (reduced) {
851
1363
  applyPresentation(state, presentation, false);
852
- emitCompletion(driverId, state, animationId, true);
1364
+ emitStandaloneCompletion(
1365
+ driverId, animationId, completionTag, true);
853
1366
  return animationId;
854
1367
  }
855
1368
 
856
1369
  ActiveAnimation active{animationId};
1370
+ active.completionTag = completionTag;
857
1371
  active.kind = AnimationKind::Timing;
858
1372
  active.start = resolvedStart;
859
1373
  active.timing = animation;
860
- active.durationMs = animation.durationMs;
861
- active.startedAt = CACurrentMediaTime();
862
1374
  active.started = anyDisplayableView(state);
863
1375
  state.animation = std::move(active);
864
1376
  if (!state.animation->started) {
865
- // Latch: no host view can display yet (animateTo raced the mount, or the
866
- // host sits in a detached subtree awaiting presentation). Ownership stays
867
- // Native — the pending animation owns rendering intent — and the first
868
- // displayable registration/attach rebases startedAt and starts it. The
869
- // non-zero id is still returned synchronously so the JS side does not
870
- // 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.
871
1380
  return animationId;
872
1381
  }
873
1382
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -891,14 +1400,44 @@ int32_t animateSpring(
891
1400
  uint64_t driverId,
892
1401
  AnimationStart start,
893
1402
  Presentation presentation,
894
- SpringAnimation animation) {
1403
+ SpringAnimation animation,
1404
+ int32_t completionTag) {
895
1405
  if (!NSThread.isMainThread) {
896
1406
  // See setPresentation: blocking off-main risks a deadlock.
897
1407
  return 0;
898
1408
  }
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) ||
1416
+ !validAnimation) {
1417
+ return 0;
1418
+ }
899
1419
  auto iterator = registry().find(driverId);
1420
+ Presentation preflightStart;
1421
+ if (iterator == registry().end()) {
1422
+ if (!start.hasInteractiveStart ||
1423
+ !isFinitePresentation(start.interactiveStart)) {
1424
+ return 0;
1425
+ }
1426
+ preflightStart = start.interactiveStart;
1427
+ } else {
1428
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1429
+ preflightStart = resolvedAnimationStart(iterator->second, start);
1430
+ }
1431
+ const double velocity = iterator == registry().end()
1432
+ ? animation.initialVelocity
1433
+ : resolvedSpringVelocity(iterator->second, presentation, animation);
1434
+ if (!isFinitePresentation(preflightStart) ||
1435
+ preflightStart.clip.curve != presentation.clip.curve ||
1436
+ !springScaleStaysPositive(
1437
+ preflightStart, presentation, animation, velocity)) {
1438
+ return 0;
1439
+ }
900
1440
  if (iterator == registry().end()) {
901
- if (!start.hasInteractiveStart) return 0;
902
1441
  iterator = registry().try_emplace(driverId).first;
903
1442
  iterator->second.latest = start.interactiveStart;
904
1443
  iterator->second.hasLatest = true;
@@ -906,24 +1445,27 @@ int32_t animateSpring(
906
1445
  auto &state = iterator->second;
907
1446
  state.destroyed = false;
908
1447
  const int32_t animationId = allocateAnimationId();
909
- const Presentation resolvedStart = resolvedAnimationStart(state, start);
1448
+ const Presentation resolvedStart = preflightStart;
910
1449
  prepareAnimation(driverId, state, start, presentation);
911
1450
 
912
1451
  if (shouldReduceMotion(animation.reduceMotion)) {
913
1452
  applyPresentation(state, presentation, false);
914
- emitCompletion(driverId, state, animationId, true);
1453
+ emitStandaloneCompletion(
1454
+ driverId, animationId, completionTag, true);
915
1455
  return animationId;
916
1456
  }
917
1457
 
918
1458
  ActiveAnimation active{animationId};
1459
+ active.completionTag = completionTag;
919
1460
  active.kind = AnimationKind::Spring;
920
1461
  active.start = resolvedStart;
921
1462
  active.spring = animation;
922
- active.startedAt = CACurrentMediaTime();
1463
+ active.spring.initialVelocity = velocity;
1464
+ active.spring.inheritVelocity = false;
923
1465
  active.started = anyDisplayableView(state);
924
1466
  state.animation = std::move(active);
925
1467
  if (!state.animation->started) {
926
- // Latched until the first displayable view — see animateTiming.
1468
+ // Pending until the first displayable view — see animateTiming.
927
1469
  return animationId;
928
1470
  }
929
1471
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
@@ -935,7 +1477,7 @@ int32_t animateSpring(
935
1477
  #endif
936
1478
  for (const ViewKey key : state.views) {
937
1479
  if ([viewForKey(key) smoothClipAnimateSpring:presentation
938
- animation:animation
1480
+ animation:state.animation->spring
939
1481
  animationId:animationId]) {
940
1482
  state.animation->participants.insert(key);
941
1483
  }
@@ -943,58 +1485,6 @@ int32_t animateSpring(
943
1485
  return animationId;
944
1486
  }
945
1487
 
946
- int32_t animateKeyframes(
947
- uint64_t driverId,
948
- AnimationStart start,
949
- Presentation presentation,
950
- double durationMs,
951
- std::vector<Keyframe> keyframes,
952
- int32_t reduceMotion) {
953
- if (!NSThread.isMainThread) {
954
- // See setPresentation: blocking off-main risks a deadlock.
955
- return 0;
956
- }
957
- auto iterator = registry().find(driverId);
958
- if (iterator == registry().end()) {
959
- if (!start.hasInteractiveStart) return 0;
960
- iterator = registry().try_emplace(driverId).first;
961
- iterator->second.latest = start.interactiveStart;
962
- iterator->second.hasLatest = true;
963
- }
964
- auto &state = iterator->second;
965
- state.destroyed = false;
966
- const int32_t animationId = allocateAnimationId();
967
- const Presentation resolvedStart = resolvedAnimationStart(state, start);
968
- prepareAnimation(driverId, state, start, presentation);
969
- if (shouldReduceMotion(reduceMotion) || durationMs <= 0) {
970
- applyPresentation(state, presentation, false);
971
- emitCompletion(driverId, state, animationId, true);
972
- return animationId;
973
- }
974
-
975
- ActiveAnimation active{animationId};
976
- active.kind = AnimationKind::Keyframes;
977
- active.start = resolvedStart;
978
- active.keyframes = keyframes;
979
- active.durationMs = durationMs;
980
- active.startedAt = CACurrentMediaTime();
981
- active.started = anyDisplayableView(state);
982
- state.animation = std::move(active);
983
- if (!state.animation->started) {
984
- // Latched until the first displayable view — see animateTiming.
985
- return animationId;
986
- }
987
- for (const ViewKey key : state.views) {
988
- if ([viewForKey(key) smoothClipAnimateKeyframes:presentation
989
- keyframes:keyframes
990
- durationMs:durationMs
991
- animationId:animationId]) {
992
- state.animation->participants.insert(key);
993
- }
994
- }
995
- return animationId;
996
- }
997
-
998
1488
  int32_t rejectAnimation(uint64_t driverId) {
999
1489
  if (!NSThread.isMainThread) {
1000
1490
  // See setPresentation: blocking off-main risks a deadlock.
@@ -1002,7 +1492,7 @@ int32_t rejectAnimation(uint64_t driverId) {
1002
1492
  } auto iterator = registry().find(driverId);
1003
1493
  if (iterator == registry().end()) return 0;
1004
1494
  const int32_t animationId = allocateAnimationId();
1005
- emitStandaloneCompletion(driverId, animationId, false);
1495
+ emitStandaloneCompletion(driverId, animationId, 0, false);
1006
1496
  return animationId;
1007
1497
  }
1008
1498
 
@@ -1022,6 +1512,20 @@ CancelResult cancelAnimation(
1022
1512
  (animationId > 0 && animationId != state.animation->animationId)) {
1023
1513
  return {false, state.latest};
1024
1514
  }
1515
+ if (state.animation->groupId != 0) {
1516
+ const int32_t groupId = state.animation->groupId;
1517
+ const std::vector<DriverSnapshot> snapshots = cancelGroupInternal(
1518
+ groupId,
1519
+ useTarget ? GroupCancelBehavior::Finish
1520
+ : GroupCancelBehavior::Freeze,
1521
+ useTarget);
1522
+ for (const DriverSnapshot &snapshot : snapshots) {
1523
+ if (snapshot.driverId == driverId) {
1524
+ return {true, snapshot.presentation};
1525
+ }
1526
+ }
1527
+ return {false, state.latest};
1528
+ }
1025
1529
 
1026
1530
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
1027
1531
  os_signpost_event_emit(
@@ -1031,7 +1535,7 @@ CancelResult cancelAnimation(
1031
1535
  #endif
1032
1536
  if (useTarget) {
1033
1537
  const Presentation target = state.latest;
1034
- // 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
1035
1539
  // _activeAnimationId == 0 and smoothClipCancelAnimationUsingTarget
1036
1540
  // early-returns without writing anything: state.latest would report the
1037
1541
  // target while every layer still showed the pre-animation geometry, and
@@ -1039,15 +1543,15 @@ CancelResult cancelAnimation(
1039
1543
  // unconditionally — match that. Read `started` before cancelActive, which
1040
1544
  // resets state.animation.
1041
1545
  //
1042
- // 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
1043
1547
  // (smoothClipCancelAnimationUsingTarget already applied the target there),
1044
1548
  // and applied WITHOUT a velocity sample: a jump to the target is not
1045
1549
  // interactive motion, and the cancel-to-target path deliberately keeps it
1046
1550
  // out of the 'inherit' history on both platforms — Android's cancel
1047
1551
  // fan-out records nothing either.
1048
- const bool wasLatched = !state.animation->started;
1552
+ const bool wasPending = !state.animation->started;
1049
1553
  cancelActive(driverId, state, true);
1050
- if (wasLatched) {
1554
+ if (wasPending) {
1051
1555
  // state.latest already holds the target (set at animate time); only the
1052
1556
  // view fan-out is missing.
1053
1557
  for (const ViewKey key : state.views) {
@@ -1101,11 +1605,8 @@ void viewAnimationDidStop(
1101
1605
  auto &state = iterator->second;
1102
1606
  if (!state.animation.has_value() ||
1103
1607
  state.animation->animationId != animationId) return;
1104
- // A delegate from the frozen run can still report a stop for this id after
1105
- // the animation has been re-latched. Draining participants there could
1106
- // complete an animation that is waiting to resume. Only a running animation
1107
- // may be completed by its installed delegates; a latch is completed by
1108
- // 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.
1109
1610
  if (!state.animation->started) return;
1110
1611
 
1111
1612
  const ViewKey key = keyForView(view);