react-native-smooth-clip-view 0.4.5 → 0.5.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 (65) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +52 -0
  3. package/android/src/main/cpp/SmoothClipAndroid.h +22 -0
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +37 -3
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +85 -2
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +209 -10
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBackdropView.kt +59 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipBackdropViewManager.kt +63 -0
  9. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +15 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +316 -34
  11. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +19 -0
  12. package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +1 -1
  13. package/cpp/SmoothClipAnimationCurve.h +36 -11
  14. package/cpp/SmoothClipRegistry.h +11 -4
  15. package/ios/SmoothClipBackdropView.h +17 -0
  16. package/ios/SmoothClipBackdropView.mm +267 -0
  17. package/ios/SmoothClipGeometry.h +8 -7
  18. package/ios/SmoothClipRegistry.mm +192 -9
  19. package/ios/SmoothClipTurboModule.cpp +33 -11
  20. package/ios/SmoothClipView.mm +713 -22
  21. package/ios/SmoothClipViewRegistry.h +14 -0
  22. package/lib/module/SmoothClipBackdropView.js +40 -0
  23. package/lib/module/SmoothClipBackdropView.js.map +1 -0
  24. package/lib/module/SmoothClipBackdropViewNativeComponent.ts +15 -0
  25. package/lib/module/SmoothClipView.ios.js +17 -1
  26. package/lib/module/SmoothClipView.ios.js.map +1 -1
  27. package/lib/module/SmoothClipViewNativeComponent.ts +4 -0
  28. package/lib/module/geometry.js +44 -6
  29. package/lib/module/geometry.js.map +1 -1
  30. package/lib/module/groups.native.js +10 -3
  31. package/lib/module/groups.native.js.map +1 -1
  32. package/lib/module/index.js +1 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/presentationCodec.js +6 -2
  35. package/lib/module/presentationCodec.js.map +1 -1
  36. package/lib/typescript/src/SmoothClipBackdropView.d.ts +155 -0
  37. package/lib/typescript/src/SmoothClipBackdropView.d.ts.map +1 -0
  38. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts +8 -0
  39. package/lib/typescript/src/SmoothClipBackdropViewNativeComponent.d.ts.map +1 -0
  40. package/lib/typescript/src/SmoothClipView.android.d.ts +1 -1
  41. package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -1
  42. package/lib/typescript/src/SmoothClipView.d.ts +1 -1
  43. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  44. package/lib/typescript/src/SmoothClipView.ios.d.ts +15 -1
  45. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  46. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
  47. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  48. package/lib/typescript/src/geometry.d.ts +19 -0
  49. package/lib/typescript/src/geometry.d.ts.map +1 -1
  50. package/lib/typescript/src/groups.native.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +4 -2
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  54. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  55. package/package.json +4 -1
  56. package/src/SmoothClipBackdropView.tsx +53 -0
  57. package/src/SmoothClipBackdropViewNativeComponent.ts +15 -0
  58. package/src/SmoothClipView.android.tsx +4 -1
  59. package/src/SmoothClipView.ios.tsx +24 -1
  60. package/src/SmoothClipView.tsx +4 -1
  61. package/src/SmoothClipViewNativeComponent.ts +4 -0
  62. package/src/geometry.ts +59 -4
  63. package/src/groups.native.ts +9 -3
  64. package/src/index.ts +7 -1
  65. package/src/presentationCodec.ts +8 -2
@@ -136,6 +136,8 @@ static bool SmoothClipBuildPresentation(
136
136
  double shadowSpreadDistance,
137
137
  double rotation,
138
138
  double opacity,
139
+ double backdropTranslateX,
140
+ double backdropTranslateY,
139
141
  smoothclip::Presentation *result) {
140
142
  if (result == nullptr || !isfinite(x) || !isfinite(y) ||
141
143
  !isfinite(width) || !isfinite(height) ||
@@ -148,6 +150,7 @@ static bool SmoothClipBuildPresentation(
148
150
  !isfinite(shadowOffsetX) || !isfinite(shadowOffsetY) ||
149
151
  !isfinite(shadowBlurRadius) || !isfinite(shadowSpreadDistance) ||
150
152
  !isfinite(rotation) || !isfinite(opacity) ||
153
+ !isfinite(backdropTranslateX) || !isfinite(backdropTranslateY) ||
151
154
  contentScale <= 0 || shadowRed < 0 || shadowRed > 1 ||
152
155
  shadowGreen < 0 || shadowGreen > 1 ||
153
156
  shadowBlue < 0 || shadowBlue > 1 ||
@@ -179,7 +182,8 @@ static bool SmoothClipBuildPresentation(
179
182
  shadowSpreadDistance};
180
183
  *result = {
181
184
  geometry, contentTranslateX, contentTranslateY, contentScale, shadow,
182
- rotation, smoothclip::clamp01(opacity)};
185
+ rotation, smoothclip::clamp01(opacity), backdropTranslateX,
186
+ backdropTranslateY};
183
187
  return true;
184
188
  }
185
189
 
@@ -381,6 +385,314 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
381
385
  return presentation;
382
386
  }
383
387
 
388
+ // MARK: - Baked shadow tiles
389
+
390
+ // A rounded-rect shadow's pixels depend only on corner radius, blur, spread
391
+ // and colour, so one square tile holds every distinct pixel such a shadow can
392
+ // show: four corners and a band between them that the compositor stretches
393
+ // (`contentsCenter`) to any aperture, with no blur per frame. The margin on
394
+ // each side of the shape is 3σ (1.5 × the CSS blur radius): it holds the tail
395
+ // outside the shape and separates the band from the corners, so a stretched
396
+ // edge is a true straight-edge profile.
397
+ static const CGFloat kSmoothClipShadowTileBand = 2;
398
+ // Tile radii round up to this step, so a drag ramping the radius bakes a
399
+ // handful of tiles rather than one per frame, and a tile corner is never
400
+ // tighter than the clip corner over it (that would read as a dark sliver).
401
+ static const CGFloat kSmoothClipShadowTileRadiusStep = 4;
402
+
403
+ @interface SmoothClipShadowTile : NSObject
404
+ @property(nonatomic, readonly) CGImageRef image;
405
+ /** Shape corner radius the tile was baked for, in points. */
406
+ @property(nonatomic, readonly) CGFloat radius;
407
+ /** Tile border beyond the shape rect on each side, in points. */
408
+ @property(nonatomic, readonly) CGFloat margin;
409
+ @property(nonatomic, readonly) CGFloat side;
410
+ @property(nonatomic, readonly) CGFloat scale;
411
+ @property(nonatomic, readonly) CGRect contentsCenter;
412
+ @end
413
+
414
+ @implementation SmoothClipShadowTile
415
+ - (instancetype)initWithImage:(CGImageRef)image
416
+ radius:(CGFloat)radius
417
+ margin:(CGFloat)margin
418
+ side:(CGFloat)side
419
+ scale:(CGFloat)scale {
420
+ if (self = [super init]) {
421
+ _image = CGImageRetain(image);
422
+ _radius = radius;
423
+ _margin = margin;
424
+ _side = side;
425
+ _scale = scale;
426
+ const CGFloat corner = 2 * margin + radius;
427
+ _contentsCenter = CGRectMake(
428
+ corner / side, corner / side,
429
+ kSmoothClipShadowTileBand / side, kSmoothClipShadowTileBand / side);
430
+ }
431
+ return self;
432
+ }
433
+ - (void)dealloc {
434
+ CGImageRelease(_image);
435
+ }
436
+ @end
437
+
438
+ static CGFloat SmoothClipBakedShadowTileRadius(CGFloat radius) {
439
+ return ceil(MAX(0, radius) / kSmoothClipShadowTileRadiusStep) *
440
+ kSmoothClipShadowTileRadiusStep;
441
+ }
442
+
443
+ static CGFloat SmoothClipBakedShadowMargin(const smoothclip::Shadow &shadow) {
444
+ return ceil(1.5 * MAX(0, shadow.blurRadius));
445
+ }
446
+
447
+ // Baked once per (radius step, curve, blur, colour, scale) and cached for the
448
+ // process. Rendered through Core Animation's own shadow path so the tile is
449
+ // the pure blurred shape the compositor draws for a `shadowPath` layer, with
450
+ // nothing filled in on top of it; colour alpha is applied as layer opacity.
451
+ static NSCache<NSString *, SmoothClipShadowTile *> *SmoothClipShadowTileCache(void) {
452
+ static NSCache<NSString *, SmoothClipShadowTile *> *cache;
453
+ static dispatch_once_t once;
454
+ dispatch_once(&once, ^{
455
+ cache = [NSCache new];
456
+ cache.countLimit = 24;
457
+ });
458
+ return cache;
459
+ }
460
+
461
+ static NSString *SmoothClipShadowTileKey(
462
+ CGFloat tileRadius,
463
+ SmoothClipCornerCurve curve,
464
+ const smoothclip::Shadow &shadow,
465
+ CGFloat scale) {
466
+ return [NSString stringWithFormat:@"%g|%d|%g|%g|%g|%g|%g",
467
+ tileRadius, (int)curve, MAX(0, shadow.blurRadius),
468
+ smoothclip::clamp01(shadow.red), smoothclip::clamp01(shadow.green),
469
+ smoothclip::clamp01(shadow.blue), scale];
470
+ }
471
+
472
+ // The cached tile for this radius step, or nil. Never bakes.
473
+ static SmoothClipShadowTile *SmoothClipCachedShadowTile(
474
+ CGFloat tileRadius,
475
+ SmoothClipCornerCurve curve,
476
+ const smoothclip::Shadow &shadow,
477
+ CGFloat scale) {
478
+ return [SmoothClipShadowTileCache()
479
+ objectForKey:SmoothClipShadowTileKey(tileRadius, curve, shadow, scale)];
480
+ }
481
+
482
+ // Radius steps searched on either side of a missing tile for one to show
483
+ // while it bakes.
484
+ static const int kSmoothClipShadowTileSearchSteps = 16;
485
+
486
+ // The cached tile nearest to `tileRadius`: the exact one, else at each
487
+ // distance the rounder neighbour before the tighter one (a tile corner
488
+ // tighter than the clip corner over it reads as a dark sliver; a rounder one
489
+ // only recedes under the clip). Never bakes.
490
+ static SmoothClipShadowTile *SmoothClipNearestCachedShadowTile(
491
+ CGFloat tileRadius,
492
+ SmoothClipCornerCurve curve,
493
+ const smoothclip::Shadow &shadow,
494
+ CGFloat scale) {
495
+ for (int distance = 0; distance <= kSmoothClipShadowTileSearchSteps;
496
+ distance += 1) {
497
+ const CGFloat delta = distance * kSmoothClipShadowTileRadiusStep;
498
+ SmoothClipShadowTile *tile =
499
+ SmoothClipCachedShadowTile(tileRadius + delta, curve, shadow, scale);
500
+ if (tile != nil) return tile;
501
+ if (distance == 0 || tileRadius - delta < 0) continue;
502
+ tile = SmoothClipCachedShadowTile(tileRadius - delta, curve, shadow, scale);
503
+ if (tile != nil) return tile;
504
+ }
505
+ return nil;
506
+ }
507
+
508
+ // Bakes on the calling thread and caches the result: about a millisecond for
509
+ // a 16 pt blur at 3x, growing with blur² × scale². The callers reach for it
510
+ // only when nothing else can be shown (a cold cache, at mount) and for a
511
+ // run's resting target, which must be exact from the run's last frame on;
512
+ // every other tile bakes through SmoothClipRequestShadowTile, off the frame.
513
+ static NSUInteger gSmoothClipShadowTileBakeCount = 0;
514
+ static SmoothClipShadowTile *SmoothClipBakeShadowTile(
515
+ CGFloat tileRadius,
516
+ SmoothClipCornerCurve curve,
517
+ const smoothclip::Shadow &shadow,
518
+ CGFloat scale) {
519
+ NSString *key = SmoothClipShadowTileKey(tileRadius, curve, shadow, scale);
520
+ SmoothClipShadowTile *tile = [SmoothClipShadowTileCache() objectForKey:key];
521
+ if (tile != nil) return tile;
522
+ gSmoothClipShadowTileBakeCount += 1;
523
+ const CGFloat margin = SmoothClipBakedShadowMargin(shadow);
524
+
525
+ const CGFloat side = 2 * (2 * margin + tileRadius) + kSmoothClipShadowTileBand;
526
+ const size_t pixels = (size_t)MAX(1, ceil(side * scale));
527
+ CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB();
528
+ CGContextRef context = CGBitmapContextCreate(
529
+ NULL, pixels, pixels, 8, 0, space,
530
+ kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
531
+ CGColorSpaceRelease(space);
532
+ if (context == NULL) return nil;
533
+ CGContextScaleCTM(context, scale, scale);
534
+ CALayer *layer = [CALayer layer];
535
+ layer.frame = CGRectMake(0, 0, side, side);
536
+ const CGRect shape = CGRectInset(layer.bounds, margin, margin);
537
+ const SmoothClipCornerRadii radii = {
538
+ tileRadius, tileRadius, tileRadius, tileRadius};
539
+ CGPathRef path = SmoothClipCreateRoundedRectPath(shape, radii, curve);
540
+ layer.shadowPath = path;
541
+ CGPathRelease(path);
542
+ layer.shadowColor = [UIColor colorWithRed:smoothclip::clamp01(shadow.red)
543
+ green:smoothclip::clamp01(shadow.green)
544
+ blue:smoothclip::clamp01(shadow.blue)
545
+ alpha:1].CGColor;
546
+ layer.shadowOpacity = 1;
547
+ layer.shadowOffset = CGSizeZero;
548
+ layer.shadowRadius = MAX(0, shadow.blurRadius) / 2.0;
549
+ // The bitmap's y axis is flipped relative to the layer; the tile is
550
+ // symmetric (uniform radii, no offset), so that is harmless.
551
+ [layer renderInContext:context];
552
+ CGImageRef image = CGBitmapContextCreateImage(context);
553
+ CGContextRelease(context);
554
+ if (image == NULL) return nil;
555
+ tile = [[SmoothClipShadowTile alloc] initWithImage:image
556
+ radius:tileRadius
557
+ margin:margin
558
+ side:side
559
+ scale:scale];
560
+ CGImageRelease(image);
561
+ [SmoothClipShadowTileCache() setObject:tile forKey:key];
562
+ return tile;
563
+ }
564
+
565
+ typedef void (^SmoothClipShadowTileCompletion)(SmoothClipShadowTile *tile);
566
+
567
+ @interface SmoothClipShadowTileRequest : NSObject
568
+ @property(nonatomic) CGFloat tileRadius;
569
+ @property(nonatomic) SmoothClipCornerCurve curve;
570
+ @property(nonatomic) smoothclip::Shadow shadow;
571
+ @property(nonatomic) CGFloat scale;
572
+ @property(nonatomic, readonly)
573
+ NSMutableArray<SmoothClipShadowTileCompletion> *completions;
574
+ @end
575
+
576
+ @implementation SmoothClipShadowTileRequest
577
+ - (instancetype)init {
578
+ if (self = [super init]) {
579
+ _completions = [NSMutableArray array];
580
+ }
581
+ return self;
582
+ }
583
+ @end
584
+
585
+ // Tiles requested off the frame's critical path, in request order, one per
586
+ // main-queue drain: a block enqueued from inside a drain runs in the next
587
+ // one, so a burst of missing steps costs one bake per run-loop turn rather
588
+ // than all of them inside the transaction that asked for them.
589
+ static NSMutableArray<SmoothClipShadowTileRequest *> *SmoothClipPendingShadowTiles(void) {
590
+ static NSMutableArray<SmoothClipShadowTileRequest *> *pending;
591
+ static dispatch_once_t once;
592
+ dispatch_once(&once, ^{
593
+ pending = [NSMutableArray array];
594
+ });
595
+ return pending;
596
+ }
597
+
598
+ static NSMutableDictionary<NSString *, SmoothClipShadowTileRequest *> *
599
+ SmoothClipPendingShadowTilesByKey(void) {
600
+ static NSMutableDictionary<NSString *, SmoothClipShadowTileRequest *> *pending;
601
+ static dispatch_once_t once;
602
+ dispatch_once(&once, ^{
603
+ pending = [NSMutableDictionary dictionary];
604
+ });
605
+ return pending;
606
+ }
607
+
608
+ static BOOL gSmoothClipShadowTileBakeScheduled = NO;
609
+ static void SmoothClipScheduleShadowTileBake(void);
610
+
611
+ static void SmoothClipBakeNextShadowTile(void) {
612
+ gSmoothClipShadowTileBakeScheduled = NO;
613
+ NSMutableArray<SmoothClipShadowTileRequest *> *pending =
614
+ SmoothClipPendingShadowTiles();
615
+ SmoothClipShadowTileRequest *request = pending.firstObject;
616
+ if (request == nil) return;
617
+ [pending removeObjectAtIndex:0];
618
+ [SmoothClipPendingShadowTilesByKey() removeObjectForKey:SmoothClipShadowTileKey(
619
+ request.tileRadius, request.curve, request.shadow, request.scale)];
620
+ SmoothClipShadowTile *tile = SmoothClipBakeShadowTile(
621
+ request.tileRadius, request.curve, request.shadow, request.scale);
622
+ for (SmoothClipShadowTileCompletion completion in request.completions) {
623
+ completion(tile);
624
+ }
625
+ if (pending.count > 0) SmoothClipScheduleShadowTileBake();
626
+ }
627
+
628
+ static void SmoothClipScheduleShadowTileBake(void) {
629
+ if (gSmoothClipShadowTileBakeScheduled) return;
630
+ gSmoothClipShadowTileBakeScheduled = YES;
631
+ dispatch_async(dispatch_get_main_queue(), ^{
632
+ SmoothClipBakeNextShadowTile();
633
+ });
634
+ }
635
+
636
+ // Asks for the tile without baking it now. `completion` runs on the main
637
+ // thread once the tile is cached, at once when it already is.
638
+ static void SmoothClipRequestShadowTile(
639
+ CGFloat tileRadius,
640
+ SmoothClipCornerCurve curve,
641
+ const smoothclip::Shadow &shadow,
642
+ CGFloat scale,
643
+ SmoothClipShadowTileCompletion completion) {
644
+ SmoothClipShadowTile *cached =
645
+ SmoothClipCachedShadowTile(tileRadius, curve, shadow, scale);
646
+ if (cached != nil) {
647
+ if (completion != nil) completion(cached);
648
+ return;
649
+ }
650
+ NSString *key = SmoothClipShadowTileKey(tileRadius, curve, shadow, scale);
651
+ NSMutableDictionary<NSString *, SmoothClipShadowTileRequest *> *byKey =
652
+ SmoothClipPendingShadowTilesByKey();
653
+ SmoothClipShadowTileRequest *request = byKey[key];
654
+ if (request == nil) {
655
+ request = [SmoothClipShadowTileRequest new];
656
+ request.tileRadius = tileRadius;
657
+ request.curve = curve;
658
+ request.shadow = shadow;
659
+ request.scale = scale;
660
+ byKey[key] = request;
661
+ [SmoothClipPendingShadowTiles() addObject:request];
662
+ }
663
+ if (completion != nil) [request.completions addObject:[completion copy]];
664
+ SmoothClipScheduleShadowTileBake();
665
+ }
666
+
667
+ NSUInteger SmoothClipShadowTileBakeCountForTesting(void) {
668
+ return gSmoothClipShadowTileBakeCount;
669
+ }
670
+
671
+ NSUInteger SmoothClipPendingShadowTileCountForTesting(void) {
672
+ return SmoothClipPendingShadowTiles().count;
673
+ }
674
+
675
+ void SmoothClipBakePendingShadowTilesForTesting(void) {
676
+ while (SmoothClipPendingShadowTiles().count > 0) {
677
+ SmoothClipBakeNextShadowTile();
678
+ }
679
+ }
680
+
681
+ // The shadow layer's frame for a baked tile: the shape rect (aperture grown by
682
+ // the spread, then offset) plus the tile margin on every side.
683
+ static CGRect SmoothClipBakedShadowLayerRect(
684
+ SmoothClipCanonicalGeometry geometry,
685
+ const smoothclip::Shadow &shadow,
686
+ CGFloat margin) {
687
+ const CGFloat spread = shadow.spreadDistance;
688
+ CGRect rect = CGRectInset(geometry.rect, -spread, -spread);
689
+ if (CGRectGetWidth(rect) <= 0 || CGRectGetHeight(rect) <= 0) {
690
+ return CGRectZero;
691
+ }
692
+ rect = CGRectOffset(rect, shadow.offsetX, shadow.offsetY);
693
+ return CGRectInset(rect, -margin, -margin);
694
+ }
695
+
384
696
  @implementation SmoothClipView {
385
697
  CALayer *_shadowLayer;
386
698
  SmoothClipPresentationContainerView *_presentationContainer;
@@ -395,6 +707,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
395
707
  BOOL _hasShadowModel;
396
708
  SmoothClipCanonicalGeometry _shadowModelGeometry;
397
709
  smoothclip::Shadow _shadowModel;
710
+ // `shadowRendering="baked"`: stretch one pre-blurred tile instead of
711
+ // blurring a path every frame. See SmoothClipBakedShadowTile.
712
+ BOOL _bakedShadows;
398
713
 
399
714
  SmoothClipContainerView *_clipContainer;
400
715
  SmoothClipContainerView *_contentContainer;
@@ -485,6 +800,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
485
800
 
486
801
  _shadowLayer = nil;
487
802
  // Created lazily so the common clipping-only path owns no shadow layer.
803
+ _bakedShadows = NO;
488
804
 
489
805
  _unequalCornerMask = [CAShapeLayer layer];
490
806
  _unequalCornerMask.actions = @{
@@ -600,31 +916,175 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
600
916
  @"shadowOffset": [NSNull null],
601
917
  @"bounds": [NSNull null],
602
918
  @"position": [NSNull null],
919
+ @"opacity": [NSNull null],
920
+ @"contents": [NSNull null],
921
+ @"contentsCenter": [NSNull null],
922
+ @"contentsScale": [NSNull null],
603
923
  };
604
924
  [_presentationContainer.layer insertSublayer:_shadowLayer below:_clipContainer.layer];
605
925
  return _shadowLayer;
606
926
  }
607
927
 
928
+ - (CGFloat)bakedShadowScale {
929
+ const CGFloat scale = self.traitCollection.displayScale;
930
+ return scale > 0 ? scale : UIScreen.mainScreen.scale;
931
+ }
932
+
933
+ // Radius step of the tile a geometry's shadow draws from.
934
+ - (CGFloat)bakedShadowTileRadiusForGeometry:(SmoothClipCanonicalGeometry)geometry
935
+ shadow:(const smoothclip::Shadow &)shadow {
936
+ return SmoothClipBakedShadowTileRadius(SmoothClipAdjustedShadowRadius(
937
+ geometry.radii.topLeft, shadow.spreadDistance));
938
+ }
939
+
940
+ - (BOOL)usesBakedShadowForGeometry:(SmoothClipCanonicalGeometry)geometry
941
+ shadow:(const smoothclip::Shadow &)shadow {
942
+ // Unequal radii have no single tile; they keep the blur path. So does a
943
+ // shape too small for two corner slices on a side, 2 x (margin + radius):
944
+ // the compositor would squash the slices rather than overlap the blur
945
+ // tails the way the true shadow does.
946
+ if (!_bakedShadows || !SmoothClipCornerRadiiAreUniform(geometry.radii)) {
947
+ return NO;
948
+ }
949
+ const CGFloat minimumSide = 2 *
950
+ (SmoothClipBakedShadowMargin(shadow) +
951
+ [self bakedShadowTileRadiusForGeometry:geometry shadow:shadow]);
952
+ const CGFloat spread = 2 * shadow.spreadDistance;
953
+ return CGRectGetWidth(geometry.rect) + spread >= minimumSide &&
954
+ CGRectGetHeight(geometry.rect) + spread >= minimumSide;
955
+ }
956
+
957
+ // A requested tile is cached: draw it, if the model still wants it and
958
+ // nothing else owns the layer (a run in flight swaps tiles from its own
959
+ // keyframes, and its completion writes the model again).
960
+ - (void)shadowTileDidLand:(SmoothClipShadowTile *)tile {
961
+ if (tile == nil || _activeAnimationId != 0 || !_hasShadowModel ||
962
+ _shadowLayer == nil ||
963
+ ![self usesBakedShadowForGeometry:_shadowModelGeometry
964
+ shadow:_shadowModel]) {
965
+ return;
966
+ }
967
+ const CGFloat wanted = [self bakedShadowTileRadiusForGeometry:_shadowModelGeometry
968
+ shadow:_shadowModel];
969
+ if (SmoothClipCachedShadowTile(wanted, _shadowModelGeometry.curve,
970
+ _shadowModel, [self bakedShadowScale]) != tile ||
971
+ _shadowLayer.contents == (__bridge id)tile.image) {
972
+ return;
973
+ }
974
+ [CATransaction begin];
975
+ [CATransaction setDisableActions:YES];
976
+ [self writeBakedShadowModel:_shadowModel
977
+ geometry:_shadowModelGeometry
978
+ visible:SmoothBoxShadowVisible(_shadowModelGeometry, _shadowModel)
979
+ nearestTile:NO
980
+ layer:_shadowLayer];
981
+ [CATransaction commit];
982
+ }
983
+
984
+ // `allowNearest`: a missing tile may be stood in for by the nearest cached
985
+ // step until the exact one lands (a drag crossing a step, a model write); a
986
+ // run's resting target passes NO and bakes now, so its last frame is exact.
987
+ - (void)writeBakedShadowModel:(const smoothclip::Shadow &)shadow
988
+ geometry:(SmoothClipCanonicalGeometry)geometry
989
+ visible:(BOOL)visible
990
+ nearestTile:(BOOL)allowNearest
991
+ layer:(CALayer *)shadowLayer {
992
+ SmoothClipShadowTile *tile = nil;
993
+ if (visible) {
994
+ const CGFloat tileRadius =
995
+ [self bakedShadowTileRadiusForGeometry:geometry shadow:shadow];
996
+ const CGFloat scale = [self bakedShadowScale];
997
+ tile = SmoothClipCachedShadowTile(tileRadius, geometry.curve, shadow, scale);
998
+ if (tile == nil && allowNearest) {
999
+ tile = SmoothClipNearestCachedShadowTile(
1000
+ tileRadius, geometry.curve, shadow, scale);
1001
+ if (tile != nil) {
1002
+ // A neighbouring step for now (one step off is invisible under the
1003
+ // blur the tile was made for); the exact tile lands off this frame.
1004
+ __weak SmoothClipView *weakSelf = self;
1005
+ SmoothClipRequestShadowTile(
1006
+ tileRadius, geometry.curve, shadow, scale,
1007
+ ^(SmoothClipShadowTile *exact) {
1008
+ [weakSelf shadowTileDidLand:exact];
1009
+ });
1010
+ }
1011
+ }
1012
+ if (tile == nil) {
1013
+ // Nothing near enough to show meanwhile (a cold cache, at mount), or a
1014
+ // resting target that must be exact.
1015
+ tile = SmoothClipBakeShadowTile(tileRadius, geometry.curve, shadow, scale);
1016
+ }
1017
+ }
1018
+ // Leave no blur-path state behind: a tile layer that kept a shadowPath
1019
+ // would draw both.
1020
+ if (shadowLayer.shadowPath != NULL) shadowLayer.shadowPath = NULL;
1021
+ shadowLayer.shadowOpacity = 0;
1022
+ if (tile == nil) {
1023
+ shadowLayer.opacity = 0;
1024
+ return;
1025
+ }
1026
+ if (shadowLayer.contents != (__bridge id)tile.image) {
1027
+ shadowLayer.contents = (__bridge id)tile.image;
1028
+ shadowLayer.contentsCenter = tile.contentsCenter;
1029
+ shadowLayer.contentsScale = tile.scale;
1030
+ shadowLayer.contentsGravity = kCAGravityResize;
1031
+ }
1032
+ const CGRect rect =
1033
+ SmoothClipBakedShadowLayerRect(geometry, shadow, tile.margin);
1034
+ shadowLayer.bounds = CGRectMake(0, 0, rect.size.width, rect.size.height);
1035
+ shadowLayer.position =
1036
+ CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect));
1037
+ shadowLayer.opacity = smoothclip::clamp01(shadow.alpha);
1038
+ }
1039
+
608
1040
  - (void)writeShadowModel:(const smoothclip::Shadow &)shadow
609
1041
  geometry:(SmoothClipCanonicalGeometry)geometry {
1042
+ [self writeShadowModel:shadow
1043
+ geometry:geometry
1044
+ baked:[self usesBakedShadowForGeometry:geometry shadow:shadow]
1045
+ nearestTile:YES];
1046
+ }
1047
+
1048
+ - (void)writeShadowModel:(const smoothclip::Shadow &)shadow
1049
+ geometry:(SmoothClipCanonicalGeometry)geometry
1050
+ baked:(BOOL)baked
1051
+ nearestTile:(BOOL)allowNearest {
610
1052
  const smoothclip::Shadow effectiveShadow =
611
1053
  SmoothBoxShadowWithEffectiveAlpha(geometry, shadow);
612
1054
  const BOOL visible = SmoothBoxShadowVisible(geometry, effectiveShadow);
613
1055
  if (_shadowLayer == nil && !visible) return;
614
1056
 
615
1057
  CALayer *shadowLayer = visible ? [self ensureShadowLayer] : _shadowLayer;
1058
+ const BOOL pathInputChanged = !_hasShadowModel ||
1059
+ !SmoothBoxShadowPathInputEqual(
1060
+ _shadowModelGeometry, _shadowModel, geometry, effectiveShadow);
1061
+ _hasShadowModel = YES;
1062
+ _shadowModelGeometry = geometry;
1063
+ _shadowModel = effectiveShadow;
1064
+ if (baked) {
1065
+ [self writeBakedShadowModel:effectiveShadow
1066
+ geometry:geometry
1067
+ visible:visible
1068
+ nearestTile:allowNearest
1069
+ layer:shadowLayer];
1070
+ return;
1071
+ }
1072
+ BOOL leftTile = NO;
1073
+ if (shadowLayer.contents != nil) {
1074
+ // Back from a baked tile: the blur path draws at layer opacity 1 with
1075
+ // the alpha in shadowColor, and needs its path again.
1076
+ shadowLayer.contents = nil;
1077
+ shadowLayer.opacity = 1;
1078
+ leftTile = YES;
1079
+ }
616
1080
  shadowLayer.bounds = self.bounds;
617
1081
  shadowLayer.position = CGPointMake(
618
1082
  CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
619
- if (!_hasShadowModel || !SmoothBoxShadowPathInputEqual(
620
- _shadowModelGeometry, _shadowModel, geometry, effectiveShadow)) {
1083
+ if (pathInputChanged || leftTile) {
621
1084
  CGPathRef path = SmoothClipCreateShadowPath(geometry, effectiveShadow);
622
1085
  shadowLayer.shadowPath = path;
623
1086
  CGPathRelease(path);
624
1087
  }
625
- _hasShadowModel = YES;
626
- _shadowModelGeometry = geometry;
627
- _shadowModel = effectiveShadow;
628
1088
  shadowLayer.shadowColor = [self colorForShadow:effectiveShadow];
629
1089
  shadowLayer.shadowOpacity = visible ? 1 : 0;
630
1090
  shadowLayer.shadowRadius = MAX(0, effectiveShadow.blurRadius) / 2.0;
@@ -632,6 +1092,178 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
632
1092
  effectiveShadow.offsetX, effectiveShadow.offsetY);
633
1093
  }
634
1094
 
1095
+ // Animations for a baked shadow run. Bounds, position and opacity
1096
+ // interpolate; the tile (radius step, blur, colour) is categorical, so it steps
1097
+ // at the key times where the run's progress crosses each radius step, with the
1098
+ // target's blur and colour from the first swap on. Steps are one radius step
1099
+ // apart, invisible under any blur the tile was made for.
1100
+ - (NSArray<CAAnimation *> *)bakedShadowAnimationsFromGeometry:
1101
+ (SmoothClipCanonicalGeometry)fromGeometry
1102
+ fromShadow:(smoothclip::Shadow)fromShadow
1103
+ toGeometry:(SmoothClipCanonicalGeometry)toGeometry
1104
+ toShadow:(smoothclip::Shadow)toShadow
1105
+ timing:(CAMediaTimingFunction *)timing
1106
+ spring:(BOOL)spring
1107
+ velocity:(double)velocity
1108
+ duration:(CFTimeInterval)duration {
1109
+ const CGFloat scale = [self bakedShadowScale];
1110
+ const CGFloat fromRadius =
1111
+ [self bakedShadowTileRadiusForGeometry:fromGeometry shadow:fromShadow];
1112
+ const CGFloat toRadius =
1113
+ [self bakedShadowTileRadiusForGeometry:toGeometry shadow:toShadow];
1114
+ // The start is whatever the model shows (its step or a stand-in); the
1115
+ // target was baked by the model write just before this and must be exact.
1116
+ SmoothClipShadowTile *fromTile = SmoothClipNearestCachedShadowTile(
1117
+ fromRadius, fromGeometry.curve, fromShadow, scale);
1118
+ if (fromTile == nil) {
1119
+ fromTile = SmoothClipBakeShadowTile(
1120
+ fromRadius, fromGeometry.curve, fromShadow, scale);
1121
+ }
1122
+ SmoothClipShadowTile *toTile = SmoothClipBakeShadowTile(
1123
+ toRadius, toGeometry.curve, toShadow, scale);
1124
+ const CGFloat fromMargin = fromTile != nil
1125
+ ? fromTile.margin : SmoothClipBakedShadowMargin(fromShadow);
1126
+ const CGFloat toMargin = toTile != nil
1127
+ ? toTile.margin : SmoothClipBakedShadowMargin(toShadow);
1128
+ const CGRect fromRect =
1129
+ SmoothClipBakedShadowLayerRect(fromGeometry, fromShadow, fromMargin);
1130
+ const CGRect toRect =
1131
+ SmoothClipBakedShadowLayerRect(toGeometry, toShadow, toMargin);
1132
+ NSMutableArray<CAAnimation *> *animations = [NSMutableArray array];
1133
+ const auto add = [&](NSString *keyPath, id from, id to) {
1134
+ [animations addObject:spring
1135
+ ? [self springAnimationForKeyPath:keyPath fromValue:from toValue:to
1136
+ velocity:velocity duration:duration]
1137
+ : [self basicAnimationForKeyPath:keyPath fromValue:from toValue:to
1138
+ timingFunction:timing]];
1139
+ };
1140
+ if (!CGSizeEqualToSize(fromRect.size, toRect.size)) {
1141
+ add(@"bounds",
1142
+ [NSValue valueWithCGRect:CGRectMake(
1143
+ 0, 0, fromRect.size.width, fromRect.size.height)],
1144
+ [NSValue valueWithCGRect:CGRectMake(
1145
+ 0, 0, toRect.size.width, toRect.size.height)]);
1146
+ }
1147
+ const CGPoint fromCenter =
1148
+ CGPointMake(CGRectGetMidX(fromRect), CGRectGetMidY(fromRect));
1149
+ const CGPoint toCenter =
1150
+ CGPointMake(CGRectGetMidX(toRect), CGRectGetMidY(toRect));
1151
+ if (!CGPointEqualToPoint(fromCenter, toCenter)) {
1152
+ add(@"position",
1153
+ [NSValue valueWithCGPoint:fromCenter],
1154
+ [NSValue valueWithCGPoint:toCenter]);
1155
+ }
1156
+ if (fromShadow.alpha != toShadow.alpha) {
1157
+ add(@"opacity", @(smoothclip::clamp01(fromShadow.alpha)),
1158
+ @(smoothclip::clamp01(toShadow.alpha)));
1159
+ }
1160
+ if (fromTile == nil || toTile == nil || fromTile == toTile) {
1161
+ return animations;
1162
+ }
1163
+
1164
+ NSMutableArray *images = [NSMutableArray arrayWithObject:(__bridge id)fromTile.image];
1165
+ NSMutableArray<NSValue *> *centers =
1166
+ [NSMutableArray arrayWithObject:[NSValue valueWithCGRect:fromTile.contentsCenter]];
1167
+ NSMutableArray<NSNumber *> *keyTimes = [NSMutableArray arrayWithObject:@0];
1168
+ const CGFloat rawFrom = SmoothClipAdjustedShadowRadius(
1169
+ fromGeometry.radii.topLeft, fromShadow.spreadDistance);
1170
+ const CGFloat rawTo = SmoothClipAdjustedShadowRadius(
1171
+ toGeometry.radii.topLeft, toShadow.spreadDistance);
1172
+ // Normalised time at which the run's progress first reaches `target`.
1173
+ const auto timeForProgress = [&](double target) -> double {
1174
+ if (target <= 0) return 0;
1175
+ if (target >= 1) return 1;
1176
+ constexpr double step = 1.0 / 240.0;
1177
+ if (spring) {
1178
+ smoothclip::ScalarSpringState state{0, velocity};
1179
+ double elapsed = 0;
1180
+ while (elapsed < duration) {
1181
+ state = smoothclip::advanceScalarSpring(state, _springAnimation, step);
1182
+ elapsed += step;
1183
+ if (state.position >= target) break;
1184
+ }
1185
+ return duration > 0 ? std::min(1.0, elapsed / duration) : 1.0;
1186
+ }
1187
+ double low = 0, high = 1;
1188
+ for (int iteration = 0; iteration < 20; iteration += 1) {
1189
+ const double middle = (low + high) / 2;
1190
+ const double progress = smoothclip::cubicBezier(
1191
+ _timingAnimation.controlPoint1X, _timingAnimation.controlPoint1Y,
1192
+ _timingAnimation.controlPoint2X, _timingAnimation.controlPoint2Y,
1193
+ middle);
1194
+ if (progress < target) low = middle; else high = middle;
1195
+ }
1196
+ return (low + high) / 2;
1197
+ };
1198
+ if (fromTile.radius == toTile.radius || rawTo == rawFrom) {
1199
+ // Same radius step, a different blur or colour: one swap, mid-run.
1200
+ [images addObject:(__bridge id)toTile.image];
1201
+ [centers addObject:[NSValue valueWithCGRect:toTile.contentsCenter]];
1202
+ [keyTimes addObject:@0.5];
1203
+ } else {
1204
+ const BOOL growing = toTile.radius > fromTile.radius;
1205
+ const CGFloat step = kSmoothClipShadowTileRadiusStep * (growing ? 1 : -1);
1206
+ // Key time a missing step hands to the next cached tile on a growing run.
1207
+ double carriedTime = -1;
1208
+ for (CGFloat tileRadius = fromTile.radius + step;
1209
+ growing ? tileRadius <= toTile.radius : tileRadius >= toTile.radius;
1210
+ tileRadius += step) {
1211
+ // Rounding up: a growing radius takes tile T once it passes T - step,
1212
+ // a shrinking one once it reaches T.
1213
+ const CGFloat threshold = growing
1214
+ ? tileRadius - kSmoothClipShadowTileRadiusStep : tileRadius;
1215
+ const double progress = (threshold - rawFrom) / (rawTo - rawFrom);
1216
+ SmoothClipShadowTile *tile = SmoothClipCachedShadowTile(
1217
+ tileRadius, toGeometry.curve, toShadow, scale);
1218
+ if (tile == nil) {
1219
+ // Not baked inside this transaction: it lands for the next run. A
1220
+ // growing run swaps to the next cached (rounder) tile at this step's
1221
+ // time instead, so the shown corner is never tighter than the clip's;
1222
+ // a shrinking run simply keeps the rounder tile a step longer.
1223
+ SmoothClipRequestShadowTile(
1224
+ tileRadius, toGeometry.curve, toShadow, scale, nil);
1225
+ if (growing && carriedTime < 0) carriedTime = timeForProgress(progress);
1226
+ continue;
1227
+ }
1228
+ const double ownTime = timeForProgress(progress);
1229
+ const double time = std::max(
1230
+ keyTimes.lastObject.doubleValue,
1231
+ carriedTime >= 0 ? std::min(carriedTime, ownTime) : ownTime);
1232
+ carriedTime = -1;
1233
+ if (time <= keyTimes.lastObject.doubleValue + 1e-6) {
1234
+ // Same instant as the previous swap (leaving a step value at t = 0):
1235
+ // the later tile is the one shown; key times must strictly increase.
1236
+ images[images.count - 1] = (__bridge id)tile.image;
1237
+ centers[centers.count - 1] = [NSValue valueWithCGRect:tile.contentsCenter];
1238
+ continue;
1239
+ }
1240
+ [images addObject:(__bridge id)tile.image];
1241
+ [centers addObject:[NSValue valueWithCGRect:tile.contentsCenter]];
1242
+ [keyTimes addObject:@(time)];
1243
+ }
1244
+ }
1245
+ if (images.count < 2) return animations;
1246
+ // Discrete key times bracket their values: one more entry than values, the
1247
+ // last at 1 (CAKeyframeAnimation.keyTimes). Without the closing entry Core
1248
+ // Animation ignores the array and spaces the swaps evenly over the run.
1249
+ [keyTimes addObject:@1];
1250
+ CAKeyframeAnimation *contents =
1251
+ [CAKeyframeAnimation animationWithKeyPath:@"contents"];
1252
+ contents.values = images;
1253
+ contents.keyTimes = keyTimes;
1254
+ contents.calculationMode = kCAAnimationDiscrete;
1255
+ contents.duration = duration;
1256
+ [animations addObject:contents];
1257
+ CAKeyframeAnimation *contentsCenter =
1258
+ [CAKeyframeAnimation animationWithKeyPath:@"contentsCenter"];
1259
+ contentsCenter.values = centers;
1260
+ contentsCenter.keyTimes = keyTimes;
1261
+ contentsCenter.calculationMode = kCAAnimationDiscrete;
1262
+ contentsCenter.duration = duration;
1263
+ [animations addObject:contentsCenter];
1264
+ return animations;
1265
+ }
1266
+
635
1267
  - (void)writeShadow:(const smoothclip::Shadow &)shadow
636
1268
  geometry:(SmoothClipCanonicalGeometry)geometry {
637
1269
  _canonicalShadow = shadow;
@@ -964,6 +1596,15 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
964
1596
  if (shadowLayer == nil) return SmoothClipCanonicalSnapshot({
965
1597
  geometry, transform.tx, transform.ty, contentScale, shadow,
966
1598
  [self visibleRotation], [self visibleOpacity]});
1599
+ if (shadowLayer.contents != nil) {
1600
+ // A baked tile: colour, blur, offset and spread never move on the layer
1601
+ // (a run swaps tiles); the layer's opacity carries the alpha.
1602
+ shadow.enabled = _canonicalShadow.enabled || shadowLayer.opacity > 0;
1603
+ shadow.alpha = shadowLayer.opacity;
1604
+ return SmoothClipCanonicalSnapshot({
1605
+ geometry, transform.tx, transform.ty, contentScale, shadow,
1606
+ [self visibleRotation], [self visibleOpacity]});
1607
+ }
967
1608
  shadow.enabled = _canonicalShadow.enabled || shadowLayer.shadowOpacity > 0;
968
1609
  shadow.blurRadius = shadowLayer.shadowRadius * 2.0;
969
1610
  shadow.offsetX = shadowLayer.shadowOffset.width;
@@ -1087,16 +1728,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1087
1728
  // Every key path shares one normalized trajectory. Derive the terminal
1088
1729
  // duration from the same relative-energy rule as Android/Reanimated instead
1089
1730
  // of asking each Core Animation property for an independent settle time.
1090
- smoothclip::ScalarSpringState state{0, velocity};
1091
- constexpr double step = 1.0 / 120.0;
1092
- double elapsed = 0;
1093
- while (elapsed < 10.0 &&
1094
- smoothclip::relativeSpringEnergy(state, _springAnimation) >
1095
- _springAnimation.energyThreshold) {
1096
- state = smoothclip::advanceScalarSpring(state, _springAnimation, step);
1097
- elapsed += step;
1098
- }
1099
- return elapsed;
1731
+ return smoothclip::springSettleDuration(_springAnimation, velocity);
1100
1732
  }
1101
1733
 
1102
1734
  - (CASpringAnimation *)springAnimationForKeyPath:(NSString *)keyPath
@@ -1174,11 +1806,22 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1174
1806
  _canonicalShadow = toShadow;
1175
1807
  SmoothBoxShadowNormalizeAnimationEndpoints(
1176
1808
  fromGeometry, &fromShadow, toGeometry, &toShadow);
1809
+ // A baked run animates the tile layer's frame and opacity and swaps tiles
1810
+ // in steps (bakedShadowAnimationsFromGeometry:...). One non-uniform
1811
+ // endpoint has no tile, so such a run takes the blur path throughout.
1812
+ const BOOL bakedShadow =
1813
+ (SmoothBoxShadowVisible(fromGeometry, fromShadow) ||
1814
+ SmoothBoxShadowVisible(toGeometry, toShadow)) &&
1815
+ [self usesBakedShadowForGeometry:fromGeometry shadow:fromShadow] &&
1816
+ [self usesBakedShadowForGeometry:toGeometry shadow:toShadow];
1177
1817
  // The target model layer is complete before any presentation animations are
1178
1818
  // attached. Core Animation may remove the terminal presentation animation
1179
1819
  // before its delegate runs, so correctness cannot depend on completion-time
1180
1820
  // cleanup hiding a stale path or opaque color.
1181
- [self writeShadowModel:toShadow geometry:toGeometry];
1821
+ [self writeShadowModel:toShadow
1822
+ geometry:toGeometry
1823
+ baked:bakedShadow
1824
+ nearestTile:NO];
1182
1825
  if (usesMask) {
1183
1826
  // Keep one mask representation for the full interval, including an
1184
1827
  // unequal→uniform transition. Switching layer.mask mid-animation would
@@ -1217,12 +1860,13 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1217
1860
  [self ensureShadowLayer];
1218
1861
  // Reveal opacity is encoded in shadowColor alpha. Animating layer opacity
1219
1862
  // too would multiply both curves and produce a quadratic-looking fade.
1220
- _shadowLayer.shadowOpacity = 1;
1863
+ // (A baked tile has no shadowColor; its alpha is the layer opacity.)
1864
+ if (!bakedShadow) _shadowLayer.shadowOpacity = 1;
1221
1865
  }
1222
- CGPathRef fromShadowPath = animatesShadowPath
1866
+ CGPathRef fromShadowPath = animatesShadowPath && !bakedShadow
1223
1867
  ? SmoothClipCreateShadowPath(fromGeometry, fromShadow)
1224
1868
  : nil;
1225
- CGPathRef toShadowPath = animatesShadowPath
1869
+ CGPathRef toShadowPath = animatesShadowPath && !bakedShadow
1226
1870
  ? SmoothClipCreateShadowPath(toGeometry, toShadow)
1227
1871
  : nil;
1228
1872
  CAAnimationGroup *group = [CAAnimationGroup animation];
@@ -1292,7 +1936,18 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1292
1936
  }
1293
1937
  contentGroup.animations = contentAnimations;
1294
1938
  contentGroup.duration = duration;
1295
- if (animatesShadow) {
1939
+ if (animatesShadow && bakedShadow) {
1940
+ shadowGroup.animations = [self
1941
+ bakedShadowAnimationsFromGeometry:fromGeometry
1942
+ fromShadow:fromShadow
1943
+ toGeometry:toGeometry
1944
+ toShadow:toShadow
1945
+ timing:timing
1946
+ spring:NO
1947
+ velocity:0
1948
+ duration:duration];
1949
+ shadowGroup.duration = duration;
1950
+ } else if (animatesShadow) {
1296
1951
  NSMutableArray<CAAnimation *> *shadowAnimations = [NSMutableArray array];
1297
1952
  if (animatesShadowPath) [shadowAnimations addObject:
1298
1953
  [self basicAnimationForKeyPath:@"shadowPath"
@@ -1435,7 +2090,18 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1435
2090
  }
1436
2091
  contentGroup.animations = contentAnimations;
1437
2092
  contentGroup.duration = springDuration;
1438
- if (animatesShadow) {
2093
+ if (animatesShadow && bakedShadow) {
2094
+ shadowGroup.animations = [self
2095
+ bakedShadowAnimationsFromGeometry:fromGeometry
2096
+ fromShadow:fromShadow
2097
+ toGeometry:toGeometry
2098
+ toShadow:toShadow
2099
+ timing:nil
2100
+ spring:YES
2101
+ velocity:velocity
2102
+ duration:springDuration];
2103
+ shadowGroup.duration = springDuration;
2104
+ } else if (animatesShadow) {
1439
2105
  NSMutableArray<CAAnimation *> *shadowAnimations = [NSMutableArray array];
1440
2106
  if (animatesShadowPath) [shadowAnimations addObject:
1441
2107
  [self springAnimationForKeyPath:@"shadowPath"
@@ -1527,6 +2193,14 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1527
2193
  convertTime:sharedBeginTime
1528
2194
  fromLayer:nil];
1529
2195
  }
2196
+ // A stamped epoch may sit up to one frame ahead of this commit. Until it
2197
+ // arrives the run presents its first frame rather than the model values
2198
+ // the layers already hold, which are the target.
2199
+ appearanceGroup.fillMode = kCAFillModeBackwards;
2200
+ group.fillMode = kCAFillModeBackwards;
2201
+ contentGroup.fillMode = kCAFillModeBackwards;
2202
+ if (animatesShadow) shadowGroup.fillMode = kCAFillModeBackwards;
2203
+ if (maskAnimation != nil) maskAnimation.fillMode = kCAFillModeBackwards;
1530
2204
  }
1531
2205
 
1532
2206
  _animationDelegate = [SmoothClipAnimationDelegate new];
@@ -1859,6 +2533,17 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1859
2533
  [super updateProps:props oldProps:oldProps];
1860
2534
  self.clipsToBounds = YES;
1861
2535
 
2536
+ const BOOL bakedShadows = newProps.shadowRendering == 1;
2537
+ if (bakedShadows != _bakedShadows) {
2538
+ _bakedShadows = bakedShadows;
2539
+ // Re-render the current shadow in the new mode. A run in flight keeps its
2540
+ // renderer to the end; its completion writes the model again.
2541
+ if (_activeAnimationId == 0 && _hasShadowModel) {
2542
+ [self writeShadow:_requestedShadow
2543
+ geometry:[self canonicalGeometryValue]];
2544
+ }
2545
+ }
2546
+
1862
2547
  const uint64_t nextDriverId =
1863
2548
  isfinite(newProps.driverId) && newProps.driverId > 0
1864
2549
  ? static_cast<uint64_t>(newProps.driverId)
@@ -1888,6 +2573,8 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1888
2573
  newProps.initialClipBoxShadowSpreadDistance,
1889
2574
  newProps.initialRotation,
1890
2575
  newProps.initialOpacity,
2576
+ newProps.initialBackdropTranslateX,
2577
+ newProps.initialBackdropTranslateY,
1891
2578
  &initial)) {
1892
2579
  // The initial presentation is atomic. Never apply a valid subset when one
1893
2580
  // field rejects.
@@ -2013,6 +2700,10 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
2013
2700
  shadowSpreadDistance,
2014
2701
  rotation,
2015
2702
  opacity,
2703
+ // The command protocol carries no backdrop channel; a command-driven
2704
+ // host keeps its backdrops where they are.
2705
+ 0,
2706
+ 0,
2016
2707
  &presentation)) {
2017
2708
  return;
2018
2709
  }