react-native-smooth-clip-view 0.2.9 → 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 (130) hide show
  1. package/README.md +60 -21
  2. package/android/build.gradle +6 -2
  3. package/android/proguard-rules.pro +3 -3
  4. package/android/src/main/cpp/SmoothClipAndroid.h +10 -0
  5. package/android/src/main/cpp/SmoothClipBindings.cpp +890 -4
  6. package/android/src/main/cpp/SmoothClipRegistry.cpp +1056 -44
  7. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +265 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +21 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +283 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +371 -18
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +138 -24
  12. package/cpp/SmoothClipAnimationCurve.h +151 -122
  13. package/cpp/SmoothClipRegistry.h +95 -3
  14. package/cpp/SmoothClipSharedGeometry.h +146 -7
  15. package/cpp/SmoothClipVelocityTracker.h +13 -9
  16. package/ios/SmoothClipGeometry.h +184 -6
  17. package/ios/SmoothClipRegistry.mm +1028 -22
  18. package/ios/SmoothClipRegistrySnapshot.h +13 -0
  19. package/ios/SmoothClipTurboModule.cpp +857 -2
  20. package/ios/SmoothClipTurboModule.h +310 -0
  21. package/ios/SmoothClipView.mm +714 -183
  22. package/ios/SmoothClipViewRegistry.h +1 -0
  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 +22 -6
  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 +27 -1
  31. package/lib/module/capabilities.js +4 -0
  32. package/lib/module/capabilities.js.map +1 -0
  33. package/lib/module/capabilities.native.js +36 -0
  34. package/lib/module/capabilities.native.js.map +1 -0
  35. package/lib/module/capabilityTypes.js +11 -0
  36. package/lib/module/capabilityTypes.js.map +1 -0
  37. package/lib/module/driverState.js +0 -28
  38. package/lib/module/driverState.js.map +1 -1
  39. package/lib/module/drivers.js +1 -281
  40. package/lib/module/drivers.js.map +1 -1
  41. package/lib/module/drivers.native.js +263 -63
  42. package/lib/module/drivers.native.js.map +1 -1
  43. package/lib/module/geometry.js +147 -17
  44. package/lib/module/geometry.js.map +1 -1
  45. package/lib/module/groupDriverTypes.js +4 -0
  46. package/lib/module/groupDriverTypes.js.map +1 -0
  47. package/lib/module/groupDrivers.android.js +4 -0
  48. package/lib/module/groupDrivers.android.js.map +1 -0
  49. package/lib/module/groupDrivers.ios.js +4 -0
  50. package/lib/module/groupDrivers.ios.js.map +1 -0
  51. package/lib/module/groupDrivers.js +4 -0
  52. package/lib/module/groupDrivers.js.map +1 -0
  53. package/lib/module/groupDrivers.native.js +799 -0
  54. package/lib/module/groupDrivers.native.js.map +1 -0
  55. package/lib/module/index.js +3 -2
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/presentationProtocol.js +15 -0
  58. package/lib/module/presentationProtocol.js.map +1 -0
  59. package/lib/typescript/src/NativeSmoothClipModule.d.ts +25 -0
  60. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  61. package/lib/typescript/src/SmoothClipView.d.ts +2 -10
  62. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  63. package/lib/typescript/src/SmoothClipView.ios.d.ts +133 -2
  64. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  65. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +8 -0
  66. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  67. package/lib/typescript/src/capabilities.d.ts +3 -0
  68. package/lib/typescript/src/capabilities.d.ts.map +1 -0
  69. package/lib/typescript/src/capabilities.native.d.ts +3 -0
  70. package/lib/typescript/src/capabilities.native.d.ts.map +1 -0
  71. package/lib/typescript/src/capabilityTypes.d.ts +10 -0
  72. package/lib/typescript/src/capabilityTypes.d.ts.map +1 -0
  73. package/lib/typescript/src/driverState.d.ts +1 -6
  74. package/lib/typescript/src/driverState.d.ts.map +1 -1
  75. package/lib/typescript/src/driverTypes.d.ts +25 -11
  76. package/lib/typescript/src/driverTypes.d.ts.map +1 -1
  77. package/lib/typescript/src/drivers.d.ts +2 -4
  78. package/lib/typescript/src/drivers.d.ts.map +1 -1
  79. package/lib/typescript/src/drivers.native.d.ts.map +1 -1
  80. package/lib/typescript/src/geometry.d.ts +36 -2
  81. package/lib/typescript/src/geometry.d.ts.map +1 -1
  82. package/lib/typescript/src/groupDriverTypes.d.ts +66 -0
  83. package/lib/typescript/src/groupDriverTypes.d.ts.map +1 -0
  84. package/lib/typescript/src/groupDrivers.android.d.ts +2 -0
  85. package/lib/typescript/src/groupDrivers.android.d.ts.map +1 -0
  86. package/lib/typescript/src/groupDrivers.d.ts +2 -0
  87. package/lib/typescript/src/groupDrivers.d.ts.map +1 -0
  88. package/lib/typescript/src/groupDrivers.ios.d.ts +2 -0
  89. package/lib/typescript/src/groupDrivers.ios.d.ts.map +1 -0
  90. package/lib/typescript/src/groupDrivers.native.d.ts +4 -0
  91. package/lib/typescript/src/groupDrivers.native.d.ts.map +1 -0
  92. package/lib/typescript/src/index.d.ts +6 -3
  93. package/lib/typescript/src/index.d.ts.map +1 -1
  94. package/lib/typescript/src/presentationProtocol.d.ts +4 -0
  95. package/lib/typescript/src/presentationProtocol.d.ts.map +1 -0
  96. package/package.json +10 -11
  97. package/src/NativeSmoothClipModule.ts +275 -0
  98. package/src/SmoothClipView.ios.tsx +30 -11
  99. package/src/SmoothClipView.tsx +2 -76
  100. package/src/SmoothClipViewNativeComponent.ts +27 -1
  101. package/src/capabilities.native.ts +69 -0
  102. package/src/capabilities.ts +2 -0
  103. package/src/capabilityTypes.ts +17 -0
  104. package/src/driverState.ts +1 -41
  105. package/src/driverTypes.ts +42 -11
  106. package/src/drivers.native.ts +824 -124
  107. package/src/drivers.ts +2 -423
  108. package/src/geometry.ts +231 -27
  109. package/src/groupDriverTypes.ts +117 -0
  110. package/src/groupDrivers.android.ts +1 -0
  111. package/src/groupDrivers.ios.ts +1 -0
  112. package/src/groupDrivers.native.ts +1189 -0
  113. package/src/groupDrivers.ts +1 -0
  114. package/src/index.ts +30 -2
  115. package/src/presentationProtocol.ts +28 -0
  116. package/lib/module/SmoothClipView.web.js +0 -54
  117. package/lib/module/SmoothClipView.web.js.map +0 -1
  118. package/lib/module/index.web.js +0 -8
  119. package/lib/module/index.web.js.map +0 -1
  120. package/lib/module/webClipPath.js +0 -25
  121. package/lib/module/webClipPath.js.map +0 -1
  122. package/lib/typescript/src/SmoothClipView.web.d.ts +0 -10
  123. package/lib/typescript/src/SmoothClipView.web.d.ts.map +0 -1
  124. package/lib/typescript/src/index.web.d.ts +0 -9
  125. package/lib/typescript/src/index.web.d.ts.map +0 -1
  126. package/lib/typescript/src/webClipPath.d.ts +0 -7
  127. package/lib/typescript/src/webClipPath.d.ts.map +0 -1
  128. package/src/SmoothClipView.web.tsx +0 -66
  129. package/src/index.web.ts +0 -23
  130. package/src/webClipPath.ts +0 -30
@@ -57,14 +57,14 @@ struct ActiveAnimation {
57
57
  Presentation current{{0, 0, 0, 0, 0}, 0, 0};
58
58
  TimingAnimation timing{};
59
59
  SpringAnimation spring{};
60
- // Owns the keyframes and their precomputed monotone-cubic tangents; the
61
- // tangent solve happens on reset(), never per frame.
60
+ // Owns the segment-linear keyframes and a cursor into the active segment.
62
61
  KeyframeCurve keyframes;
63
62
  double durationS = 0;
64
63
  double startedAtS = 0;
65
- // Integrated spring state (channels: x, y, width, height, radius, tx, ty).
66
- std::array<double, 7> springPosition{};
67
- std::array<double, 7> springVelocity{};
64
+ // Integrated spring state (the eleven continuous V2 channels; curve is
65
+ // categorical and remains the start presentation's curve while running).
66
+ Channels springPosition{};
67
+ Channels springVelocity{};
68
68
  double lastFrameS = 0;
69
69
  bool finished = true;
70
70
  // False while the animation is latched, and held out of animatingDrivers()
@@ -104,6 +104,8 @@ struct DriverState {
104
104
  // replay, hosts mounted in another subtree). The entry is erased when the
105
105
  // last view leaves and revived by a take-ownership setPresentation.
106
106
  bool destroyed = false;
107
+ // Non-zero while this driver is an immutable member of one active group.
108
+ int32_t groupId = 0;
107
109
 
108
110
  // 'inherit' velocity samples; recording/coalescing/projection live in the
109
111
  // shared cpp/SmoothClipVelocityTracker.h (behavior-paired with iOS).
@@ -115,6 +117,31 @@ struct CompletionSink {
115
117
  CompletionCallback callback;
116
118
  };
117
119
 
120
+ struct GroupCompletionSink {
121
+ const void *owner = nullptr;
122
+ GroupCompletionCallback callback;
123
+ };
124
+
125
+ struct GroupMemberAnimation {
126
+ uint64_t driverId = 0;
127
+ ActiveAnimation animation;
128
+ };
129
+
130
+ struct ActiveGroup {
131
+ uint64_t controllerId = 0;
132
+ int32_t id = 0;
133
+ AnimationKind kind = AnimationKind::Timing;
134
+ GroupSuspensionPolicy suspensionPolicy = GroupSuspensionPolicy::Pause;
135
+ TimingAnimation timing{};
136
+ SpringAnimation spring{};
137
+ double durationS = 0;
138
+ double startedAtS = 0;
139
+ double lastFrameS = 0;
140
+ bool started = false;
141
+ bool frameClockAnchored = false;
142
+ std::vector<GroupMemberAnimation> members;
143
+ };
144
+
118
145
  std::unordered_map<uint64_t, DriverState> &registry() {
119
146
  static std::unordered_map<uint64_t, DriverState> value;
120
147
  return value;
@@ -125,6 +152,21 @@ CompletionSink &completionSink() {
125
152
  return sink;
126
153
  }
127
154
 
155
+ GroupCompletionSink &groupCompletionSink() {
156
+ static GroupCompletionSink sink;
157
+ return sink;
158
+ }
159
+
160
+ std::unordered_map<int32_t, ActiveGroup> &groupRegistry() {
161
+ static std::unordered_map<int32_t, ActiveGroup> value;
162
+ return value;
163
+ }
164
+
165
+ std::vector<int32_t> &animatingGroups() {
166
+ static std::vector<int32_t> value;
167
+ return value;
168
+ }
169
+
128
170
  // The sink is written from the JS thread (installBindings / invalidate) and
129
171
  // invoked on main. Invoking under the lock also keeps the owning bindings
130
172
  // state alive for the duration of the callback.
@@ -242,7 +284,7 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
242
284
  while (dt > 0) {
243
285
  const double step = std::min(dt, kMaxStepS);
244
286
  dt -= step;
245
- for (int index = 0; index < 7; index += 1) {
287
+ for (std::size_t index = 0; index < kChannelCount; index += 1) {
246
288
  const double displacement =
247
289
  animation.springPosition[index] - target[index];
248
290
  const double acceleration =
@@ -254,7 +296,7 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
254
296
  }
255
297
  }
256
298
  bool settled = true;
257
- for (int index = 0; index < 7; index += 1) {
299
+ for (std::size_t index = 0; index < kChannelCount; index += 1) {
258
300
  if (!std::isfinite(animation.springPosition[index]) ||
259
301
  !std::isfinite(animation.springVelocity[index])) {
260
302
  // A divergent configuration must never freeze the clip: end at target.
@@ -269,7 +311,8 @@ bool advanceSpring(ActiveAnimation &animation, double now) {
269
311
  settled = false;
270
312
  }
271
313
  }
272
- animation.current = fromChannels(animation.springPosition);
314
+ animation.current =
315
+ fromChannels(animation.springPosition, animation.start.clip.curve);
273
316
  return settled || now - animation.startedAtS >= kSpringMaxDurationS;
274
317
  }
275
318
 
@@ -296,32 +339,117 @@ void emitCompletion(uint64_t driverId, int32_t animationId, bool finished) {
296
339
  }
297
340
  }
298
341
 
342
+ void emitGroupCompletion(const ActiveGroup &group, bool finished) {
343
+ std::vector<uint64_t> driverIds;
344
+ driverIds.reserve(group.members.size());
345
+ for (const GroupMemberAnimation &member : group.members) {
346
+ driverIds.push_back(member.driverId);
347
+ }
348
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
349
+ if (groupCompletionSink().callback) {
350
+ groupCompletionSink().callback(
351
+ group.controllerId, group.id, finished, std::move(driverIds));
352
+ }
353
+ }
354
+
355
+ const GroupMemberAnimation *findGroupMember(
356
+ int32_t groupId,
357
+ uint64_t driverId) {
358
+ const auto groupIterator = groupRegistry().find(groupId);
359
+ if (groupIterator == groupRegistry().end()) return nullptr;
360
+ for (const GroupMemberAnimation &member : groupIterator->second.members) {
361
+ if (member.driverId == driverId) return &member;
362
+ }
363
+ return nullptr;
364
+ }
365
+
366
+ Presentation visiblePresentation(
367
+ uint64_t driverId,
368
+ const DriverState &state) {
369
+ if (state.groupId != 0) {
370
+ const GroupMemberAnimation *member =
371
+ findGroupMember(state.groupId, driverId);
372
+ if (member != nullptr) return member->animation.current;
373
+ }
374
+ return state.animation.has_value() ? state.animation->current : state.latest;
375
+ }
376
+
377
+ bool needsV2Delivery(const Presentation &presentation) {
378
+ const double topLeft = SmoothClipResolvedRadius(
379
+ presentation.clip.topLeftRadius, presentation.clip.radius);
380
+ const double topRight = SmoothClipResolvedRadius(
381
+ presentation.clip.topRightRadius, presentation.clip.radius);
382
+ const double bottomRight = SmoothClipResolvedRadius(
383
+ presentation.clip.bottomRightRadius, presentation.clip.radius);
384
+ const double bottomLeft = SmoothClipResolvedRadius(
385
+ presentation.clip.bottomLeftRadius, presentation.clip.radius);
386
+ return presentation.clip.curve != ClipCurve::Circular ||
387
+ presentation.contentScale != 1.0 ||
388
+ topLeft != presentation.clip.radius ||
389
+ topRight != presentation.clip.radius ||
390
+ bottomRight != presentation.clip.radius ||
391
+ bottomLeft != presentation.clip.radius;
392
+ }
393
+
299
394
  // Terminal per-frame delivery: scale DIP -> px and normalize against the
300
395
  // pushed host metrics in C++, so the JNI call carries final pixel floats and
301
396
  // the Kotlin side reduces to field stores + invalidateOutline().
302
397
  void deliverToView(const ViewEntry &entry, const Presentation &presentation) {
398
+ const bool deliverV2 = needsV2Delivery(presentation);
303
399
  if (entry.density <= 0) {
304
- entry.view->applyClip(presentation);
400
+ if (deliverV2) {
401
+ entry.view->applyClipV2(presentation);
402
+ } else {
403
+ entry.view->applyClip(presentation);
404
+ }
305
405
  return;
306
406
  }
307
407
  const double density = entry.density;
308
408
  NormalizedClip clip;
309
- if (!SmoothClipNormalize(
310
- presentation.clip.x * density,
311
- presentation.clip.y * density,
312
- presentation.clip.width * density,
313
- presentation.clip.height * density,
314
- presentation.clip.radius * density,
315
- entry.hostWidthPx,
316
- entry.hostHeightPx,
317
- clip)) {
318
- return;
409
+ if (deliverV2) {
410
+ if (!SmoothClipNormalizeV2(
411
+ presentation.clip.x * density,
412
+ presentation.clip.y * density,
413
+ presentation.clip.width * density,
414
+ presentation.clip.height * density,
415
+ presentation.clip.radius * density,
416
+ presentation.clip.topLeftRadius * density,
417
+ presentation.clip.topRightRadius * density,
418
+ presentation.clip.bottomRightRadius * density,
419
+ presentation.clip.bottomLeftRadius * density,
420
+ presentation.clip.curve,
421
+ entry.hostWidthPx,
422
+ entry.hostHeightPx,
423
+ clip)) {
424
+ return;
425
+ }
426
+ } else {
427
+ if (!SmoothClipNormalize(
428
+ presentation.clip.x * density,
429
+ presentation.clip.y * density,
430
+ presentation.clip.width * density,
431
+ presentation.clip.height * density,
432
+ presentation.clip.radius * density,
433
+ entry.hostWidthPx,
434
+ entry.hostHeightPx,
435
+ clip)) {
436
+ return;
437
+ }
319
438
  }
320
439
  const double translateX = presentation.contentTranslateX * density;
321
440
  const double translateY = presentation.contentTranslateY * density;
322
441
  // Atomic reject, mirroring the Kotlin DIP path's all-or-nothing gate.
323
- if (!std::isfinite(translateX) || !std::isfinite(translateY)) return;
324
- entry.view->applyClipPx(clip, translateX, translateY);
442
+ if (!std::isfinite(translateX) || !std::isfinite(translateY) ||
443
+ !std::isfinite(presentation.contentScale) ||
444
+ presentation.contentScale <= 0) {
445
+ return;
446
+ }
447
+ if (deliverV2) {
448
+ entry.view->applyClipV2Px(
449
+ clip, translateX, translateY, presentation.contentScale);
450
+ } else {
451
+ entry.view->applyClipPx(clip, translateX, translateY);
452
+ }
325
453
  }
326
454
 
327
455
  void applyToViews(DriverState &state, const Presentation &presentation) {
@@ -369,8 +497,10 @@ Presentation prepareAnimation(
369
497
  Presentation target) {
370
498
  const Presentation visibleBefore =
371
499
  state.animation.has_value() ? state.animation->current : state.latest;
372
- const bool acceptsInteractiveStart =
373
- start.hasInteractiveStart && state.ownership == Ownership::Interactive;
500
+ // `from` is an explicit part of the replacement transaction and therefore
501
+ // overrides the sampled native frame even when the previous owner was a
502
+ // native animation/group.
503
+ const bool acceptsInteractiveStart = start.hasInteractiveStart;
374
504
  finishActive(driverId, state, false);
375
505
  const Presentation resolvedStart =
376
506
  acceptsInteractiveStart ? start.interactiveStart : visibleBefore;
@@ -392,6 +522,77 @@ bool entryDisplayable(const ViewEntry &entry) {
392
522
  entry.lifecycleVisible;
393
523
  }
394
524
 
525
+ Geometry geometryInPixels(
526
+ const Geometry &geometry,
527
+ double density) {
528
+ Geometry result = geometry;
529
+ result.x *= density;
530
+ result.y *= density;
531
+ result.width *= density;
532
+ result.height *= density;
533
+ result.radius *= density;
534
+ result.topLeftRadius *= density;
535
+ result.topRightRadius *= density;
536
+ result.bottomRightRadius *= density;
537
+ result.bottomLeftRadius *= density;
538
+ return result;
539
+ }
540
+
541
+ bool presentationFitsEveryKnownHostWithoutNormalization(
542
+ const DriverState &state,
543
+ const Presentation &presentation) {
544
+ for (const ViewEntry &entry : state.views) {
545
+ // Zero host metrics are still waiting on Fabric layout and remain covered
546
+ // by the existing readiness latch. Once metrics exist, density is required
547
+ // to compare the DIP presentation against the exact pixel host bounds.
548
+ if (entry.hostWidthPx <= 0 || entry.hostHeightPx <= 0) continue;
549
+ if (entry.density <= 0 ||
550
+ !SmoothClipGeometryNormalizationIsIdentity(
551
+ geometryInPixels(presentation.clip, entry.density),
552
+ entry.hostWidthPx,
553
+ entry.hostHeightPx)) {
554
+ return false;
555
+ }
556
+ }
557
+ return true;
558
+ }
559
+
560
+ bool timingPlanPreservesLinearHostNormalization(
561
+ const DriverState &state,
562
+ const Presentation &start,
563
+ const Presentation &target,
564
+ const TimingAnimation &animation) {
565
+ const double minimumProgress = std::min(
566
+ {0.0, 1.0, animation.controlPoint1Y, animation.controlPoint2Y});
567
+ const double maximumProgress = std::max(
568
+ {0.0, 1.0, animation.controlPoint1Y, animation.controlPoint2Y});
569
+ const Presentation minimum =
570
+ interpolate(start, target, minimumProgress);
571
+ const Presentation maximum =
572
+ interpolate(start, target, maximumProgress);
573
+ return isFiniteProtocolV2Presentation(minimum) &&
574
+ isFiniteProtocolV2Presentation(maximum) &&
575
+ presentationFitsEveryKnownHostWithoutNormalization(state, minimum) &&
576
+ presentationFitsEveryKnownHostWithoutNormalization(state, maximum);
577
+ }
578
+
579
+ bool keyframePlanPreservesLinearHostNormalization(
580
+ const DriverState &state,
581
+ const Presentation &resolvedStart,
582
+ const std::vector<Keyframe> &keyframes) {
583
+ if (!presentationFitsEveryKnownHostWithoutNormalization(
584
+ state, resolvedStart)) {
585
+ return false;
586
+ }
587
+ for (std::size_t index = 1; index < keyframes.size(); index += 1) {
588
+ if (!presentationFitsEveryKnownHostWithoutNormalization(
589
+ state, keyframes[index].presentation)) {
590
+ return false;
591
+ }
592
+ }
593
+ return true;
594
+ }
595
+
395
596
  bool anyDisplayableView(const DriverState &state) {
396
597
  for (const ViewEntry &entry : state.views) {
397
598
  if (entryDisplayable(entry)) return true;
@@ -399,6 +600,141 @@ bool anyDisplayableView(const DriverState &state) {
399
600
  return false;
400
601
  }
401
602
 
603
+ bool allGroupMembersReady(const ActiveGroup &group) {
604
+ for (const GroupMemberAnimation &member : group.members) {
605
+ const auto iterator = registry().find(member.driverId);
606
+ if (iterator == registry().end() || iterator->second.destroyed ||
607
+ !anyDisplayableView(iterator->second)) {
608
+ return false;
609
+ }
610
+ }
611
+ return true;
612
+ }
613
+
614
+ void removeAnimatingGroup(int32_t groupId) {
615
+ auto &active = animatingGroups();
616
+ active.erase(std::remove(active.begin(), active.end(), groupId), active.end());
617
+ }
618
+
619
+ void applyGroupCurrent(const ActiveGroup &group) {
620
+ for (const GroupMemberAnimation &member : group.members) {
621
+ const auto iterator = registry().find(member.driverId);
622
+ if (iterator != registry().end()) {
623
+ applyToViews(iterator->second, member.animation.current);
624
+ }
625
+ }
626
+ }
627
+
628
+ std::vector<DriverSnapshot> finishGroupImpl(
629
+ int32_t groupId,
630
+ bool useTarget,
631
+ bool finished) {
632
+ auto groupIterator = groupRegistry().find(groupId);
633
+ if (groupIterator == groupRegistry().end()) return {};
634
+ ActiveGroup group = std::move(groupIterator->second);
635
+ groupRegistry().erase(groupIterator);
636
+ removeAnimatingGroup(groupId);
637
+
638
+ std::vector<DriverSnapshot> snapshots;
639
+ snapshots.reserve(group.members.size());
640
+ for (GroupMemberAnimation &member : group.members) {
641
+ auto stateIterator = registry().find(member.driverId);
642
+ if (stateIterator == registry().end()) {
643
+ snapshots.push_back({member.driverId, unavailablePresentation(), false});
644
+ continue;
645
+ }
646
+ DriverState &state = stateIterator->second;
647
+ const Presentation result =
648
+ useTarget ? member.animation.target : member.animation.current;
649
+ state.groupId = 0;
650
+ state.latest = result;
651
+ state.hasLatest = true;
652
+ state.ownership = Ownership::Interactive;
653
+ applyToViews(state, result);
654
+ snapshots.push_back(
655
+ {member.driverId, result, !state.destroyed && anyDisplayableView(state)});
656
+ }
657
+ emitGroupCompletion(group, finished);
658
+ return snapshots;
659
+ }
660
+
661
+ void startLatchedGroup(ActiveGroup &group) {
662
+ const double now = nowSeconds();
663
+ group.started = true;
664
+ group.startedAtS = now;
665
+ group.lastFrameS = now;
666
+ group.frameClockAnchored = false;
667
+ for (GroupMemberAnimation &member : group.members) {
668
+ member.animation.startedAtS = now;
669
+ member.animation.lastFrameS = now;
670
+ member.animation.frameClockAnchored = false;
671
+ }
672
+ auto &active = animatingGroups();
673
+ if (std::find(active.begin(), active.end(), group.id) == active.end()) {
674
+ active.push_back(group.id);
675
+ }
676
+ applyGroupCurrent(group);
677
+ scheduleFrame();
678
+ }
679
+
680
+ void pauseGroup(ActiveGroup &group) {
681
+ if (group.kind == AnimationKind::Timing) {
682
+ const double rawProgress = timingFraction(
683
+ group.lastFrameS, group.startedAtS, group.durationS);
684
+ const TimingRemainder remainder = timingRemainder(group.timing, rawProgress);
685
+ group.timing = remainder.animation;
686
+ group.durationS = remainder.animation.durationMs / 1000.0;
687
+ for (GroupMemberAnimation &member : group.members) {
688
+ member.animation.start = member.animation.current;
689
+ member.animation.timing = remainder.animation;
690
+ }
691
+ } else if (group.kind == AnimationKind::Keyframes) {
692
+ double remainingDurationMs = 0;
693
+ for (GroupMemberAnimation &member : group.members) {
694
+ const KeyframeContinuation continuation = keyframeContinuationAtFrame(
695
+ member.animation.keyframes.frames(),
696
+ member.animation.current,
697
+ member.animation.target,
698
+ group.lastFrameS,
699
+ group.startedAtS,
700
+ group.durationS);
701
+ member.animation.start = continuation.start;
702
+ member.animation.current = continuation.start;
703
+ member.animation.keyframes.reset(continuation.frames);
704
+ remainingDurationMs = continuation.durationMs;
705
+ }
706
+ group.durationS = remainingDurationMs / 1000.0;
707
+ }
708
+ group.started = false;
709
+ group.frameClockAnchored = false;
710
+ removeAnimatingGroup(group.id);
711
+ }
712
+
713
+ void reconcileGroupReadiness(uint64_t driverId) {
714
+ const auto stateIterator = registry().find(driverId);
715
+ if (stateIterator == registry().end() || stateIterator->second.groupId == 0) {
716
+ return;
717
+ }
718
+ const int32_t groupId = stateIterator->second.groupId;
719
+ auto groupIterator = groupRegistry().find(groupId);
720
+ if (groupIterator == groupRegistry().end()) {
721
+ stateIterator->second.groupId = 0;
722
+ return;
723
+ }
724
+ ActiveGroup &group = groupIterator->second;
725
+ const bool ready = allGroupMembersReady(group);
726
+ if (!group.started) {
727
+ if (ready) startLatchedGroup(group);
728
+ return;
729
+ }
730
+ if (ready) return;
731
+ if (group.suspensionPolicy == GroupSuspensionPolicy::Finish) {
732
+ finishGroupImpl(groupId, true, true);
733
+ } else {
734
+ pauseGroup(group);
735
+ }
736
+ }
737
+
402
738
  // Starts a latched animation: rebases the clock so no progress was burned
403
739
  // while no view could display, then joins the choreographer loop.
404
740
  void startLatchedAnimation(uint64_t driverId, DriverState &state) {
@@ -615,6 +951,54 @@ void advance(uint64_t driverId, DriverState &state, double now) {
615
951
  }
616
952
  }
617
953
 
954
+ void advanceGroup(int32_t groupId, ActiveGroup &group, double now) {
955
+ if (!group.frameClockAnchored) {
956
+ group.startedAtS = anchorStartTime(group.startedAtS, now);
957
+ group.lastFrameS = group.startedAtS;
958
+ group.frameClockAnchored = true;
959
+ for (GroupMemberAnimation &member : group.members) {
960
+ member.animation.startedAtS = group.startedAtS;
961
+ member.animation.lastFrameS = group.startedAtS;
962
+ member.animation.frameClockAnchored = true;
963
+ }
964
+ }
965
+
966
+ bool done = false;
967
+ if (group.kind == AnimationKind::Spring) {
968
+ done = true;
969
+ for (GroupMemberAnimation &member : group.members) {
970
+ if (!advanceSpring(member.animation, now)) done = false;
971
+ }
972
+ } else {
973
+ group.lastFrameS = now;
974
+ const double fraction =
975
+ timingFraction(now, group.startedAtS, group.durationS);
976
+ for (GroupMemberAnimation &member : group.members) {
977
+ member.animation.lastFrameS = now;
978
+ if (group.kind == AnimationKind::Keyframes) {
979
+ member.animation.current =
980
+ member.animation.keyframes.evaluate(fraction);
981
+ } else {
982
+ const double eased = cubicBezier(
983
+ group.timing.controlPoint1X,
984
+ group.timing.controlPoint1Y,
985
+ group.timing.controlPoint2X,
986
+ group.timing.controlPoint2Y,
987
+ fraction);
988
+ member.animation.current = interpolate(
989
+ member.animation.start, member.animation.target, eased);
990
+ }
991
+ }
992
+ done = fraction >= 1.0;
993
+ }
994
+
995
+ if (done) {
996
+ finishGroupImpl(groupId, true, true);
997
+ } else {
998
+ applyGroupCurrent(group);
999
+ }
1000
+ }
1001
+
618
1002
  void onFrameImpl(double now) {
619
1003
  SMOOTH_CLIP_TRACE("SmoothClip.frame");
620
1004
  gFrameScheduled = false;
@@ -634,7 +1018,14 @@ void onFrameImpl(double now) {
634
1018
  }
635
1019
  advance(driverId, iterator->second, now);
636
1020
  }
637
- if (!animatingDrivers().empty()) scheduleFrame();
1021
+ static std::vector<int32_t> groupScratch;
1022
+ groupScratch = animatingGroups();
1023
+ for (const int32_t groupId : groupScratch) {
1024
+ auto iterator = groupRegistry().find(groupId);
1025
+ if (iterator == groupRegistry().end() || !iterator->second.started) continue;
1026
+ advanceGroup(groupId, iterator->second, now);
1027
+ }
1028
+ if (!animatingDrivers().empty() || !animatingGroups().empty()) scheduleFrame();
638
1029
  }
639
1030
 
640
1031
  } // namespace
@@ -673,6 +1064,407 @@ void clearCompletionCallback(const void *owner) {
673
1064
  completionSink().callback = nullptr;
674
1065
  }
675
1066
 
1067
+ void setGroupCompletionCallback(
1068
+ const void *owner,
1069
+ GroupCompletionCallback callback) {
1070
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
1071
+ groupCompletionSink().owner = owner;
1072
+ groupCompletionSink().callback = std::move(callback);
1073
+ }
1074
+
1075
+ void clearGroupCompletionCallback(const void *owner) {
1076
+ std::lock_guard<std::mutex> lock(completionSinkMutex());
1077
+ if (groupCompletionSink().owner != owner) return;
1078
+ groupCompletionSink().owner = nullptr;
1079
+ groupCompletionSink().callback = nullptr;
1080
+ }
1081
+
1082
+ Presentation snapshotCurrent(uint64_t driverId) {
1083
+ if (!isOnMainThread()) return unavailablePresentation();
1084
+ const auto iterator = registry().find(driverId);
1085
+ if (iterator == registry().end() || iterator->second.destroyed ||
1086
+ !iterator->second.hasLatest) {
1087
+ return unavailablePresentation();
1088
+ }
1089
+ return visiblePresentation(driverId, iterator->second);
1090
+ }
1091
+
1092
+ Presentation snapshotCurrentAndroid(uint64_t driverId) {
1093
+ return snapshotCurrent(driverId);
1094
+ }
1095
+
1096
+ std::vector<DriverSnapshot> snapshotGroup(
1097
+ const std::vector<uint64_t> &driverIds) {
1098
+ std::vector<DriverSnapshot> snapshots;
1099
+ snapshots.reserve(driverIds.size());
1100
+ if (!isOnMainThread()) return snapshots;
1101
+ for (const uint64_t driverId : driverIds) {
1102
+ const auto iterator = registry().find(driverId);
1103
+ if (iterator == registry().end() || iterator->second.destroyed ||
1104
+ !iterator->second.hasLatest) {
1105
+ snapshots.push_back({driverId, unavailablePresentation(), false});
1106
+ continue;
1107
+ }
1108
+ const DriverState &state = iterator->second;
1109
+ snapshots.push_back(
1110
+ {driverId, visiblePresentation(driverId, state),
1111
+ anyDisplayableView(state)});
1112
+ }
1113
+ return snapshots;
1114
+ }
1115
+
1116
+ std::vector<DriverSnapshot> beginGroupInteraction(
1117
+ const std::vector<uint64_t> &driverIds) {
1118
+ if (!isOnMainThread()) return {};
1119
+ std::vector<uint64_t> unique;
1120
+ unique.reserve(driverIds.size());
1121
+ for (const uint64_t driverId : driverIds) {
1122
+ if (driverId == 0 ||
1123
+ std::find(unique.begin(), unique.end(), driverId) != unique.end()) {
1124
+ return {};
1125
+ }
1126
+ const auto iterator = registry().find(driverId);
1127
+ if (iterator == registry().end() || iterator->second.destroyed ||
1128
+ !iterator->second.hasLatest) {
1129
+ return snapshotGroup(driverIds);
1130
+ }
1131
+ unique.push_back(driverId);
1132
+ }
1133
+
1134
+ std::vector<int32_t> groupsToCancel;
1135
+ for (const uint64_t driverId : driverIds) {
1136
+ const int32_t groupId = registry().at(driverId).groupId;
1137
+ if (groupId != 0 &&
1138
+ std::find(groupsToCancel.begin(), groupsToCancel.end(), groupId) ==
1139
+ groupsToCancel.end()) {
1140
+ groupsToCancel.push_back(groupId);
1141
+ }
1142
+ }
1143
+ for (const int32_t groupId : groupsToCancel) {
1144
+ finishGroupImpl(groupId, false, false);
1145
+ }
1146
+ for (const uint64_t driverId : driverIds) {
1147
+ DriverState &state = registry().at(driverId);
1148
+ if (state.animation.has_value()) {
1149
+ const int32_t animationId = state.animation->id;
1150
+ const Presentation current = state.animation->current;
1151
+ clearActiveAnimation(driverId, state);
1152
+ state.latest = current;
1153
+ state.hasLatest = true;
1154
+ applyToViews(state, current);
1155
+ emitCompletion(driverId, animationId, false);
1156
+ }
1157
+ state.ownership = Ownership::Interactive;
1158
+ }
1159
+ return snapshotGroup(driverIds);
1160
+ }
1161
+
1162
+ bool setPresentationBatch(const std::vector<BatchEntry> &entries) {
1163
+ if (!isOnMainThread() || entries.empty()) return false;
1164
+ std::vector<uint64_t> unique;
1165
+ std::vector<int32_t> groupsToCancel;
1166
+ unique.reserve(entries.size());
1167
+ for (const BatchEntry &entry : entries) {
1168
+ if (entry.driverId == 0 ||
1169
+ std::find(unique.begin(), unique.end(), entry.driverId) != unique.end()) {
1170
+ return false;
1171
+ }
1172
+ const auto iterator = registry().find(entry.driverId);
1173
+ if (iterator == registry().end() || iterator->second.destroyed ||
1174
+ !isFiniteProtocolV2Presentation(entry.presentation)) {
1175
+ return false;
1176
+ }
1177
+ if (iterator != registry().end() && iterator->second.groupId != 0 &&
1178
+ std::find(
1179
+ groupsToCancel.begin(),
1180
+ groupsToCancel.end(),
1181
+ iterator->second.groupId) == groupsToCancel.end()) {
1182
+ groupsToCancel.push_back(iterator->second.groupId);
1183
+ }
1184
+ unique.push_back(entry.driverId);
1185
+ }
1186
+
1187
+ // Replacement is all-or-nothing: validation above is deliberately complete
1188
+ // before any old group is dissolved. An overlap dissolves the whole immutable
1189
+ // old group (including members absent from this batch) exactly once.
1190
+ for (const int32_t groupId : groupsToCancel) {
1191
+ finishGroupImpl(groupId, false, false);
1192
+ }
1193
+
1194
+ const double now = nowSeconds();
1195
+ for (const BatchEntry &entry : entries) {
1196
+ DriverState &state = registry().at(entry.driverId);
1197
+ state.destroyed = false;
1198
+ finishActive(entry.driverId, state, false);
1199
+ state.latest = entry.presentation;
1200
+ state.hasLatest = true;
1201
+ state.ownership = Ownership::Interactive;
1202
+ recordVelocitySample(state.samples, toChannels(entry.presentation), now);
1203
+ }
1204
+ for (const BatchEntry &entry : entries) {
1205
+ applyToViews(registry().at(entry.driverId), entry.presentation);
1206
+ }
1207
+ return true;
1208
+ }
1209
+
1210
+ namespace {
1211
+
1212
+ int32_t startGroupCommon(
1213
+ uint64_t controllerId,
1214
+ std::vector<GroupMotionEntry> entries,
1215
+ AnimationKind kind,
1216
+ TimingAnimation timing,
1217
+ SpringAnimation spring,
1218
+ double durationMs,
1219
+ int32_t reduceMotion,
1220
+ GroupSuspensionPolicy suspensionPolicy,
1221
+ double startedAtHintS) {
1222
+ if (!isOnMainThread() || controllerId == 0 || entries.empty() ||
1223
+ (suspensionPolicy != GroupSuspensionPolicy::Pause &&
1224
+ suspensionPolicy != GroupSuspensionPolicy::Finish)) {
1225
+ return 0;
1226
+ }
1227
+ if (kind == AnimationKind::Timing &&
1228
+ !isValidProtocolV2Timing(timing)) {
1229
+ return 0;
1230
+ }
1231
+ if (kind == AnimationKind::Spring &&
1232
+ !isValidProtocolV2Spring(spring)) {
1233
+ return 0;
1234
+ }
1235
+ if (kind == AnimationKind::Keyframes &&
1236
+ (!std::isfinite(durationMs) || durationMs < 0 ||
1237
+ !isValidReduceMotionCode(reduceMotion))) {
1238
+ return 0;
1239
+ }
1240
+ const bool reduce = shouldReduceMotion(reduceMotion) ||
1241
+ (kind != AnimationKind::Spring && durationMs <= 0);
1242
+ std::vector<uint64_t> unique;
1243
+ std::vector<int32_t> groupsToCancel;
1244
+ std::vector<Presentation> resolvedStarts;
1245
+ unique.reserve(entries.size());
1246
+ resolvedStarts.reserve(entries.size());
1247
+ for (const GroupMotionEntry &entry : entries) {
1248
+ if (entry.driverId == 0 ||
1249
+ std::find(unique.begin(), unique.end(), entry.driverId) != unique.end()) {
1250
+ return 0;
1251
+ }
1252
+ const auto iterator = registry().find(entry.driverId);
1253
+ if ((iterator == registry().end() || iterator->second.destroyed ||
1254
+ !iterator->second.hasLatest) && !entry.hasFrom) {
1255
+ return 0;
1256
+ }
1257
+ if (iterator != registry().end() && iterator->second.groupId != 0 &&
1258
+ std::find(
1259
+ groupsToCancel.begin(),
1260
+ groupsToCancel.end(),
1261
+ iterator->second.groupId) == groupsToCancel.end()) {
1262
+ groupsToCancel.push_back(iterator->second.groupId);
1263
+ }
1264
+ const Presentation resolvedStart = entry.hasFrom
1265
+ ? entry.from
1266
+ : visiblePresentation(entry.driverId, iterator->second);
1267
+ if (!isFiniteProtocolV2Presentation(resolvedStart) ||
1268
+ !isFiniteProtocolV2Presentation(entry.target) ||
1269
+ resolvedStart.clip.curve != entry.target.clip.curve) {
1270
+ return 0;
1271
+ }
1272
+ if (kind == AnimationKind::Spring &&
1273
+ !protocolV2SpringScaleIsProvablyPositive(
1274
+ resolvedStart, entry.target, spring)) {
1275
+ return 0;
1276
+ }
1277
+ if (kind == AnimationKind::Keyframes) {
1278
+ if (!isValidProtocolV2Keyframes(
1279
+ entry.keyframes,
1280
+ resolvedStart,
1281
+ entry.target,
1282
+ entry.hasFrom)) {
1283
+ return 0;
1284
+ }
1285
+ } else if (!entry.keyframes.empty()) {
1286
+ return 0;
1287
+ }
1288
+ if (!reduce && iterator != registry().end()) {
1289
+ if (kind == AnimationKind::Timing &&
1290
+ !timingPlanPreservesLinearHostNormalization(
1291
+ iterator->second, resolvedStart, entry.target, timing)) {
1292
+ return 0;
1293
+ }
1294
+ if (kind == AnimationKind::Keyframes &&
1295
+ !keyframePlanPreservesLinearHostNormalization(
1296
+ iterator->second, resolvedStart, entry.keyframes)) {
1297
+ return 0;
1298
+ }
1299
+ }
1300
+ resolvedStarts.push_back(resolvedStart);
1301
+ unique.push_back(entry.driverId);
1302
+ }
1303
+
1304
+ // Capture every implicit start above, while overlapped groups still own
1305
+ // their current frames. Only after full preflight may replacement dissolve
1306
+ // those immutable groups and emit one unfinished completion for each.
1307
+ for (const int32_t groupId : groupsToCancel) {
1308
+ finishGroupImpl(groupId, false, false);
1309
+ }
1310
+
1311
+ ActiveGroup group;
1312
+ group.controllerId = controllerId;
1313
+ group.id = allocateAnimationId();
1314
+ group.kind = kind;
1315
+ group.suspensionPolicy = suspensionPolicy;
1316
+ group.timing = timing;
1317
+ group.spring = spring;
1318
+ group.durationS = durationMs / 1000.0;
1319
+ group.members.reserve(entries.size());
1320
+ const double wallNow = nowSeconds();
1321
+
1322
+ for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1323
+ GroupMotionEntry &entry = entries[entryIndex];
1324
+ DriverState &state = registry()[entry.driverId];
1325
+ const Presentation resolvedStart = resolvedStarts[entryIndex];
1326
+ finishActive(entry.driverId, state, false);
1327
+ state.destroyed = false;
1328
+ state.latest = entry.target;
1329
+ state.hasLatest = true;
1330
+ state.ownership = Ownership::Native;
1331
+ state.groupId = group.id;
1332
+
1333
+ GroupMemberAnimation member;
1334
+ member.driverId = entry.driverId;
1335
+ member.animation.id = group.id;
1336
+ member.animation.kind = kind;
1337
+ member.animation.start = resolvedStart;
1338
+ member.animation.target = entry.target;
1339
+ member.animation.current = resolvedStart;
1340
+ member.animation.timing = timing;
1341
+ member.animation.spring = spring;
1342
+ member.animation.durationS = group.durationS;
1343
+ if (kind == AnimationKind::Keyframes) {
1344
+ if (!entry.hasFrom) {
1345
+ entry.keyframes.front().presentation = resolvedStart;
1346
+ }
1347
+ member.animation.keyframes.reset(std::move(entry.keyframes));
1348
+ } else if (kind == AnimationKind::Spring) {
1349
+ const double velocity = spring.inheritVelocity
1350
+ ? inheritedVelocity(state.samples, toChannels(entry.target), wallNow)
1351
+ : spring.initialVelocity;
1352
+ member.animation.spring.initialVelocity = velocity;
1353
+ member.animation.spring.inheritVelocity = false;
1354
+ const Channels startChannels = toChannels(resolvedStart);
1355
+ const Channels targetChannels = toChannels(entry.target);
1356
+ for (std::size_t index = 0; index < kChannelCount; index += 1) {
1357
+ member.animation.springPosition[index] = startChannels[index];
1358
+ member.animation.springVelocity[index] =
1359
+ velocity * (targetChannels[index] - startChannels[index]);
1360
+ }
1361
+ }
1362
+ group.members.push_back(std::move(member));
1363
+ }
1364
+
1365
+ // Apply every explicit start after the whole participant set has passed
1366
+ // preflight and acquired the new immutable group id. This remains one
1367
+ // synchronous main-thread transaction even when the initial readiness
1368
+ // barrier is latched; ready hosts must not keep displaying their old frame
1369
+ // while snapshots already report `from`.
1370
+ for (std::size_t entryIndex = 0; entryIndex < entries.size(); entryIndex += 1) {
1371
+ if (!entries[entryIndex].hasFrom) continue;
1372
+ applyToViews(
1373
+ registry().at(entries[entryIndex].driverId),
1374
+ resolvedStarts[entryIndex]);
1375
+ }
1376
+
1377
+ const int32_t groupId = group.id;
1378
+ const StartStamp stamp = resolveStartStamp(startedAtHintS, wallNow);
1379
+ group.startedAtS = stamp.startedAtS;
1380
+ group.lastFrameS = stamp.startedAtS;
1381
+ group.frameClockAnchored = stamp.frameClockAnchored;
1382
+ for (GroupMemberAnimation &member : group.members) {
1383
+ member.animation.startedAtS = stamp.startedAtS;
1384
+ member.animation.lastFrameS = stamp.startedAtS;
1385
+ member.animation.frameClockAnchored = stamp.frameClockAnchored;
1386
+ }
1387
+ group.started = allGroupMembersReady(group);
1388
+ groupRegistry().emplace(groupId, std::move(group));
1389
+ if (reduce) {
1390
+ finishGroupImpl(groupId, true, true);
1391
+ return groupId;
1392
+ }
1393
+ ActiveGroup &stored = groupRegistry().at(groupId);
1394
+ if (stored.started) {
1395
+ animatingGroups().push_back(groupId);
1396
+ applyGroupCurrent(stored);
1397
+ scheduleFrame();
1398
+ }
1399
+ return groupId;
1400
+ }
1401
+
1402
+ } // namespace
1403
+
1404
+ int32_t animateTimingGroup(
1405
+ uint64_t controllerId,
1406
+ std::vector<GroupMotionEntry> entries,
1407
+ TimingAnimation animation,
1408
+ GroupSuspensionPolicy suspensionPolicy,
1409
+ double startedAtHintS) {
1410
+ return startGroupCommon(
1411
+ controllerId,
1412
+ std::move(entries),
1413
+ AnimationKind::Timing,
1414
+ animation,
1415
+ {},
1416
+ animation.durationMs,
1417
+ animation.reduceMotion,
1418
+ suspensionPolicy,
1419
+ startedAtHintS);
1420
+ }
1421
+
1422
+ int32_t animateSpringGroup(
1423
+ uint64_t controllerId,
1424
+ std::vector<GroupMotionEntry> entries,
1425
+ SpringAnimation animation,
1426
+ GroupSuspensionPolicy suspensionPolicy,
1427
+ double startedAtHintS) {
1428
+ return startGroupCommon(
1429
+ controllerId,
1430
+ std::move(entries),
1431
+ AnimationKind::Spring,
1432
+ {},
1433
+ animation,
1434
+ kSpringMaxDurationS * 1000.0,
1435
+ animation.reduceMotion,
1436
+ suspensionPolicy,
1437
+ startedAtHintS);
1438
+ }
1439
+
1440
+ int32_t animateKeyframesGroup(
1441
+ uint64_t controllerId,
1442
+ std::vector<GroupMotionEntry> entries,
1443
+ double durationMs,
1444
+ int32_t reduceMotion,
1445
+ GroupSuspensionPolicy suspensionPolicy,
1446
+ double startedAtHintS) {
1447
+ return startGroupCommon(
1448
+ controllerId,
1449
+ std::move(entries),
1450
+ AnimationKind::Keyframes,
1451
+ {},
1452
+ {},
1453
+ durationMs,
1454
+ reduceMotion,
1455
+ suspensionPolicy,
1456
+ startedAtHintS);
1457
+ }
1458
+
1459
+ std::vector<DriverSnapshot> cancelAnimationGroup(
1460
+ int32_t groupId,
1461
+ GroupCancelBehavior behavior) {
1462
+ if (!isOnMainThread()) return {};
1463
+ const bool finish = behavior == GroupCancelBehavior::Finish;
1464
+ return finishGroupImpl(
1465
+ groupId, finish, finish);
1466
+ }
1467
+
676
1468
  void setPresentation(
677
1469
  uint64_t driverId,
678
1470
  Presentation presentation,
@@ -692,6 +1484,10 @@ void setPresentation(
692
1484
  iterator = registry().try_emplace(driverId).first;
693
1485
  }
694
1486
  auto &state = iterator->second;
1487
+ if (state.groupId != 0) {
1488
+ if (!takeOwnership) return;
1489
+ finishGroupImpl(state.groupId, false, false);
1490
+ }
695
1491
  if (!takeOwnership && state.ownership != Ownership::Interactive) return;
696
1492
  if (takeOwnership) {
697
1493
  state.destroyed = false;
@@ -721,6 +1517,9 @@ Presentation beginInteraction(uint64_t driverId) {
721
1517
  auto iterator = registry().find(driverId);
722
1518
  if (iterator == registry().end()) return unavailablePresentation();
723
1519
  auto &state = iterator->second;
1520
+ if (state.groupId != 0) {
1521
+ finishGroupImpl(state.groupId, false, false);
1522
+ }
724
1523
  state.destroyed = false;
725
1524
  if (!state.animation.has_value()) {
726
1525
  state.ownership = Ownership::Interactive;
@@ -743,21 +1542,62 @@ int32_t animateTiming(
743
1542
  uint64_t driverId,
744
1543
  AnimationStart start,
745
1544
  Presentation presentation,
746
- TimingAnimation animation) {
747
- if (!isOnMainThread()) return 0;
1545
+ TimingAnimation animation,
1546
+ AnimationValidationMode validationMode) {
1547
+ const bool strictV2 =
1548
+ validationMode == AnimationValidationMode::ProtocolV2;
1549
+ const bool validAnimation = strictV2
1550
+ ? isValidProtocolV2Timing(animation)
1551
+ : std::isfinite(animation.durationMs) &&
1552
+ std::isfinite(animation.controlPoint1X) &&
1553
+ std::isfinite(animation.controlPoint1Y) &&
1554
+ std::isfinite(animation.controlPoint2X) &&
1555
+ std::isfinite(animation.controlPoint2Y) &&
1556
+ isValidReduceMotionCode(animation.reduceMotion);
1557
+ if (!strictV2 && validAnimation) {
1558
+ animation.durationMs = std::max(0.0, animation.durationMs);
1559
+ }
1560
+ if (!isOnMainThread() || driverId == 0 ||
1561
+ !isFiniteProtocolV2Presentation(presentation) || !validAnimation) {
1562
+ return 0;
1563
+ }
748
1564
  auto iterator = registry().find(driverId);
1565
+ Presentation preflightStart;
1566
+ if (iterator == registry().end()) {
1567
+ if (!start.hasInteractiveStart ||
1568
+ !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1569
+ return 0;
1570
+ }
1571
+ preflightStart = start.interactiveStart;
1572
+ } else {
1573
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1574
+ preflightStart = start.hasInteractiveStart
1575
+ ? start.interactiveStart
1576
+ : visiblePresentation(driverId, iterator->second);
1577
+ }
1578
+ if (!isFiniteProtocolV2Presentation(preflightStart) ||
1579
+ preflightStart.clip.curve != presentation.clip.curve) {
1580
+ return 0;
1581
+ }
1582
+ const bool reduce = shouldReduceMotion(animation.reduceMotion) ||
1583
+ animation.durationMs <= 0;
1584
+ if (strictV2 && !reduce && iterator != registry().end() &&
1585
+ !timingPlanPreservesLinearHostNormalization(
1586
+ iterator->second, preflightStart, presentation, animation)) {
1587
+ return 0;
1588
+ }
749
1589
  if (iterator == registry().end()) {
750
- if (!start.hasInteractiveStart) return 0;
751
1590
  iterator = registry().try_emplace(driverId).first;
752
1591
  iterator->second.latest = start.interactiveStart;
753
1592
  iterator->second.hasLatest = true;
754
1593
  }
755
1594
  auto &state = iterator->second;
1595
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
756
1596
  state.destroyed = false;
757
1597
  const int32_t animationId = allocateAnimationId();
758
1598
  const Presentation resolvedStart =
759
1599
  prepareAnimation(driverId, state, start, presentation);
760
- if (shouldReduceMotion(animation.reduceMotion) || animation.durationMs <= 0) {
1600
+ if (reduce) {
761
1601
  applyPresentation(state, presentation);
762
1602
  emitCompletion(driverId, animationId, true);
763
1603
  return animationId;
@@ -776,16 +1616,53 @@ int32_t animateSpring(
776
1616
  uint64_t driverId,
777
1617
  AnimationStart start,
778
1618
  Presentation presentation,
779
- SpringAnimation animation) {
780
- if (!isOnMainThread()) return 0;
1619
+ SpringAnimation animation,
1620
+ AnimationValidationMode validationMode) {
1621
+ const bool strictV2 =
1622
+ validationMode == AnimationValidationMode::ProtocolV2;
1623
+ const bool validAnimation = strictV2
1624
+ ? isValidProtocolV2Spring(animation)
1625
+ : std::isfinite(animation.mass) &&
1626
+ std::isfinite(animation.stiffness) &&
1627
+ std::isfinite(animation.damping) &&
1628
+ std::isfinite(animation.initialVelocity) &&
1629
+ isValidReduceMotionCode(animation.reduceMotion);
1630
+ if (!strictV2 && validAnimation) {
1631
+ animation.mass = std::max(0.0001, animation.mass);
1632
+ animation.stiffness = std::max(0.0001, animation.stiffness);
1633
+ animation.damping = std::max(0.0, animation.damping);
1634
+ }
1635
+ if (!isOnMainThread() || driverId == 0 ||
1636
+ !isFiniteProtocolV2Presentation(presentation) || !validAnimation) {
1637
+ return 0;
1638
+ }
781
1639
  auto iterator = registry().find(driverId);
1640
+ Presentation preflightStart;
1641
+ if (iterator == registry().end()) {
1642
+ if (!start.hasInteractiveStart ||
1643
+ !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1644
+ return 0;
1645
+ }
1646
+ preflightStart = start.interactiveStart;
1647
+ } else {
1648
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1649
+ preflightStart = start.hasInteractiveStart
1650
+ ? start.interactiveStart
1651
+ : visiblePresentation(driverId, iterator->second);
1652
+ }
1653
+ if (!isFiniteProtocolV2Presentation(preflightStart) ||
1654
+ preflightStart.clip.curve != presentation.clip.curve ||
1655
+ (strictV2 && !protocolV2SpringScaleIsProvablyPositive(
1656
+ preflightStart, presentation, animation))) {
1657
+ return 0;
1658
+ }
782
1659
  if (iterator == registry().end()) {
783
- if (!start.hasInteractiveStart) return 0;
784
1660
  iterator = registry().try_emplace(driverId).first;
785
1661
  iterator->second.latest = start.interactiveStart;
786
1662
  iterator->second.hasLatest = true;
787
1663
  }
788
1664
  auto &state = iterator->second;
1665
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
789
1666
  state.destroyed = false;
790
1667
  const int32_t animationId = allocateAnimationId();
791
1668
  // Scalar velocity along the current-to-target trajectory; each channel is
@@ -812,7 +1689,7 @@ int32_t animateSpring(
812
1689
  active.durationS = kSpringMaxDurationS;
813
1690
  const auto startChannels = toChannels(resolvedStart);
814
1691
  const auto targetChannels = toChannels(presentation);
815
- for (int index = 0; index < 7; index += 1) {
1692
+ for (std::size_t index = 0; index < kChannelCount; index += 1) {
816
1693
  active.springPosition[index] = startChannels[index];
817
1694
  active.springVelocity[index] =
818
1695
  velocity * (targetChannels[index] - startChannels[index]);
@@ -826,26 +1703,74 @@ int32_t animateKeyframes(
826
1703
  Presentation presentation,
827
1704
  double durationMs,
828
1705
  std::vector<Keyframe> keyframes,
829
- int32_t reduceMotion) {
830
- if (!isOnMainThread()) return 0;
1706
+ int32_t reduceMotion,
1707
+ AnimationValidationMode validationMode) {
1708
+ const bool strictV2 =
1709
+ validationMode == AnimationValidationMode::ProtocolV2;
1710
+ const bool finiteDuration = std::isfinite(durationMs);
1711
+ if (!strictV2 && finiteDuration) durationMs = std::max(0.0, durationMs);
1712
+ if (!isOnMainThread() || driverId == 0 ||
1713
+ !isFiniteProtocolV2Presentation(presentation) ||
1714
+ !finiteDuration || (strictV2 && durationMs < 0) ||
1715
+ !isValidReduceMotionCode(reduceMotion)) {
1716
+ return 0;
1717
+ }
831
1718
  auto iterator = registry().find(driverId);
1719
+ Presentation preflightStart;
1720
+ if (iterator == registry().end()) {
1721
+ if (!start.hasInteractiveStart ||
1722
+ !isFiniteProtocolV2Presentation(start.interactiveStart)) {
1723
+ return 0;
1724
+ }
1725
+ preflightStart = start.interactiveStart;
1726
+ } else {
1727
+ if (iterator->second.destroyed && !start.hasInteractiveStart) return 0;
1728
+ preflightStart = start.hasInteractiveStart
1729
+ ? start.interactiveStart
1730
+ : visiblePresentation(driverId, iterator->second);
1731
+ }
1732
+ bool validKeyframes = keyframes.size() >= 2 &&
1733
+ keyframes.front().offset == 0 && keyframes.back().offset == 1;
1734
+ double previousOffset = -1;
1735
+ for (const Keyframe &keyframe : keyframes) {
1736
+ validKeyframes = validKeyframes && std::isfinite(keyframe.offset) &&
1737
+ keyframe.offset > previousOffset && keyframe.offset >= 0 &&
1738
+ keyframe.offset <= 1 &&
1739
+ isFiniteProtocolV2Presentation(keyframe.presentation);
1740
+ previousOffset = keyframe.offset;
1741
+ }
1742
+ if (strictV2) {
1743
+ validKeyframes = isValidProtocolV2Keyframes(
1744
+ keyframes, preflightStart, presentation, start.hasInteractiveStart);
1745
+ }
1746
+ if (!isFiniteProtocolV2Presentation(preflightStart) || !validKeyframes) {
1747
+ return 0;
1748
+ }
1749
+ const bool reduce = shouldReduceMotion(reduceMotion) || durationMs <= 0;
1750
+ if (strictV2 && !reduce && iterator != registry().end() &&
1751
+ !keyframePlanPreservesLinearHostNormalization(
1752
+ iterator->second, preflightStart, keyframes)) {
1753
+ return 0;
1754
+ }
832
1755
  if (iterator == registry().end()) {
833
- if (!start.hasInteractiveStart) return 0;
834
1756
  iterator = registry().try_emplace(driverId).first;
835
1757
  iterator->second.latest = start.interactiveStart;
836
1758
  iterator->second.hasLatest = true;
837
1759
  }
838
1760
  auto &state = iterator->second;
1761
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
839
1762
  state.destroyed = false;
840
1763
  const int32_t animationId = allocateAnimationId();
841
1764
  const Presentation resolvedStart =
842
1765
  prepareAnimation(driverId, state, start, presentation);
843
- if (shouldReduceMotion(reduceMotion) || durationMs <= 0 ||
844
- keyframes.size() < 2) {
1766
+ if (reduce) {
845
1767
  applyPresentation(state, presentation);
846
1768
  emitCompletion(driverId, animationId, true);
847
1769
  return animationId;
848
1770
  }
1771
+ if (strictV2 && !start.hasInteractiveStart) {
1772
+ keyframes.front().presentation = resolvedStart;
1773
+ }
849
1774
  ActiveAnimation active;
850
1775
  active.id = animationId;
851
1776
  active.kind = AnimationKind::Keyframes;
@@ -875,6 +1800,18 @@ CancelResult cancelAnimation(
875
1800
  return {false, {{0, 0, 0, 0, 0}, 0, 0}};
876
1801
  }
877
1802
  auto &state = iterator->second;
1803
+ if (state.groupId != 0) {
1804
+ const int32_t activeGroupId = state.groupId;
1805
+ if (animationId > 0 && animationId != activeGroupId) {
1806
+ return {false, visiblePresentation(driverId, state)};
1807
+ }
1808
+ const std::vector<DriverSnapshot> snapshots =
1809
+ finishGroupImpl(activeGroupId, useTarget, false);
1810
+ for (const DriverSnapshot &snapshot : snapshots) {
1811
+ if (snapshot.driverId == driverId) return {true, snapshot.presentation};
1812
+ }
1813
+ return {false, unavailablePresentation()};
1814
+ }
878
1815
  if (!state.animation.has_value() ||
879
1816
  (animationId > 0 && animationId != state.animation->id)) {
880
1817
  return {false, state.animation.has_value() ? state.animation->current
@@ -897,6 +1834,7 @@ void destroyDriver(uint64_t driverId) {
897
1834
  auto iterator = registry().find(driverId);
898
1835
  if (iterator == registry().end()) return;
899
1836
  auto &state = iterator->second;
1837
+ if (state.groupId != 0) finishGroupImpl(state.groupId, false, false);
900
1838
  state.ownership = Ownership::Interactive;
901
1839
  finishActive(driverId, state, false);
902
1840
  // A destroyed driver's interaction history must not seed a revived
@@ -934,6 +1872,42 @@ void JSmoothClipView::applyClip(const Presentation &presentation) const {
934
1872
  presentation.contentTranslateY);
935
1873
  }
936
1874
 
1875
+ void JSmoothClipView::applyClipV2(const Presentation &presentation) const {
1876
+ static const auto method =
1877
+ javaClassStatic()
1878
+ ->getMethod<void(
1879
+ jdouble,
1880
+ jdouble,
1881
+ jdouble,
1882
+ jdouble,
1883
+ jdouble,
1884
+ jdouble,
1885
+ jdouble,
1886
+ jdouble,
1887
+ jint,
1888
+ jdouble,
1889
+ jdouble,
1890
+ jdouble)>("setClipPresentationV2Dip");
1891
+ method(
1892
+ self(),
1893
+ presentation.clip.x,
1894
+ presentation.clip.y,
1895
+ presentation.clip.width,
1896
+ presentation.clip.height,
1897
+ SmoothClipResolvedRadius(
1898
+ presentation.clip.topLeftRadius, presentation.clip.radius),
1899
+ SmoothClipResolvedRadius(
1900
+ presentation.clip.topRightRadius, presentation.clip.radius),
1901
+ SmoothClipResolvedRadius(
1902
+ presentation.clip.bottomRightRadius, presentation.clip.radius),
1903
+ SmoothClipResolvedRadius(
1904
+ presentation.clip.bottomLeftRadius, presentation.clip.radius),
1905
+ static_cast<jint>(presentation.clip.curve),
1906
+ presentation.contentTranslateX,
1907
+ presentation.contentTranslateY,
1908
+ presentation.contentScale);
1909
+ }
1910
+
937
1911
  void JSmoothClipView::applyClipPx(
938
1912
  const NormalizedClip &clip,
939
1913
  double contentTranslateXPx,
@@ -954,6 +1928,42 @@ void JSmoothClipView::applyClipPx(
954
1928
  static_cast<jfloat>(contentTranslateYPx));
955
1929
  }
956
1930
 
1931
+ void JSmoothClipView::applyClipV2Px(
1932
+ const NormalizedClip &clip,
1933
+ double contentTranslateXPx,
1934
+ double contentTranslateYPx,
1935
+ double contentScale) const {
1936
+ static const auto method =
1937
+ javaClassStatic()
1938
+ ->getMethod<void(
1939
+ jfloat,
1940
+ jfloat,
1941
+ jfloat,
1942
+ jfloat,
1943
+ jfloat,
1944
+ jfloat,
1945
+ jfloat,
1946
+ jfloat,
1947
+ jint,
1948
+ jfloat,
1949
+ jfloat,
1950
+ jfloat)>("setClipPresentationV2Px");
1951
+ method(
1952
+ self(),
1953
+ static_cast<jfloat>(clip.left),
1954
+ static_cast<jfloat>(clip.top),
1955
+ static_cast<jfloat>(clip.right),
1956
+ static_cast<jfloat>(clip.bottom),
1957
+ static_cast<jfloat>(clip.topLeftRadius),
1958
+ static_cast<jfloat>(clip.topRightRadius),
1959
+ static_cast<jfloat>(clip.bottomRightRadius),
1960
+ static_cast<jfloat>(clip.bottomLeftRadius),
1961
+ static_cast<jint>(clip.curve),
1962
+ static_cast<jfloat>(contentTranslateXPx),
1963
+ static_cast<jfloat>(contentTranslateYPx),
1964
+ static_cast<jfloat>(contentScale));
1965
+ }
1966
+
957
1967
  void registerViewAndroid(
958
1968
  uint64_t driverId,
959
1969
  alias_ref<JSmoothClipView> view,
@@ -968,9 +1978,7 @@ void registerViewAndroid(
968
1978
  state.latest = initialPresentation;
969
1979
  state.hasLatest = true;
970
1980
  }
971
- const Presentation visible = state.animation.has_value()
972
- ? state.animation->current
973
- : state.latest;
1981
+ const Presentation visible = visiblePresentation(driverId, state);
974
1982
  JNIEnv *env = facebook::jni::Environment::current();
975
1983
  for (auto &existing : state.views) {
976
1984
  if (env->IsSameObject(existing.view.get(), view.get())) {
@@ -980,6 +1988,7 @@ void registerViewAndroid(
980
1988
  existing.lifecycleVisible = lifecycleVisible;
981
1989
  deliverToView(existing, visible);
982
1990
  reconcileViewDisplayability(driverId, state, existing);
1991
+ reconcileGroupReadiness(driverId);
983
1992
  return;
984
1993
  }
985
1994
  }
@@ -995,6 +2004,7 @@ void registerViewAndroid(
995
2004
  deliverToView(entry, visible);
996
2005
  state.views.push_back(std::move(entry));
997
2006
  reconcileViewDisplayability(driverId, state, state.views.back());
2007
+ reconcileGroupReadiness(driverId);
998
2008
  }
999
2009
 
1000
2010
  void setViewHostGeometryAndroid(
@@ -1014,12 +2024,10 @@ void setViewHostGeometryAndroid(
1014
2024
  entry.hostWidthPx = hostWidthPx;
1015
2025
  entry.hostHeightPx = hostHeightPx;
1016
2026
  if (state.hasLatest) {
1017
- deliverToView(
1018
- entry,
1019
- state.animation.has_value() ? state.animation->current
1020
- : state.latest);
2027
+ deliverToView(entry, visiblePresentation(driverId, state));
1021
2028
  }
1022
2029
  reconcileViewDisplayability(driverId, state, entry);
2030
+ reconcileGroupReadiness(driverId);
1023
2031
  return;
1024
2032
  }
1025
2033
  }
@@ -1037,6 +2045,7 @@ void setViewLifecycleVisibilityAndroid(
1037
2045
  if (!env->IsSameObject(entry.view.get(), view.get())) continue;
1038
2046
  entry.lifecycleVisible = lifecycleVisible;
1039
2047
  reconcileViewDisplayability(driverId, state, entry);
2048
+ reconcileGroupReadiness(driverId);
1040
2049
  return;
1041
2050
  }
1042
2051
  }
@@ -1081,6 +2090,9 @@ void unregisterViewAndroid(
1081
2090
  relatchIfNoDisplayable(driverId, state);
1082
2091
  }
1083
2092
  }
2093
+ if (removed && state.groupId != 0 && isOnMainThread()) {
2094
+ reconcileGroupReadiness(driverId);
2095
+ }
1084
2096
  if (state.destroyed && state.views.empty()) {
1085
2097
  registry().erase(iterator);
1086
2098
  }