react-native-smooth-clip-view 0.2.8 → 0.3.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 (132) hide show
  1. package/README.md +86 -21
  2. package/android/CMakeLists.txt +7 -0
  3. package/android/build.gradle +14 -2
  4. package/android/proguard-rules.pro +3 -3
  5. package/android/src/main/cpp/SmoothClipAndroid.h +10 -0
  6. package/android/src/main/cpp/SmoothClipBindings.cpp +896 -6
  7. package/android/src/main/cpp/SmoothClipRegistry.cpp +1067 -46
  8. package/android/src/main/cpp/SmoothClipTrace.h +25 -0
  9. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +265 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +21 -0
  11. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +283 -0
  12. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +385 -24
  13. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +138 -24
  14. package/cpp/SmoothClipAnimationCurve.h +151 -122
  15. package/cpp/SmoothClipRegistry.h +102 -4
  16. package/cpp/SmoothClipSharedGeometry.h +146 -7
  17. package/cpp/SmoothClipVelocityTracker.h +13 -9
  18. package/ios/SmoothClipGeometry.h +184 -6
  19. package/ios/SmoothClipRegistry.mm +1033 -25
  20. package/ios/SmoothClipRegistrySnapshot.h +13 -0
  21. package/ios/SmoothClipTurboModule.cpp +857 -2
  22. package/ios/SmoothClipTurboModule.h +310 -0
  23. package/ios/SmoothClipView.mm +714 -183
  24. package/ios/SmoothClipViewRegistry.h +1 -0
  25. package/ios/reactnativesmoothclipview.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  26. package/ios/reactnativesmoothclipview.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  27. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  28. package/lib/module/SmoothClipView.ios.js +22 -6
  29. package/lib/module/SmoothClipView.ios.js.map +1 -1
  30. package/lib/module/SmoothClipView.js +1 -46
  31. package/lib/module/SmoothClipView.js.map +1 -1
  32. package/lib/module/SmoothClipViewNativeComponent.ts +27 -1
  33. package/lib/module/capabilities.js +4 -0
  34. package/lib/module/capabilities.js.map +1 -0
  35. package/lib/module/capabilities.native.js +36 -0
  36. package/lib/module/capabilities.native.js.map +1 -0
  37. package/lib/module/capabilityTypes.js +11 -0
  38. package/lib/module/capabilityTypes.js.map +1 -0
  39. package/lib/module/driverState.js +0 -28
  40. package/lib/module/driverState.js.map +1 -1
  41. package/lib/module/drivers.js +1 -281
  42. package/lib/module/drivers.js.map +1 -1
  43. package/lib/module/drivers.native.js +340 -75
  44. package/lib/module/drivers.native.js.map +1 -1
  45. package/lib/module/geometry.js +147 -17
  46. package/lib/module/geometry.js.map +1 -1
  47. package/lib/module/groupDriverTypes.js +4 -0
  48. package/lib/module/groupDriverTypes.js.map +1 -0
  49. package/lib/module/groupDrivers.android.js +4 -0
  50. package/lib/module/groupDrivers.android.js.map +1 -0
  51. package/lib/module/groupDrivers.ios.js +4 -0
  52. package/lib/module/groupDrivers.ios.js.map +1 -0
  53. package/lib/module/groupDrivers.js +4 -0
  54. package/lib/module/groupDrivers.js.map +1 -0
  55. package/lib/module/groupDrivers.native.js +799 -0
  56. package/lib/module/groupDrivers.native.js.map +1 -0
  57. package/lib/module/index.js +3 -2
  58. package/lib/module/index.js.map +1 -1
  59. package/lib/module/presentationProtocol.js +15 -0
  60. package/lib/module/presentationProtocol.js.map +1 -0
  61. package/lib/typescript/src/NativeSmoothClipModule.d.ts +25 -0
  62. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  63. package/lib/typescript/src/SmoothClipView.d.ts +2 -10
  64. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  65. package/lib/typescript/src/SmoothClipView.ios.d.ts +133 -2
  66. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  67. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +8 -0
  68. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  69. package/lib/typescript/src/capabilities.d.ts +3 -0
  70. package/lib/typescript/src/capabilities.d.ts.map +1 -0
  71. package/lib/typescript/src/capabilities.native.d.ts +3 -0
  72. package/lib/typescript/src/capabilities.native.d.ts.map +1 -0
  73. package/lib/typescript/src/capabilityTypes.d.ts +10 -0
  74. package/lib/typescript/src/capabilityTypes.d.ts.map +1 -0
  75. package/lib/typescript/src/driverState.d.ts +1 -6
  76. package/lib/typescript/src/driverState.d.ts.map +1 -1
  77. package/lib/typescript/src/driverTypes.d.ts +39 -11
  78. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  79. package/lib/typescript/src/drivers.d.ts +2 -4
  80. package/lib/typescript/src/drivers.d.ts.map +1 -1
  81. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  82. package/lib/typescript/src/geometry.d.ts +36 -2
  83. package/lib/typescript/src/geometry.d.ts.map +1 -1
  84. package/lib/typescript/src/groupDriverTypes.d.ts +66 -0
  85. package/lib/typescript/src/groupDriverTypes.d.ts.map +1 -0
  86. package/lib/typescript/src/groupDrivers.android.d.ts +2 -0
  87. package/lib/typescript/src/groupDrivers.android.d.ts.map +1 -0
  88. package/lib/typescript/src/groupDrivers.d.ts +2 -0
  89. package/lib/typescript/src/groupDrivers.d.ts.map +1 -0
  90. package/lib/typescript/src/groupDrivers.ios.d.ts +2 -0
  91. package/lib/typescript/src/groupDrivers.ios.d.ts.map +1 -0
  92. package/lib/typescript/src/groupDrivers.native.d.ts +4 -0
  93. package/lib/typescript/src/groupDrivers.native.d.ts.map +1 -0
  94. package/lib/typescript/src/index.d.ts +6 -3
  95. package/lib/typescript/src/index.d.ts.map +1 -1
  96. package/lib/typescript/src/presentationProtocol.d.ts +4 -0
  97. package/lib/typescript/src/presentationProtocol.d.ts.map +1 -0
  98. package/package.json +10 -11
  99. package/src/NativeSmoothClipModule.ts +275 -0
  100. package/src/SmoothClipView.ios.tsx +30 -11
  101. package/src/SmoothClipView.tsx +2 -76
  102. package/src/SmoothClipViewNativeComponent.ts +27 -1
  103. package/src/capabilities.native.ts +69 -0
  104. package/src/capabilities.ts +2 -0
  105. package/src/capabilityTypes.ts +17 -0
  106. package/src/driverState.ts +1 -41
  107. package/src/driverTypes.ts +56 -11
  108. package/src/drivers.native.ts +921 -151
  109. package/src/drivers.ts +2 -423
  110. package/src/geometry.ts +231 -27
  111. package/src/groupDriverTypes.ts +117 -0
  112. package/src/groupDrivers.android.ts +1 -0
  113. package/src/groupDrivers.ios.ts +1 -0
  114. package/src/groupDrivers.native.ts +1189 -0
  115. package/src/groupDrivers.ts +1 -0
  116. package/src/index.ts +30 -2
  117. package/src/presentationProtocol.ts +28 -0
  118. package/lib/module/SmoothClipView.web.js +0 -54
  119. package/lib/module/SmoothClipView.web.js.map +0 -1
  120. package/lib/module/index.web.js +0 -8
  121. package/lib/module/index.web.js.map +0 -1
  122. package/lib/module/webClipPath.js +0 -25
  123. package/lib/module/webClipPath.js.map +0 -1
  124. package/lib/typescript/src/SmoothClipView.web.d.ts +0 -10
  125. package/lib/typescript/src/SmoothClipView.web.d.ts.map +0 -1
  126. package/lib/typescript/src/index.web.d.ts +0 -9
  127. package/lib/typescript/src/index.web.d.ts.map +0 -1
  128. package/lib/typescript/src/webClipPath.d.ts +0 -7
  129. package/lib/typescript/src/webClipPath.d.ts.map +0 -1
  130. package/src/SmoothClipView.web.tsx +0 -66
  131. package/src/index.web.ts +0 -23
  132. 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,13 +32,26 @@
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;
42
+ - (BOOL)smoothClipAnimateSpring:(smoothclip::Presentation)presentation
43
+ animation:(smoothclip::SpringAnimation)animation
44
+ animationId:(int32_t)animationId
45
+ sharedBeginTime:(CFTimeInterval)sharedBeginTime;
37
46
  - (BOOL)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
38
47
  keyframes:(const std::vector<smoothclip::Keyframe> &)keyframes
39
48
  durationMs:(double)durationMs
40
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;
41
55
  - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget;
42
56
  - (BOOL)smoothClipIsJoinable;
43
57
  - (BOOL)smoothClipCanDisplay;
@@ -56,6 +70,9 @@ enum class AnimationKind { Timing, Spring, Keyframes };
56
70
 
57
71
  struct ActiveAnimation {
58
72
  int32_t animationId;
73
+ // Zero identifies a legacy/single-driver animation. A non-zero value binds
74
+ // the driver to an immutable group whose completion is aggregated once.
75
+ int32_t groupId = 0;
59
76
  std::unordered_set<ViewKey> participants;
60
77
  // Hosts that installed this id and temporarily lost displayability. They
61
78
  // may rejoin the same remainder; completion is false if any remain
@@ -98,11 +115,31 @@ struct CompletionSink {
98
115
  CompletionCallback callback;
99
116
  };
100
117
 
118
+ struct GroupCompletionSink {
119
+ const void *owner = nullptr;
120
+ GroupCompletionCallback callback;
121
+ };
122
+
123
+ struct GroupState {
124
+ uint64_t controllerId;
125
+ int32_t groupId;
126
+ std::vector<uint64_t> driverIds;
127
+ std::unordered_set<uint64_t> remainingDriverIds;
128
+ GroupSuspensionPolicy suspensionPolicy = GroupSuspensionPolicy::Pause;
129
+ bool finished = true;
130
+ bool mutating = false;
131
+ };
132
+
101
133
  std::unordered_map<uint64_t, DriverState> &registry() {
102
134
  static std::unordered_map<uint64_t, DriverState> value;
103
135
  return value;
104
136
  }
105
137
 
138
+ std::unordered_map<int32_t, GroupState> &groupRegistry() {
139
+ static std::unordered_map<int32_t, GroupState> value;
140
+ return value;
141
+ }
142
+
106
143
  // The transitions must be observed for the whole process lifetime, not per
107
144
  // view: the state below is process-global, and a notification delivered while
108
145
  // no SmoothClipView happens to be alive (the last host unmounted during
@@ -143,6 +180,11 @@ CompletionSink &completionSink() {
143
180
  return sink;
144
181
  }
145
182
 
183
+ GroupCompletionSink &groupCompletionSink() {
184
+ static GroupCompletionSink sink;
185
+ return sink;
186
+ }
187
+
146
188
  // The sink is written from the TurboModule ctor/dtor on the JS thread and
147
189
  // invoked on main. Invoking under the lock also keeps the owning module
148
190
  // alive for the duration of the callback.
@@ -151,6 +193,18 @@ std::mutex &completionSinkMutex() {
151
193
  return value;
152
194
  }
153
195
 
196
+
197
+ void groupMemberCompleted(
198
+ int32_t groupId,
199
+ uint64_t driverId,
200
+ bool finished);
201
+ std::vector<DriverSnapshot> cancelGroupInternal(
202
+ int32_t groupId,
203
+ GroupCancelBehavior behavior,
204
+ bool completionFinished);
205
+ void tryStartGroup(int32_t groupId);
206
+ void suspendGroup(int32_t groupId);
207
+
154
208
  Presentation unavailablePresentation() {
155
209
  // Non-finite geometry makes the JS side fall back to its current value
156
210
  // instead of applying zeros.
@@ -183,7 +237,14 @@ void emitCompletion(
183
237
  DriverState &state,
184
238
  int32_t animationId,
185
239
  bool finished) {
240
+ const int32_t groupId = state.animation.has_value()
241
+ ? state.animation->groupId
242
+ : 0;
186
243
  state.animation.reset();
244
+ if (groupId != 0) {
245
+ groupMemberCompleted(groupId, driverId, finished);
246
+ return;
247
+ }
187
248
  {
188
249
  std::lock_guard<std::mutex> lock(completionSinkMutex());
189
250
  if (completionSink().callback) {
@@ -198,6 +259,37 @@ void emitCompletion(
198
259
  #endif
199
260
  }
200
261
 
262
+ void emitGroupCompletion(
263
+ uint64_t controllerId,
264
+ int32_t groupId,
265
+ bool finished,
266
+ const std::vector<uint64_t> &driverIds) {
267
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
268
+ if (groupCompletionSink().callback) {
269
+ groupCompletionSink().callback(
270
+ controllerId, groupId, finished, driverIds);
271
+ }
272
+ }
273
+
274
+ void groupMemberCompleted(
275
+ int32_t groupId,
276
+ uint64_t driverId,
277
+ bool finished) {
278
+ auto iterator = groupRegistry().find(groupId);
279
+ if (iterator == groupRegistry().end()) return;
280
+ GroupState &group = iterator->second;
281
+ if (group.remainingDriverIds.erase(driverId) == 0) return;
282
+ group.finished = group.finished && finished;
283
+ if (!group.remainingDriverIds.empty() || group.mutating) return;
284
+
285
+ const uint64_t controllerId = group.controllerId;
286
+ const bool aggregateFinished = group.finished;
287
+ const std::vector<uint64_t> driverIds = group.driverIds;
288
+ groupRegistry().erase(iterator);
289
+ emitGroupCompletion(
290
+ controllerId, groupId, aggregateFinished, driverIds);
291
+ }
292
+
201
293
  void emitStandaloneCompletion(
202
294
  uint64_t driverId,
203
295
  int32_t animationId,
@@ -258,11 +350,184 @@ Presentation canonicalFrozenPresentation(
258
350
  return canonical;
259
351
  }
260
352
 
353
+ Presentation canonicalVisiblePresentation(const DriverState &state) {
354
+ if (state.animation.has_value() && !state.animation->started) {
355
+ return state.animation->start;
356
+ }
357
+ for (const ViewKey key : state.views) {
358
+ SmoothClipView *candidate = viewForKey(key);
359
+ if ([candidate smoothClipIsJoinable]) {
360
+ return [candidate smoothClipCurrentPresentation];
361
+ }
362
+ }
363
+ return state.latest;
364
+ }
365
+
366
+ bool driverReady(const DriverState &state) {
367
+ if (state.destroyed) return false;
368
+ for (const ViewKey key : state.views) {
369
+ if ([viewForKey(key) smoothClipCanDisplay]) return true;
370
+ }
371
+ return false;
372
+ }
373
+
374
+ bool presentationFitsEveryKnownHostWithoutNormalization(
375
+ const DriverState &state,
376
+ const Presentation &presentation) {
377
+ for (const ViewKey key : state.views) {
378
+ 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;
430
+ }
431
+ }
432
+ return true;
433
+ }
434
+
435
+ DriverSnapshot snapshotForDriver(uint64_t driverId) {
436
+ const auto iterator = registry().find(driverId);
437
+ if (iterator == registry().end() || iterator->second.destroyed) {
438
+ return {driverId, unavailablePresentation(), false};
439
+ }
440
+ return {
441
+ driverId,
442
+ canonicalVisiblePresentation(iterator->second),
443
+ driverReady(iterator->second),
444
+ };
445
+ }
446
+
447
+ std::vector<DriverSnapshot> cancelGroupInternal(
448
+ int32_t groupId,
449
+ GroupCancelBehavior behavior,
450
+ bool completionFinished) {
451
+ auto groupIterator = groupRegistry().find(groupId);
452
+ if (groupIterator == groupRegistry().end()) return {};
453
+ GroupState &group = groupIterator->second;
454
+ if (group.mutating) return {};
455
+ group.mutating = true;
456
+
457
+ const std::vector<uint64_t> driverIds = group.driverIds;
458
+ std::vector<DriverSnapshot> snapshots;
459
+ snapshots.reserve(driverIds.size());
460
+ for (const uint64_t driverId : driverIds) {
461
+ auto stateIterator = registry().find(driverId);
462
+ if (stateIterator == registry().end()) {
463
+ snapshots.push_back({driverId, unavailablePresentation(), false});
464
+ groupMemberCompleted(groupId, driverId, false);
465
+ continue;
466
+ }
467
+ DriverState &state = stateIterator->second;
468
+ if (!state.animation.has_value() ||
469
+ state.animation->groupId != groupId) {
470
+ snapshots.push_back(snapshotForDriver(driverId));
471
+ groupMemberCompleted(groupId, driverId, false);
472
+ continue;
473
+ }
474
+
475
+ const int32_t animationId = state.animation->animationId;
476
+ Presentation resolved;
477
+ if (behavior == GroupCancelBehavior::Finish) {
478
+ resolved = state.latest;
479
+ const bool wasLatched = !state.animation->started;
480
+ for (const ViewKey key : state.views) {
481
+ [viewForKey(key) smoothClipCancelAnimationUsingTarget:YES];
482
+ }
483
+ if (wasLatched) {
484
+ for (const ViewKey key : state.views) {
485
+ [viewForKey(key) smoothClipApplyPresentation:resolved
486
+ recordVelocitySample:NO];
487
+ }
488
+ }
489
+ } else {
490
+ resolved = canonicalFrozenPresentation(state);
491
+ }
492
+ state.latest = resolved;
493
+ state.hasLatest = true;
494
+ state.ownership = Ownership::Interactive;
495
+ const bool ready = driverReady(state);
496
+ emitCompletion(
497
+ driverId, state, animationId, completionFinished);
498
+ snapshots.push_back({driverId, resolved, ready});
499
+ }
500
+
501
+ groupIterator = groupRegistry().find(groupId);
502
+ if (groupIterator != groupRegistry().end()) {
503
+ GroupState &remainingGroup = groupIterator->second;
504
+ remainingGroup.mutating = false;
505
+ if (remainingGroup.remainingDriverIds.empty()) {
506
+ const uint64_t controllerId = remainingGroup.controllerId;
507
+ const bool finished = remainingGroup.finished;
508
+ const std::vector<uint64_t> completedDrivers =
509
+ remainingGroup.driverIds;
510
+ groupRegistry().erase(groupIterator);
511
+ emitGroupCompletion(
512
+ controllerId, groupId, finished, completedDrivers);
513
+ }
514
+ }
515
+ return snapshots;
516
+ }
517
+
261
518
  void cancelActive(
262
519
  uint64_t driverId,
263
520
  DriverState &state,
264
521
  bool useTarget) {
265
522
  if (!state.animation.has_value()) return;
523
+ if (state.animation->groupId != 0) {
524
+ cancelGroupInternal(
525
+ state.animation->groupId,
526
+ useTarget ? GroupCancelBehavior::Finish
527
+ : GroupCancelBehavior::Freeze,
528
+ false);
529
+ return;
530
+ }
266
531
  const int32_t animationId = state.animation->animationId;
267
532
  // Deferred views are deliberately not completion participants, but they do
268
533
  // hold a pending install and requested target. Cancellation must still reach
@@ -310,8 +575,10 @@ void prepareAnimation(
310
575
  DriverState &state,
311
576
  AnimationStart start,
312
577
  Presentation target) {
313
- const bool acceptsInteractiveStart =
314
- start.hasInteractiveStart && state.ownership == Ownership::Interactive;
578
+ // An explicit `from` is part of the new animation's start transaction. It
579
+ // intentionally overrides a sampled native frame even when this operation
580
+ // is replacing another native-owned animation.
581
+ const bool acceptsInteractiveStart = start.hasInteractiveStart;
315
582
  cancelActive(driverId, state, false);
316
583
  if (acceptsInteractiveStart) {
317
584
  applyPresentation(state, start.interactiveStart);
@@ -326,7 +593,7 @@ void prepareAnimation(
326
593
  Presentation resolvedAnimationStart(
327
594
  const DriverState &state,
328
595
  const AnimationStart &start) {
329
- if (start.hasInteractiveStart && state.ownership == Ownership::Interactive) {
596
+ if (start.hasInteractiveStart) {
330
597
  return start.interactiveStart;
331
598
  }
332
599
  // Replacing a latched animation (e.g. open→close before the host mounted):
@@ -335,7 +602,9 @@ Presentation resolvedAnimationStart(
335
602
  if (state.animation.has_value() && !state.animation->started) {
336
603
  return state.animation->start;
337
604
  }
338
- return state.latest;
605
+ return state.animation.has_value()
606
+ ? canonicalVisiblePresentation(state)
607
+ : state.latest;
339
608
  }
340
609
 
341
610
  // Installs the remaining part of the active animation on a view that joins
@@ -346,9 +615,12 @@ void dispatchActiveAnimationJoin(
346
615
  SmoothClipView *view,
347
616
  ViewKey key,
348
617
  const Presentation &visible,
349
- SmoothClipView *reference = nil) {
618
+ SmoothClipView *reference = nil,
619
+ CFTimeInterval sharedBeginTime = 0) {
350
620
  auto &active = *state.animation;
351
- const double elapsedMs = (CACurrentMediaTime() - active.startedAt) * 1000.0;
621
+ const double elapsedMs = sharedBeginTime > 0
622
+ ? 0
623
+ : (CACurrentMediaTime() - active.startedAt) * 1000.0;
352
624
  const double remainingMs = MAX(0, active.durationMs - elapsedMs);
353
625
  BOOL installed = NO;
354
626
  if (active.kind == AnimationKind::Timing) {
@@ -358,7 +630,8 @@ void dispatchActiveAnimationJoin(
358
630
  TimingAnimation timing = timingRemainder(active.timing, progress).animation;
359
631
  installed = [view smoothClipAnimateTiming:state.latest
360
632
  animation:timing
361
- animationId:active.animationId];
633
+ animationId:active.animationId
634
+ sharedBeginTime:sharedBeginTime];
362
635
  } else if (active.kind == AnimationKind::Spring) {
363
636
  SpringAnimation spring = active.spring;
364
637
  if (reference != nil) {
@@ -371,7 +644,8 @@ void dispatchActiveAnimationJoin(
371
644
  }
372
645
  installed = [view smoothClipAnimateSpring:state.latest
373
646
  animation:spring
374
- animationId:active.animationId];
647
+ animationId:active.animationId
648
+ sharedBeginTime:sharedBeginTime];
375
649
  } else {
376
650
  const double progress = active.durationMs <= 0
377
651
  ? 1
@@ -387,7 +661,8 @@ void dispatchActiveAnimationJoin(
387
661
  installed = [view smoothClipAnimateKeyframes:state.latest
388
662
  keyframes:remaining
389
663
  durationMs:remainingMs
390
- animationId:active.animationId];
664
+ animationId:active.animationId
665
+ sharedBeginTime:sharedBeginTime];
391
666
  }
392
667
  if (installed) {
393
668
  active.participants.insert(key);
@@ -485,6 +760,10 @@ void relatchActiveAnimation(
485
760
  // view. Views that still cannot display defer their install per-view and
486
761
  // resume via a later positive displayability update.
487
762
  void startLatchedAnimation(DriverState &state) {
763
+ if (state.animation->groupId != 0) {
764
+ tryStartGroup(state.animation->groupId);
765
+ return;
766
+ }
488
767
  state.animation->startedAt = CACurrentMediaTime();
489
768
  state.animation->started = true;
490
769
  // Participants describe live CA delegates, not registered views. Anything
@@ -498,6 +777,86 @@ void startLatchedAnimation(DriverState &state) {
498
777
  }
499
778
  }
500
779
 
780
+ void tryStartGroup(int32_t groupId) {
781
+ auto groupIterator = groupRegistry().find(groupId);
782
+ if (groupIterator == groupRegistry().end() ||
783
+ groupIterator->second.mutating) {
784
+ return;
785
+ }
786
+ const std::vector<uint64_t> driverIds = groupIterator->second.driverIds;
787
+ for (const uint64_t driverId : driverIds) {
788
+ const auto stateIterator = registry().find(driverId);
789
+ if (stateIterator == registry().end() ||
790
+ !stateIterator->second.animation.has_value() ||
791
+ stateIterator->second.animation->groupId != groupId ||
792
+ !driverReady(stateIterator->second)) {
793
+ return;
794
+ }
795
+ }
796
+
797
+ const CFTimeInterval sharedBeginTime = CACurrentMediaTime();
798
+ [CATransaction begin];
799
+ [CATransaction setDisableActions:YES];
800
+ for (const uint64_t driverId : driverIds) {
801
+ DriverState &state = registry().find(driverId)->second;
802
+ ActiveAnimation &active = *state.animation;
803
+ active.startedAt = sharedBeginTime;
804
+ active.started = true;
805
+ active.participants.clear();
806
+ const Presentation start = active.start;
807
+ for (const ViewKey key : state.views) {
808
+ SmoothClipView *participant = viewForKey(key);
809
+ [participant smoothClipApplyPresentation:start recordVelocitySample:NO];
810
+ dispatchActiveAnimationJoin(
811
+ state,
812
+ participant,
813
+ key,
814
+ start,
815
+ nil,
816
+ sharedBeginTime);
817
+ }
818
+ }
819
+ [CATransaction commit];
820
+ }
821
+
822
+ void suspendGroup(int32_t groupId) {
823
+ auto groupIterator = groupRegistry().find(groupId);
824
+ if (groupIterator == groupRegistry().end() ||
825
+ groupIterator->second.mutating) {
826
+ return;
827
+ }
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);
858
+ }
859
+
501
860
  } // namespace
502
861
 
503
862
  bool applicationIsActive() {
@@ -508,8 +867,19 @@ void applicationWillResignActive() {
508
867
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
509
868
  if (!applicationActiveState()) return;
510
869
  applicationActiveState() = false;
870
+ std::vector<int32_t> groupIds;
871
+ groupIds.reserve(groupRegistry().size());
872
+ for (const auto &[groupId, group] : groupRegistry()) {
873
+ (void)group;
874
+ groupIds.push_back(groupId);
875
+ }
876
+ for (const int32_t groupId : groupIds) {
877
+ suspendGroup(groupId);
878
+ }
511
879
  for (auto &[driverId, state] : registry()) {
512
- relatchActiveAnimation(driverId, state);
880
+ if (!state.animation.has_value() || state.animation->groupId == 0) {
881
+ relatchActiveAnimation(driverId, state);
882
+ }
513
883
  }
514
884
  }
515
885
 
@@ -517,10 +887,19 @@ void applicationDidBecomeActive() {
517
887
  NSCAssert(NSThread.isMainThread, @"SmoothClip registry is main-thread only");
518
888
  if (applicationActiveState()) return;
519
889
  applicationActiveState() = true;
890
+ std::vector<int32_t> groupIds;
891
+ groupIds.reserve(groupRegistry().size());
892
+ for (const auto &[groupId, group] : groupRegistry()) {
893
+ (void)group;
894
+ groupIds.push_back(groupId);
895
+ }
896
+ for (const int32_t groupId : groupIds) {
897
+ tryStartGroup(groupId);
898
+ }
520
899
  for (auto &[driverId, state] : registry()) {
521
900
  (void)driverId;
522
901
  if (state.animation.has_value() && !state.animation->started &&
523
- anyDisplayableView(state)) {
902
+ state.animation->groupId == 0 && anyDisplayableView(state)) {
524
903
  startLatchedAnimation(state);
525
904
  }
526
905
  }
@@ -541,6 +920,21 @@ void clearCompletionCallback(const void *owner) {
541
920
  completionSink().callback = nullptr;
542
921
  }
543
922
 
923
+ void setGroupCompletionCallback(
924
+ const void *owner,
925
+ GroupCompletionCallback callback) {
926
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
927
+ groupCompletionSink().owner = owner;
928
+ groupCompletionSink().callback = std::move(callback);
929
+ }
930
+
931
+ void clearGroupCompletionCallback(const void *owner) {
932
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
933
+ if (groupCompletionSink().owner != owner) return;
934
+ groupCompletionSink().owner = nullptr;
935
+ groupCompletionSink().callback = nullptr;
936
+ }
937
+
544
938
  void registerView(
545
939
  uint64_t driverId,
546
940
  SmoothClipView *view,
@@ -614,6 +1008,38 @@ void viewDisplayabilityChanged(uint64_t driverId, SmoothClipView *view) {
614
1008
  state.views.end()) {
615
1009
  return;
616
1010
  }
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
+ }
617
1043
  if (![view smoothClipCanDisplay]) {
618
1044
  if (!state.animation->started) return;
619
1045
  if (state.animation->participants.count(key) == 0) return;
@@ -663,6 +1089,11 @@ bool joinActiveAnimation(uint64_t driverId, SmoothClipView *view) {
663
1089
  // instead of falling through.
664
1090
  if (!state.animation->started) {
665
1091
  if (![view smoothClipCanDisplay]) return false;
1092
+ 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
+ }
666
1097
  startLatchedAnimation(state);
667
1098
  return true;
668
1099
  }
@@ -692,6 +1123,46 @@ void unregisterView(uint64_t driverId, SmoothClipView *view) {
692
1123
 
693
1124
  auto &state = iterator->second;
694
1125
  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
+ }
695
1166
  bool installed = false;
696
1167
  if (state.animation.has_value()) {
697
1168
  installed = state.animation->participants.count(key) > 0 ||
@@ -731,7 +1202,8 @@ void setPresentation(
731
1202
  uint64_t driverId,
732
1203
  Presentation presentation,
733
1204
  bool takeOwnership,
734
- bool overridePendingAnimation) {
1205
+ bool overridePendingAnimation,
1206
+ bool recordVelocity) {
735
1207
  // CALayer writes require the main thread (not main-queue drain context).
736
1208
  if (!NSThread.isMainThread) {
737
1209
  // Never block an off-main caller: a synchronous hop can deadlock against
@@ -740,7 +1212,8 @@ void setPresentation(
740
1212
  // same mutex when draining scheduled worklets).
741
1213
  RCTExecuteOnMainQueue(^{
742
1214
  setPresentation(
743
- driverId, presentation, takeOwnership, overridePendingAnimation);
1215
+ driverId, presentation, takeOwnership, overridePendingAnimation,
1216
+ recordVelocity);
744
1217
  });
745
1218
  return;
746
1219
  }
@@ -789,7 +1262,7 @@ void setPresentation(
789
1262
  }
790
1263
  cancelActive(driverId, state, false);
791
1264
  }
792
- applyPresentation(state, presentation);
1265
+ applyPresentation(state, presentation, recordVelocity);
793
1266
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
794
1267
  if (signpostsEnabled) {
795
1268
  os_signpost_interval_end(
@@ -813,6 +1286,16 @@ Presentation beginInteraction(uint64_t driverId) {
813
1286
  return state.latest;
814
1287
  }
815
1288
 
1289
+ if (state.animation->groupId != 0) {
1290
+ const int32_t groupId = state.animation->groupId;
1291
+ cancelGroupInternal(
1292
+ groupId, GroupCancelBehavior::Freeze, false);
1293
+ const auto current = registry().find(driverId);
1294
+ return current == registry().end()
1295
+ ? unavailablePresentation()
1296
+ : current->second.latest;
1297
+ }
1298
+
816
1299
  const int32_t animationId = state.animation->animationId;
817
1300
  const Presentation canonical = canonicalFrozenPresentation(state);
818
1301
  state.latest = canonical;
@@ -822,19 +1305,443 @@ Presentation beginInteraction(uint64_t driverId) {
822
1305
  return canonical;
823
1306
  }
824
1307
 
1308
+ Presentation snapshotCurrent(uint64_t driverId) {
1309
+ if (!NSThread.isMainThread) return unavailablePresentation();
1310
+ const auto iterator = registry().find(driverId);
1311
+ if (iterator == registry().end() || iterator->second.destroyed) {
1312
+ return unavailablePresentation();
1313
+ }
1314
+ return canonicalVisiblePresentation(iterator->second);
1315
+ }
1316
+
1317
+ std::vector<DriverSnapshot> snapshotGroup(
1318
+ const std::vector<uint64_t> &driverIds) {
1319
+ if (!NSThread.isMainThread) return {};
1320
+ std::vector<DriverSnapshot> snapshots;
1321
+ snapshots.reserve(driverIds.size());
1322
+ for (const uint64_t driverId : driverIds) {
1323
+ snapshots.push_back(snapshotForDriver(driverId));
1324
+ }
1325
+ return snapshots;
1326
+ }
1327
+
1328
+ std::vector<DriverSnapshot> beginGroupInteraction(
1329
+ const std::vector<uint64_t> &driverIds) {
1330
+ if (!NSThread.isMainThread) return {};
1331
+ std::unordered_set<uint64_t> unique;
1332
+ for (const uint64_t driverId : driverIds) {
1333
+ const auto iterator = registry().find(driverId);
1334
+ if (driverId == 0 || !unique.insert(driverId).second ||
1335
+ iterator == registry().end() || iterator->second.destroyed) {
1336
+ std::vector<DriverSnapshot> unavailable;
1337
+ unavailable.reserve(driverIds.size());
1338
+ for (const uint64_t requested : driverIds) {
1339
+ unavailable.push_back(
1340
+ {requested, unavailablePresentation(), false});
1341
+ }
1342
+ return unavailable;
1343
+ }
1344
+ }
1345
+
1346
+ std::unordered_set<int32_t> cancelledGroups;
1347
+ for (const uint64_t driverId : driverIds) {
1348
+ DriverState &state = registry().find(driverId)->second;
1349
+ if (!state.animation.has_value()) continue;
1350
+ const int32_t groupId = state.animation->groupId;
1351
+ if (groupId != 0) {
1352
+ if (cancelledGroups.insert(groupId).second) {
1353
+ cancelGroupInternal(
1354
+ groupId, GroupCancelBehavior::Freeze, false);
1355
+ }
1356
+ continue;
1357
+ }
1358
+ const int32_t animationId = state.animation->animationId;
1359
+ const Presentation frozen = canonicalFrozenPresentation(state);
1360
+ state.latest = frozen;
1361
+ state.hasLatest = true;
1362
+ state.ownership = Ownership::Interactive;
1363
+ emitCompletion(driverId, state, animationId, false);
1364
+ }
1365
+
1366
+ std::vector<DriverSnapshot> snapshots;
1367
+ snapshots.reserve(driverIds.size());
1368
+ for (const uint64_t driverId : driverIds) {
1369
+ DriverState &state = registry().find(driverId)->second;
1370
+ state.destroyed = false;
1371
+ state.ownership = Ownership::Interactive;
1372
+ snapshots.push_back(
1373
+ {driverId, state.latest, driverReady(state)});
1374
+ }
1375
+ return snapshots;
1376
+ }
1377
+
1378
+ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1379
+ if (!NSThread.isMainThread) return false;
1380
+ std::unordered_set<uint64_t> unique;
1381
+ for (const BatchEntry &entry : entries) {
1382
+ const auto iterator = registry().find(entry.driverId);
1383
+ if (entry.driverId == 0 || !unique.insert(entry.driverId).second ||
1384
+ iterator == registry().end() || iterator->second.destroyed ||
1385
+ !isFiniteProtocolV2Presentation(entry.presentation)) {
1386
+ return false;
1387
+ }
1388
+ }
1389
+
1390
+ std::unordered_set<int32_t> cancelledGroups;
1391
+ for (const BatchEntry &entry : entries) {
1392
+ DriverState &state = registry().find(entry.driverId)->second;
1393
+ if (!state.animation.has_value()) continue;
1394
+ const int32_t groupId = state.animation->groupId;
1395
+ if (groupId != 0) {
1396
+ if (cancelledGroups.insert(groupId).second) {
1397
+ cancelGroupInternal(
1398
+ groupId, GroupCancelBehavior::Freeze, false);
1399
+ }
1400
+ continue;
1401
+ }
1402
+ const int32_t animationId = state.animation->animationId;
1403
+ const Presentation frozen = canonicalFrozenPresentation(state);
1404
+ state.latest = frozen;
1405
+ state.hasLatest = true;
1406
+ state.ownership = Ownership::Interactive;
1407
+ emitCompletion(entry.driverId, state, animationId, false);
1408
+ }
1409
+
1410
+ [CATransaction begin];
1411
+ [CATransaction setDisableActions:YES];
1412
+ for (const BatchEntry &entry : entries) {
1413
+ applyPresentation(
1414
+ registry().find(entry.driverId)->second, entry.presentation, true);
1415
+ }
1416
+ [CATransaction commit];
1417
+ return true;
1418
+ }
1419
+
1420
+ namespace {
1421
+
1422
+ bool preflightGroupEntries(
1423
+ const std::vector<GroupMotionEntry> &entries,
1424
+ AnimationKind kind,
1425
+ const TimingAnimation &timing,
1426
+ const SpringAnimation &spring,
1427
+ double durationMs,
1428
+ int32_t reduceMotion,
1429
+ bool &reduced,
1430
+ std::vector<Presentation> &resolvedStarts) {
1431
+ if (entries.empty()) return false;
1432
+ if (kind == AnimationKind::Timing &&
1433
+ !isValidProtocolV2Timing(timing)) {
1434
+ return false;
1435
+ }
1436
+ 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))) {
1443
+ return false;
1444
+ }
1445
+ reduced = shouldReduceMotion(reduceMotion) ||
1446
+ (kind != AnimationKind::Spring && durationMs <= 0);
1447
+ std::unordered_set<uint64_t> unique;
1448
+ resolvedStarts.clear();
1449
+ resolvedStarts.reserve(entries.size());
1450
+ for (const GroupMotionEntry &entry : entries) {
1451
+ if (entry.driverId == 0 || !unique.insert(entry.driverId).second) {
1452
+ return false;
1453
+ }
1454
+ const auto iterator = registry().find(entry.driverId);
1455
+ if ((iterator == registry().end() || iterator->second.destroyed ||
1456
+ !iterator->second.hasLatest) && !entry.hasFrom) {
1457
+ return false;
1458
+ }
1459
+ const Presentation resolvedStart = entry.hasFrom
1460
+ ? entry.from
1461
+ : 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)) {
1470
+ return false;
1471
+ }
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)) {
1492
+ return false;
1493
+ }
1494
+ }
1495
+ resolvedStarts.push_back(resolvedStart);
1496
+ }
1497
+ return true;
1498
+ }
1499
+
1500
+ void freezeReplacedAnimations(
1501
+ const std::vector<GroupMotionEntry> &entries) {
1502
+ std::unordered_set<int32_t> cancelledGroups;
1503
+ for (const GroupMotionEntry &entry : entries) {
1504
+ auto iterator = registry().find(entry.driverId);
1505
+ if (iterator == registry().end() ||
1506
+ !iterator->second.animation.has_value()) {
1507
+ continue;
1508
+ }
1509
+ DriverState &state = iterator->second;
1510
+ const int32_t oldGroupId = state.animation->groupId;
1511
+ if (oldGroupId != 0) {
1512
+ if (cancelledGroups.insert(oldGroupId).second) {
1513
+ cancelGroupInternal(
1514
+ oldGroupId, GroupCancelBehavior::Freeze, false);
1515
+ }
1516
+ continue;
1517
+ }
1518
+ const int32_t animationId = state.animation->animationId;
1519
+ const Presentation frozen = canonicalFrozenPresentation(state);
1520
+ state.latest = frozen;
1521
+ state.hasLatest = true;
1522
+ state.ownership = Ownership::Interactive;
1523
+ emitCompletion(entry.driverId, state, animationId, false);
1524
+ }
1525
+ }
1526
+
1527
+ int32_t createGroupAnimation(
1528
+ uint64_t controllerId,
1529
+ std::vector<GroupMotionEntry> entries,
1530
+ AnimationKind kind,
1531
+ TimingAnimation timing,
1532
+ SpringAnimation spring,
1533
+ double durationMs,
1534
+ int32_t reduceMotion,
1535
+ GroupSuspensionPolicy suspensionPolicy) {
1536
+ std::vector<Presentation> resolvedStarts;
1537
+ bool reduced = false;
1538
+ if (!NSThread.isMainThread || controllerId == 0 ||
1539
+ !preflightGroupEntries(
1540
+ entries,
1541
+ kind,
1542
+ timing,
1543
+ spring,
1544
+ durationMs,
1545
+ reduceMotion,
1546
+ reduced,
1547
+ resolvedStarts)) {
1548
+ return 0;
1549
+ }
1550
+
1551
+ // No state changes occur before every entry is known to be viable. Once
1552
+ // preflight succeeds, replacement freezes each overlapping group at most
1553
+ // once and reports its stale completion before installing the new epoch.
1554
+ freezeReplacedAnimations(entries);
1555
+ const int32_t groupId = allocateAnimationId();
1556
+ std::vector<uint64_t> driverIds;
1557
+ driverIds.reserve(entries.size());
1558
+ for (const GroupMotionEntry &entry : entries) {
1559
+ driverIds.push_back(entry.driverId);
1560
+ }
1561
+
1562
+ if (reduced) {
1563
+ [CATransaction begin];
1564
+ [CATransaction setDisableActions:YES];
1565
+ for (const GroupMotionEntry &entry : entries) {
1566
+ auto iterator = registry().find(entry.driverId);
1567
+ if (iterator == registry().end()) {
1568
+ iterator = registry().try_emplace(entry.driverId).first;
1569
+ }
1570
+ DriverState &state = iterator->second;
1571
+ state.destroyed = false;
1572
+ applyPresentation(state, entry.target, false);
1573
+ }
1574
+ [CATransaction commit];
1575
+ emitGroupCompletion(controllerId, groupId, true, driverIds);
1576
+ return groupId;
1577
+ }
1578
+
1579
+ GroupState group{
1580
+ controllerId,
1581
+ groupId,
1582
+ driverIds,
1583
+ std::unordered_set<uint64_t>(driverIds.begin(), driverIds.end()),
1584
+ suspensionPolicy,
1585
+ };
1586
+ groupRegistry().emplace(groupId, std::move(group));
1587
+
1588
+ for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1589
+ GroupMotionEntry &entry = entries[entryIndex];
1590
+ auto iterator = registry().find(entry.driverId);
1591
+ if (iterator == registry().end()) {
1592
+ iterator = registry().try_emplace(entry.driverId).first;
1593
+ }
1594
+ DriverState &state = iterator->second;
1595
+ state.destroyed = false;
1596
+ if (entry.hasFrom) {
1597
+ applyPresentation(state, entry.from, true);
1598
+ }
1599
+ 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
+ state.latest = entry.target;
1608
+ state.hasLatest = true;
1609
+ state.ownership = Ownership::Native;
1610
+
1611
+ ActiveAnimation active{groupId};
1612
+ active.groupId = groupId;
1613
+ active.kind = kind;
1614
+ active.start = start;
1615
+ active.timing = timing;
1616
+ active.spring = spring;
1617
+ active.keyframes = std::move(entry.keyframes);
1618
+ active.durationMs = durationMs;
1619
+ active.started = false;
1620
+ state.animation = std::move(active);
1621
+ }
1622
+ tryStartGroup(groupId);
1623
+ return groupId;
1624
+ }
1625
+
1626
+ } // namespace
1627
+
1628
+ int32_t animateTimingGroup(
1629
+ uint64_t controllerId,
1630
+ std::vector<GroupMotionEntry> entries,
1631
+ TimingAnimation animation,
1632
+ GroupSuspensionPolicy suspensionPolicy,
1633
+ double startedAtHintS) {
1634
+ (void)startedAtHintS;
1635
+ return createGroupAnimation(
1636
+ controllerId,
1637
+ std::move(entries),
1638
+ AnimationKind::Timing,
1639
+ animation,
1640
+ {},
1641
+ animation.durationMs,
1642
+ animation.reduceMotion,
1643
+ suspensionPolicy);
1644
+ }
1645
+
1646
+ int32_t animateSpringGroup(
1647
+ uint64_t controllerId,
1648
+ std::vector<GroupMotionEntry> entries,
1649
+ SpringAnimation animation,
1650
+ GroupSuspensionPolicy suspensionPolicy,
1651
+ double startedAtHintS) {
1652
+ (void)startedAtHintS;
1653
+ return createGroupAnimation(
1654
+ controllerId,
1655
+ std::move(entries),
1656
+ AnimationKind::Spring,
1657
+ {},
1658
+ animation,
1659
+ 0,
1660
+ 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);
1681
+ }
1682
+
1683
+ std::vector<DriverSnapshot> cancelAnimationGroup(
1684
+ int32_t groupId,
1685
+ GroupCancelBehavior behavior) {
1686
+ if (!NSThread.isMainThread || groupId <= 0) return {};
1687
+ return cancelGroupInternal(
1688
+ groupId,
1689
+ behavior,
1690
+ behavior == GroupCancelBehavior::Finish);
1691
+ }
1692
+
825
1693
  int32_t animateTiming(
826
1694
  uint64_t driverId,
827
1695
  AnimationStart start,
828
1696
  Presentation presentation,
829
- TimingAnimation animation) {
1697
+ TimingAnimation animation,
1698
+ AnimationValidationMode validationMode) {
830
1699
  if (!NSThread.isMainThread) {
831
1700
  // See setPresentation: blocking off-main risks a deadlock. 0 is the
832
1701
  // documented rejection sentinel.
833
1702
  return 0;
834
1703
  }
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) ||
1718
+ !validAnimation) {
1719
+ return 0;
1720
+ }
835
1721
  auto iterator = registry().find(driverId);
1722
+ Presentation preflightStart;
1723
+ if (iterator == registry().end()) {
1724
+ if (!start.hasInteractiveStart ||
1725
+ !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1726
+ return 0;
1727
+ }
1728
+ preflightStart = start.interactiveStart;
1729
+ } else {
1730
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1731
+ preflightStart = resolvedAnimationStart(iterator->second, start);
1732
+ }
1733
+ if (!isFiniteProtocolV2Presentation(preflightStart) ||
1734
+ preflightStart.clip.curve != presentation.clip.curve) {
1735
+ return 0;
1736
+ }
1737
+ const bool reduced = shouldReduceMotion(animation.reduceMotion) ||
1738
+ animation.durationMs <= 0;
1739
+ if (strictV2 && !reduced && iterator != registry().end() &&
1740
+ !timingPlanPreservesLinearHostNormalization(
1741
+ iterator->second, preflightStart, presentation, animation)) {
1742
+ return 0;
1743
+ }
836
1744
  if (iterator == registry().end()) {
837
- if (!start.hasInteractiveStart) return 0;
838
1745
  iterator = registry().try_emplace(driverId).first;
839
1746
  iterator->second.latest = start.interactiveStart;
840
1747
  iterator->second.hasLatest = true;
@@ -842,10 +1749,10 @@ int32_t animateTiming(
842
1749
  auto &state = iterator->second;
843
1750
  state.destroyed = false;
844
1751
  const int32_t animationId = allocateAnimationId();
845
- const Presentation resolvedStart = resolvedAnimationStart(state, start);
1752
+ const Presentation resolvedStart = preflightStart;
846
1753
  prepareAnimation(driverId, state, start, presentation);
847
1754
 
848
- if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
1755
+ if (reduced) {
849
1756
  applyPresentation(state, presentation, false);
850
1757
  emitCompletion(driverId, state, animationId, true);
851
1758
  return animationId;
@@ -889,14 +1796,49 @@ int32_t animateSpring(
889
1796
  uint64_t driverId,
890
1797
  AnimationStart start,
891
1798
  Presentation presentation,
892
- SpringAnimation animation) {
1799
+ SpringAnimation animation,
1800
+ AnimationValidationMode validationMode) {
893
1801
  if (!NSThread.isMainThread) {
894
1802
  // See setPresentation: blocking off-main risks a deadlock.
895
1803
  return 0;
896
1804
  }
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) ||
1820
+ !validAnimation) {
1821
+ return 0;
1822
+ }
897
1823
  auto iterator = registry().find(driverId);
1824
+ Presentation preflightStart;
1825
+ if (iterator == registry().end()) {
1826
+ if (!start.hasInteractiveStart ||
1827
+ !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1828
+ return 0;
1829
+ }
1830
+ preflightStart = start.interactiveStart;
1831
+ } else {
1832
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1833
+ preflightStart = resolvedAnimationStart(iterator->second, start);
1834
+ }
1835
+ if (!isFiniteProtocolV2Presentation(preflightStart) ||
1836
+ preflightStart.clip.curve != presentation.clip.curve ||
1837
+ (strictV2 && !protocolV2SpringScaleIsProvablyPositive(
1838
+ preflightStart, presentation, animation))) {
1839
+ return 0;
1840
+ }
898
1841
  if (iterator == registry().end()) {
899
- if (!start.hasInteractiveStart) return 0;
900
1842
  iterator = registry().try_emplace(driverId).first;
901
1843
  iterator->second.latest = start.interactiveStart;
902
1844
  iterator->second.hasLatest = true;
@@ -904,7 +1846,7 @@ int32_t animateSpring(
904
1846
  auto &state = iterator->second;
905
1847
  state.destroyed = false;
906
1848
  const int32_t animationId = allocateAnimationId();
907
- const Presentation resolvedStart = resolvedAnimationStart(state, start);
1849
+ const Presentation resolvedStart = preflightStart;
908
1850
  prepareAnimation(driverId, state, start, presentation);
909
1851
 
910
1852
  if (shouldReduceMotion(animation.reduceMotion)) {
@@ -947,14 +1889,57 @@ int32_t animateKeyframes(
947
1889
  Presentation presentation,
948
1890
  double durationMs,
949
1891
  std::vector<Keyframe> keyframes,
950
- int32_t reduceMotion) {
1892
+ int32_t reduceMotion,
1893
+ AnimationValidationMode validationMode) {
951
1894
  if (!NSThread.isMainThread) {
952
1895
  // See setPresentation: blocking off-main risks a deadlock.
953
1896
  return 0;
954
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
+ }
955
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
+ }
956
1942
  if (iterator == registry().end()) {
957
- if (!start.hasInteractiveStart) return 0;
958
1943
  iterator = registry().try_emplace(driverId).first;
959
1944
  iterator->second.latest = start.interactiveStart;
960
1945
  iterator->second.hasLatest = true;
@@ -962,14 +1947,23 @@ int32_t animateKeyframes(
962
1947
  auto &state = iterator->second;
963
1948
  state.destroyed = false;
964
1949
  const int32_t animationId = allocateAnimationId();
965
- const Presentation resolvedStart = resolvedAnimationStart(state, start);
1950
+ const Presentation resolvedStart = preflightStart;
966
1951
  prepareAnimation(driverId, state, start, presentation);
967
- if (shouldReduceMotion(reduceMotion) || durationMs <= 0) {
1952
+ if (reduced) {
968
1953
  applyPresentation(state, presentation, false);
969
1954
  emitCompletion(driverId, state, animationId, true);
970
1955
  return animationId;
971
1956
  }
972
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
+
973
1967
  ActiveAnimation active{animationId};
974
1968
  active.kind = AnimationKind::Keyframes;
975
1969
  active.start = resolvedStart;
@@ -1020,6 +2014,20 @@ CancelResult cancelAnimation(
1020
2014
  (animationId > 0 && animationId != state.animation->animationId)) {
1021
2015
  return {false, state.latest};
1022
2016
  }
2017
+ if (state.animation->groupId != 0) {
2018
+ const int32_t groupId = state.animation->groupId;
2019
+ const std::vector<DriverSnapshot> snapshots = cancelGroupInternal(
2020
+ groupId,
2021
+ useTarget ? GroupCancelBehavior::Finish
2022
+ : GroupCancelBehavior::Freeze,
2023
+ useTarget);
2024
+ for (const DriverSnapshot &snapshot : snapshots) {
2025
+ if (snapshot.driverId == driverId) {
2026
+ return {true, snapshot.presentation};
2027
+ }
2028
+ }
2029
+ return {false, state.latest};
2030
+ }
1023
2031
 
1024
2032
  #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
1025
2033
  os_signpost_event_emit(