react-native-smooth-clip-view 0.4.1 → 0.4.3

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 (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +92 -5
  3. package/android/src/main/cpp/SmoothClipAndroid.h +2 -1
  4. package/android/src/main/cpp/SmoothClipBindings.cpp +22 -8
  5. package/android/src/main/cpp/SmoothClipRegistry.cpp +85 -10
  6. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +23 -0
  7. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +8 -0
  8. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +39 -1
  9. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +124 -32
  10. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +16 -0
  11. package/cpp/SmoothClipAnimationCurve.h +21 -10
  12. package/cpp/SmoothClipRegistry.h +2 -0
  13. package/cpp/SmoothClipSharedGeometry.h +20 -0
  14. package/cpp/SmoothClipVelocityTracker.h +6 -0
  15. package/ios/SmoothClipRegistry.mm +173 -17
  16. package/ios/SmoothClipTurboModule.cpp +17 -6
  17. package/ios/SmoothClipTurboModule.h +3 -1
  18. package/ios/SmoothClipView.mm +408 -20
  19. package/lib/module/NativeSmoothClipModule.js.map +1 -1
  20. package/lib/module/SmoothClipView.ios.js +3 -0
  21. package/lib/module/SmoothClipView.ios.js.map +1 -1
  22. package/lib/module/SmoothClipViewNativeComponent.ts +5 -1
  23. package/lib/module/geometry.js +22 -2
  24. package/lib/module/geometry.js.map +1 -1
  25. package/lib/module/presentationCodec.js +5 -3
  26. package/lib/module/presentationCodec.js.map +1 -1
  27. package/lib/typescript/src/NativeSmoothClipModule.d.ts +1 -1
  28. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -1
  29. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -1
  30. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +3 -1
  31. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  32. package/lib/typescript/src/controllerTypes.d.ts +5 -1
  33. package/lib/typescript/src/controllerTypes.d.ts.map +1 -1
  34. package/lib/typescript/src/geometry.d.ts +9 -0
  35. package/lib/typescript/src/geometry.d.ts.map +1 -1
  36. package/lib/typescript/src/index.d.ts +1 -1
  37. package/lib/typescript/src/index.d.ts.map +1 -1
  38. package/lib/typescript/src/presentationCodec.d.ts +1 -1
  39. package/lib/typescript/src/presentationCodec.d.ts.map +1 -1
  40. package/package.json +2 -1
  41. package/src/NativeSmoothClipModule.ts +3 -1
  42. package/src/SmoothClipView.ios.tsx +3 -0
  43. package/src/SmoothClipViewNativeComponent.ts +5 -1
  44. package/src/controllerTypes.ts +5 -1
  45. package/src/geometry.ts +35 -0
  46. package/src/index.ts +1 -0
  47. package/src/presentationCodec.ts +8 -2
@@ -45,6 +45,36 @@ using namespace facebook::react;
45
45
  return (id<CAAction>)[NSNull null];
46
46
  }
47
47
 
48
+ // UIKit normally hit-tests the target model tree. During native motion, use
49
+ // the visible bounds/transform/opacity instead, including a fade whose target
50
+ // opacity is already zero. These containers have no consumer-owned handlers.
51
+ - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
52
+ CALayer *visible = self.layer.animationKeys.count > 0 ? self.layer.presentationLayer : nil;
53
+ return CGRectContainsPoint(visible != nil ? visible.bounds : self.bounds, point);
54
+ }
55
+
56
+ - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
57
+ CALayer *visible = self.layer.animationKeys.count > 0 ? self.layer.presentationLayer : nil;
58
+ if (self.hidden || !self.userInteractionEnabled ||
59
+ (visible != nil ? visible.opacity : self.layer.opacity) <= 0) return nil;
60
+ if (visible != nil && self.layer.animationKeys.count > 0) {
61
+ CGPoint parentPoint = [self.layer convertPoint:point toLayer:self.layer.superlayer];
62
+ point = [visible convertPoint:parentPoint fromLayer:visible.superlayer];
63
+ }
64
+ if (![self pointInside:point withEvent:event]) return nil;
65
+ for (UIView *child in self.subviews.reverseObjectEnumerator) {
66
+ UIView *hit = [child hitTest:[self convertPoint:point toView:child] withEvent:event];
67
+ if (hit != nil) return hit;
68
+ }
69
+ return self;
70
+ }
71
+
72
+ @end
73
+
74
+ @interface SmoothClipPresentationContainerView : SmoothClipContainerView
75
+ @end
76
+ @implementation SmoothClipPresentationContainerView
77
+ - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { return YES; }
48
78
  @end
49
79
 
50
80
  @interface SmoothClipView () <RCTSmoothClipViewViewProtocol>
@@ -104,6 +134,8 @@ static bool SmoothClipBuildPresentation(
104
134
  double shadowOffsetY,
105
135
  double shadowBlurRadius,
106
136
  double shadowSpreadDistance,
137
+ double rotation,
138
+ double opacity,
107
139
  smoothclip::Presentation *result) {
108
140
  if (result == nullptr || !isfinite(x) || !isfinite(y) ||
109
141
  !isfinite(width) || !isfinite(height) ||
@@ -115,6 +147,7 @@ static bool SmoothClipBuildPresentation(
115
147
  !isfinite(shadowAlpha) ||
116
148
  !isfinite(shadowOffsetX) || !isfinite(shadowOffsetY) ||
117
149
  !isfinite(shadowBlurRadius) || !isfinite(shadowSpreadDistance) ||
150
+ !isfinite(rotation) || !isfinite(opacity) ||
118
151
  contentScale <= 0 || shadowRed < 0 || shadowRed > 1 ||
119
152
  shadowGreen < 0 || shadowGreen > 1 ||
120
153
  shadowBlue < 0 || shadowBlue > 1 ||
@@ -145,7 +178,8 @@ static bool SmoothClipBuildPresentation(
145
178
  shadowBlurRadius,
146
179
  shadowSpreadDistance};
147
180
  *result = {
148
- geometry, contentTranslateX, contentTranslateY, contentScale, shadow};
181
+ geometry, contentTranslateX, contentTranslateY, contentScale, shadow,
182
+ rotation, smoothclip::clamp01(opacity)};
149
183
  return true;
150
184
  }
151
185
 
@@ -320,9 +354,25 @@ static std::array<double, 11> SmoothClipVelocityChannels(
320
354
  contentScale};
321
355
  }
322
356
 
323
- static BOOL SmoothClipRectIntersectsHost(CGRect rect, CGRect host) {
324
- const CGRect intersection = CGRectIntersection(rect, host);
325
- return !CGRectIsNull(intersection) && !CGRectIsEmpty(intersection);
357
+ // Core Animation uses the overdamped physical solution as well as the critical
358
+ // and underdamped ones. This is only a turn-count reference at snapshot time.
359
+ static double SmoothClipCARotationProgress(double elapsed, const smoothclip::SpringAnimation &spring) {
360
+ const double omega = std::sqrt(spring.stiffness / spring.mass);
361
+ const double zeta = spring.damping / (2 * std::sqrt(spring.stiffness * spring.mass));
362
+ if (zeta <= 1) {
363
+ return smoothclip::advanceScalarSpring({0, spring.initialVelocity}, spring, elapsed).position;
364
+ }
365
+ const double root = std::sqrt(zeta * zeta - 1);
366
+ const double slow = -omega / (zeta + root);
367
+ const double fast = -omega * (zeta + root);
368
+ const double a = (spring.initialVelocity + fast) / (slow - fast);
369
+ return 1 + a * std::exp(slow * elapsed) + (-1 - a) * std::exp(fast * elapsed);
370
+ }
371
+
372
+ static BOOL SmoothClipRotatedRectIntersectsHost(CGRect rect, CGRect host, double rotation) {
373
+ return smoothclip::rotatedRectIntersectsHost(rect.origin.x - host.origin.x,
374
+ rect.origin.y - host.origin.y, rect.size.width, rect.size.height, rotation,
375
+ host.size.width, host.size.height);
326
376
  }
327
377
 
328
378
  static smoothclip::Presentation SmoothClipCanonicalSnapshot(
@@ -333,6 +383,19 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
333
383
 
334
384
  @implementation SmoothClipView {
335
385
  CALayer *_shadowLayer;
386
+ SmoothClipPresentationContainerView *_presentationContainer;
387
+ double _requestedRotation;
388
+ double _canonicalRotation;
389
+ double _requestedOpacity;
390
+ double _canonicalOpacity;
391
+ double _animationFromRotation;
392
+ double _animationToRotation;
393
+ BOOL _hasAppearanceAnimation;
394
+ BOOL _hasMaskAnimation;
395
+ BOOL _hasShadowModel;
396
+ SmoothClipCanonicalGeometry _shadowModelGeometry;
397
+ smoothclip::Shadow _shadowModel;
398
+
336
399
  SmoothClipContainerView *_clipContainer;
337
400
  SmoothClipContainerView *_contentContainer;
338
401
  CAShapeLayer *_unequalCornerMask;
@@ -365,6 +428,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
365
428
  smoothclip::TimingAnimation _timingAnimation;
366
429
  smoothclip::SpringAnimation _springAnimation;
367
430
  SmoothClipAnimationDelegate *_animationDelegate;
431
+ BOOL _springPausedForInactivity;
432
+ BOOL _pausedSpringHadMaskAnimation;
433
+ BOOL _pausedSpringHadShadowAnimation;
368
434
 
369
435
  // 'inherit' velocity samples (raw canonical geometry, per view); recording/
370
436
  // coalescing/projection live in the shared cpp/SmoothClipVelocityTracker.h
@@ -397,6 +463,12 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
397
463
  // intentionally overridden again after every prop update below.
398
464
  self.clipsToBounds = YES;
399
465
 
466
+ _presentationContainer = [[SmoothClipPresentationContainerView alloc] initWithFrame:CGRectZero];
467
+ _presentationContainer.layer.anchorPoint = CGPointZero;
468
+ _presentationContainer.layer.allowsGroupOpacity = YES;
469
+ _presentationContainer.autoresizesSubviews = NO;
470
+ [self addSubview:_presentationContainer];
471
+ _requestedOpacity = _canonicalOpacity = 1;
400
472
  _clipContainer = [[SmoothClipContainerView alloc] initWithFrame:CGRectZero];
401
473
  _clipContainer.layer.masksToBounds = YES;
402
474
  _clipContainer.layer.needsDisplayOnBoundsChange = NO;
@@ -409,7 +481,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
409
481
  _contentContainer.layer.needsDisplayOnBoundsChange = NO;
410
482
  _contentContainer.autoresizesSubviews = NO;
411
483
  [_clipContainer addSubview:_contentContainer];
412
- [self addSubview:_clipContainer];
484
+ [_presentationContainer addSubview:_clipContainer];
413
485
 
414
486
  _shadowLayer = nil;
415
487
  // Created lazily so the common clipping-only path owns no shadow layer.
@@ -445,6 +517,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
445
517
  _ignoreAnimationCallback = NO;
446
518
  _activeAnimationId = 0;
447
519
  _activeAnimationKind = 0;
520
+ _springPausedForInactivity = NO;
521
+ _pausedSpringHadMaskAnimation = NO;
522
+ _pausedSpringHadShadowAnimation = NO;
448
523
  smoothclip::clearVelocitySamples(_velocitySamples);
449
524
  // App-state transitions are observed by the registry itself (see
450
525
  // installApplicationStateObservers): the active flag is process-global,
@@ -502,6 +577,8 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
502
577
  presentation.contentTranslateY);
503
578
  _requestedContentScale = presentation.contentScale;
504
579
  _requestedShadow = presentation.shadow;
580
+ _requestedRotation = presentation.rotation;
581
+ _requestedOpacity = smoothclip::clamp01(presentation.opacity);
505
582
  }
506
583
 
507
584
  - (CGColorRef)colorForShadow:(const smoothclip::Shadow &)shadow {
@@ -524,7 +601,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
524
601
  @"bounds": [NSNull null],
525
602
  @"position": [NSNull null],
526
603
  };
527
- [self.layer insertSublayer:_shadowLayer below:_clipContainer.layer];
604
+ [_presentationContainer.layer insertSublayer:_shadowLayer below:_clipContainer.layer];
528
605
  return _shadowLayer;
529
606
  }
530
607
 
@@ -539,9 +616,15 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
539
616
  shadowLayer.bounds = self.bounds;
540
617
  shadowLayer.position = CGPointMake(
541
618
  CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
542
- CGPathRef path = SmoothClipCreateShadowPath(geometry, effectiveShadow);
543
- shadowLayer.shadowPath = path;
544
- CGPathRelease(path);
619
+ if (!_hasShadowModel || !SmoothBoxShadowPathInputEqual(
620
+ _shadowModelGeometry, _shadowModel, geometry, effectiveShadow)) {
621
+ CGPathRef path = SmoothClipCreateShadowPath(geometry, effectiveShadow);
622
+ shadowLayer.shadowPath = path;
623
+ CGPathRelease(path);
624
+ }
625
+ _hasShadowModel = YES;
626
+ _shadowModelGeometry = geometry;
627
+ _shadowModel = effectiveShadow;
545
628
  shadowLayer.shadowColor = [self colorForShadow:effectiveShadow];
546
629
  shadowLayer.shadowOpacity = visible ? 1 : 0;
547
630
  shadowLayer.shadowRadius = MAX(0, effectiveShadow.blurRadius) / 2.0;
@@ -597,7 +680,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
597
680
  - (void)syncVisibilityForRect:(CGRect)rect {
598
681
  [self setClipContainerHidden:CGRectIsEmpty(rect)];
599
682
  _clipContainer.accessibilityElementsHidden =
600
- !SmoothClipRectIntersectsHost(rect, self.bounds);
683
+ !SmoothClipRotatedRectIntersectsHost(rect, self.bounds, _canonicalRotation) || _canonicalOpacity <= 0;
601
684
  }
602
685
 
603
686
  - (void)configureUnequalCornerMaskForGeometry:
@@ -681,6 +764,50 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
681
764
  #endif
682
765
  }
683
766
 
767
+ - (void)writeAppearanceRotation:(double)rotation opacity:(double)opacity rect:(CGRect)rect {
768
+ CALayer *layer = _presentationContainer.layer;
769
+ // A zero-angle group is identity regardless of pivot. Keep it stationary so
770
+ // existing geometry-only streams do not also dirty the parent layer each frame.
771
+ const CGPoint center = rotation == 0 ? CGPointZero :
772
+ CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect));
773
+ const CGRect bounds = (CGRect){center, self.bounds.size};
774
+ if (!CGRectEqualToRect(layer.bounds, bounds)) layer.bounds = bounds;
775
+ if (!CGPointEqualToPoint(layer.position, center)) layer.position = center;
776
+ if (_canonicalRotation != rotation) layer.affineTransform = CGAffineTransformMakeRotation(rotation);
777
+ if (_canonicalOpacity != opacity) layer.opacity = smoothclip::clamp01(opacity);
778
+ _canonicalRotation = rotation;
779
+ _canonicalOpacity = opacity;
780
+ }
781
+
782
+ // Matrix decomposition loses complete turns. Use the installed native trajectory
783
+ // to choose the equivalent visible angle, including after a paused spring resumes.
784
+ - (double)visibleRotation {
785
+ CAAnimationGroup *group = (CAAnimationGroup *)[_presentationContainer.layer
786
+ animationForKey:@"smoothClip.appearance"];
787
+ CALayer *visible = _presentationContainer.layer.presentationLayer;
788
+ if (_activeAnimationId == 0 || group == nil || visible == nil) return _canonicalRotation;
789
+ const double elapsed = group.speed == 0 ? group.timeOffset :
790
+ std::clamp([_presentationContainer.layer convertTime:CACurrentMediaTime() fromLayer:nil] - group.beginTime,
791
+ 0.0, group.duration);
792
+ double progress;
793
+ if (_activeAnimationKind == 1) {
794
+ progress = smoothclip::cubicBezier(_timingAnimation.controlPoint1X,
795
+ _timingAnimation.controlPoint1Y, _timingAnimation.controlPoint2X,
796
+ _timingAnimation.controlPoint2Y, group.duration > 0 ? elapsed / group.duration : 1);
797
+ } else {
798
+ progress = SmoothClipCARotationProgress(elapsed, _springAnimation);
799
+ }
800
+ const double expected = _animationFromRotation +
801
+ (_animationToRotation - _animationFromRotation) * progress;
802
+ const CGAffineTransform matrix = visible.affineTransform;
803
+ return smoothclip::unwrapRotation(std::atan2(matrix.b, matrix.a), expected);
804
+ }
805
+
806
+ - (double)visibleOpacity {
807
+ CALayer *layer = _activeAnimationId != 0 ? _presentationContainer.layer.presentationLayer : nil;
808
+ return smoothclip::clamp01(layer == nil ? _canonicalOpacity : layer.opacity);
809
+ }
810
+
684
811
  - (void)writeContentTranslation:(CGPoint)translation
685
812
  scale:(CGFloat)scale {
686
813
  if (CGPointEqualToPoint(_canonicalContentTranslation, translation) &&
@@ -698,7 +825,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
698
825
  if (![self canonicalRequestedGeometry:&geometry]) return;
699
826
 
700
827
  const BOOL accessibilityHidden =
701
- !SmoothClipRectIntersectsHost(geometry.rect, self.bounds);
828
+ !SmoothClipRotatedRectIntersectsHost(geometry.rect, self.bounds, _requestedRotation) || _requestedOpacity <= 0;
702
829
  const BOOL visibilityChanged =
703
830
  _clipHidden != CGRectIsEmpty(geometry.rect) ||
704
831
  _clipContainer.accessibilityElementsHidden != accessibilityHidden;
@@ -709,15 +836,23 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
709
836
  CGPointEqualToPoint(
710
837
  _canonicalContentTranslation, _requestedContentTranslation) &&
711
838
  _canonicalContentScale == _requestedContentScale &&
839
+ _canonicalRotation == _requestedRotation && _canonicalOpacity == _requestedOpacity &&
712
840
  SmoothBoxShadowEqual(_canonicalShadow, _requestedShadow) &&
713
841
  !visibilityChanged) {
714
842
  return;
715
843
  }
844
+ const SmoothClipCanonicalGeometry previousGeometry = [self canonicalGeometryValue];
716
845
  [self writeLayerGeometry:geometry
717
846
  hasExplicitRadii:_requestedHasExplicitRadii];
718
847
  [self writeContentTranslation:_requestedContentTranslation
719
848
  scale:_requestedContentScale];
720
- [self writeShadow:_requestedShadow geometry:geometry];
849
+ if (!CGRectEqualToRect(previousGeometry.rect, geometry.rect) ||
850
+ !SmoothClipCornerRadiiEqual(previousGeometry.radii, geometry.radii) ||
851
+ previousGeometry.curve != geometry.curve ||
852
+ !SmoothBoxShadowEqual(_canonicalShadow, _requestedShadow)) {
853
+ [self writeShadow:_requestedShadow geometry:geometry];
854
+ }
855
+ [self writeAppearanceRotation:_requestedRotation opacity:_requestedOpacity rect:geometry.rect];
721
856
  [self syncVisibilityForRect:geometry.rect];
722
857
  }
723
858
 
@@ -827,7 +962,8 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
827
962
  ? (CALayer *)_shadowLayer.presentationLayer
828
963
  : _shadowLayer;
829
964
  if (shadowLayer == nil) return SmoothClipCanonicalSnapshot({
830
- geometry, transform.tx, transform.ty, contentScale, shadow});
965
+ geometry, transform.tx, transform.ty, contentScale, shadow,
966
+ [self visibleRotation], [self visibleOpacity]});
831
967
  shadow.enabled = _canonicalShadow.enabled || shadowLayer.shadowOpacity > 0;
832
968
  shadow.blurRadius = shadowLayer.shadowRadius * 2.0;
833
969
  shadow.offsetX = shadowLayer.shadowOffset.width;
@@ -850,7 +986,8 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
850
986
  CGRectGetMinX(visibleRect) - CGRectGetMinX(shadowBounds);
851
987
  }
852
988
  return SmoothClipCanonicalSnapshot(
853
- {geometry, transform.tx, transform.ty, contentScale, shadow});
989
+ {geometry, transform.tx, transform.ty, contentScale, shadow,
990
+ [self visibleRotation], [self visibleOpacity]});
854
991
  }
855
992
 
856
993
  - (BOOL)smoothClipIsJoinable {
@@ -921,12 +1058,18 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
921
1058
  [_clipContainer.layer removeAnimationForKey:@"smoothClip.geometry"];
922
1059
  [_contentContainer.layer removeAnimationForKey:@"smoothClip.content"];
923
1060
  [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1061
+ [_presentationContainer.layer removeAnimationForKey:@"smoothClip.appearance"];
924
1062
  [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
925
- [self applyStaticCornerRepresentation:[self canonicalGeometryValue]];
1063
+ if (_hasMaskAnimation) {
1064
+ [self applyStaticCornerRepresentation:[self canonicalGeometryValue]];
1065
+ }
926
1066
  _ignoreAnimationCallback = NO;
927
1067
  _activeAnimationId = 0;
928
1068
  _activeAnimationKind = 0;
929
1069
  _animationDelegate = nil;
1070
+ _springPausedForInactivity = NO;
1071
+ _pausedSpringHadMaskAnimation = NO;
1072
+ _pausedSpringHadShadowAnimation = NO;
930
1073
  }
931
1074
 
932
1075
  - (CABasicAnimation *)basicAnimationForKeyPath:(NSString *)keyPath
@@ -978,12 +1121,16 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
978
1121
  fromContentTranslation:(CGPoint)fromContentTranslation
979
1122
  fromContentScale:(CGFloat)fromContentScale
980
1123
  fromShadow:(smoothclip::Shadow)fromShadow
1124
+ fromRotation:(double)fromRotation
1125
+ fromOpacity:(double)fromOpacity
981
1126
  toGeometry:
982
1127
  (SmoothClipCanonicalGeometry)toGeometry
983
1128
  hasExplicitToRadii:(BOOL)hasExplicitToRadii
984
1129
  toContentTranslation:(CGPoint)toContentTranslation
985
1130
  toContentScale:(CGFloat)toContentScale
986
1131
  toShadow:(smoothclip::Shadow)toShadow
1132
+ toRotation:(double)toRotation
1133
+ toOpacity:(double)toOpacity
987
1134
  duration:(CFTimeInterval)duration
988
1135
  sharedBeginTime:(CFTimeInterval)sharedBeginTime {
989
1136
  const int32_t animationId = _activeAnimationId;
@@ -1011,13 +1158,19 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1011
1158
  CGPointMake(
1012
1159
  CGRectGetMidX(toGeometry.rect),
1013
1160
  CGRectGetMidY(toGeometry.rect));
1014
- const BOOL usesMask =
1015
- !SmoothClipCornerRadiiAreUniform(fromGeometry.radii) ||
1016
- !SmoothClipCornerRadiiAreUniform(toGeometry.radii) ||
1161
+ const BOOL changesShape = !CGRectEqualToRect(fromGeometry.rect, toGeometry.rect) ||
1162
+ !SmoothClipCornerRadiiEqual(fromGeometry.radii, toGeometry.radii) ||
1017
1163
  fromGeometry.curve != toGeometry.curve;
1164
+ const BOOL usesMask = changesShape &&
1165
+ (!SmoothClipCornerRadiiAreUniform(fromGeometry.radii) ||
1166
+ !SmoothClipCornerRadiiAreUniform(toGeometry.radii) ||
1167
+ fromGeometry.curve != toGeometry.curve);
1018
1168
  [self writeLayerGeometry:toGeometry
1019
1169
  hasExplicitRadii:hasExplicitToRadii];
1020
1170
  [self writeContentTranslation:toContentTranslation scale:toContentScale];
1171
+ [self writeAppearanceRotation:toRotation opacity:toOpacity rect:toGeometry.rect];
1172
+ _animationFromRotation = fromRotation;
1173
+ _animationToRotation = toRotation;
1021
1174
  _canonicalShadow = toShadow;
1022
1175
  SmoothBoxShadowNormalizeAnimationEndpoints(
1023
1176
  fromGeometry, &fromShadow, toGeometry, &toShadow);
@@ -1078,6 +1231,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1078
1231
  ? [CAAnimationGroup animation]
1079
1232
  : nil;
1080
1233
  CAAnimationGroup *maskAnimation = nil;
1234
+ _hasMaskAnimation = usesMask;
1081
1235
  if (_activeAnimationKind == 1) {
1082
1236
  CAMediaTimingFunction *timing = [CAMediaTimingFunction
1083
1237
  functionWithControlPoints:_timingAnimation.controlPoint1X
@@ -1318,10 +1472,47 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1318
1472
  maskAnimation.duration = group.duration;
1319
1473
  }
1320
1474
 
1475
+ CAAnimationGroup *appearanceGroup = [CAAnimationGroup animation];
1476
+ NSMutableArray<CAAnimation *> *appearanceAnimations = [NSMutableArray array];
1477
+ // A zero anchor and moving bounds origin avoid host-size-dependent pivot math.
1478
+ NSArray<NSString *> *keys = @[@"bounds.origin.x", @"bounds.origin.y", @"position.x",
1479
+ @"position.y", @"transform.rotation.z", @"opacity"];
1480
+ NSArray<NSNumber *> *fromValues = @[@(fromPosition.x), @(fromPosition.y), @(fromPosition.x),
1481
+ @(fromPosition.y), @(fromRotation), @(fromOpacity)];
1482
+ NSArray<NSNumber *> *toValues = @[@(toPosition.x), @(toPosition.y), @(toPosition.x),
1483
+ @(toPosition.y), @(toRotation), @(toOpacity)];
1484
+ for (NSUInteger index = 0; index < keys.count; index++) {
1485
+ if ([fromValues[index] isEqual:toValues[index]]) continue;
1486
+ // At zero rotation, origin/position cancel exactly; no pivot animation is needed.
1487
+ if (index < 4 && fromRotation == 0 && toRotation == 0) continue;
1488
+ CAAnimation *animation;
1489
+ if (_activeAnimationKind == 1) {
1490
+ CAMediaTimingFunction *timing = [CAMediaTimingFunction
1491
+ functionWithControlPoints:_timingAnimation.controlPoint1X :_timingAnimation.controlPoint1Y
1492
+ :_timingAnimation.controlPoint2X :_timingAnimation.controlPoint2Y];
1493
+ animation = [self basicAnimationForKeyPath:keys[index] fromValue:fromValues[index]
1494
+ toValue:toValues[index] timingFunction:timing];
1495
+ } else {
1496
+ animation = [self springAnimationForKeyPath:keys[index] fromValue:fromValues[index]
1497
+ toValue:toValues[index] velocity:_springAnimation.initialVelocity duration:group.duration];
1498
+ }
1499
+ [appearanceAnimations addObject:animation];
1500
+ }
1501
+ appearanceGroup.animations = appearanceAnimations;
1502
+ appearanceGroup.duration = group.duration;
1503
+
1504
+
1505
+ // An explicit media-time origin is required to pause and resume spring
1506
+ // groups without touching the parent layer clock (which would also pause
1507
+ // consumer animations in the clipped subtree).
1508
+ if (sharedBeginTime <= 0) {
1509
+ sharedBeginTime = CACurrentMediaTime();
1510
+ }
1321
1511
  if (sharedBeginTime > 0) {
1322
1512
  // Each layer receives the same absolute media timestamp translated into
1323
1513
  // its local clock. Group participants therefore advance from one epoch
1324
1514
  // even though their animations are installed sequentially.
1515
+ appearanceGroup.beginTime = [_presentationContainer.layer convertTime:sharedBeginTime fromLayer:nil];
1325
1516
  group.beginTime = [layer convertTime:sharedBeginTime fromLayer:nil];
1326
1517
  contentGroup.beginTime = [_contentContainer.layer
1327
1518
  convertTime:sharedBeginTime
@@ -1343,6 +1534,13 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1343
1534
  _animationDelegate.driverId = _driverId;
1344
1535
  _animationDelegate.animationId = _activeAnimationId;
1345
1536
  group.delegate = _animationDelegate;
1537
+ _springPausedForInactivity = NO;
1538
+ _pausedSpringHadMaskAnimation = NO;
1539
+ _pausedSpringHadShadowAnimation = NO;
1540
+ _hasAppearanceAnimation = appearanceAnimations.count > 0;
1541
+ if (_hasAppearanceAnimation) {
1542
+ [_presentationContainer.layer addAnimation:appearanceGroup forKey:@"smoothClip.appearance"];
1543
+ }
1346
1544
  [layer addAnimation:group forKey:@"smoothClip.geometry"];
1347
1545
  [_contentContainer.layer addAnimation:contentGroup
1348
1546
  forKey:@"smoothClip.content"];
@@ -1387,11 +1585,15 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1387
1585
  current.contentTranslateY)
1388
1586
  fromContentScale:current.contentScale
1389
1587
  fromShadow:current.shadow
1588
+ fromRotation:current.rotation
1589
+ fromOpacity:current.opacity
1390
1590
  toGeometry:target
1391
1591
  hasExplicitToRadii:_requestedHasExplicitRadii
1392
1592
  toContentTranslation:_requestedContentTranslation
1393
1593
  toContentScale:_requestedContentScale
1394
1594
  toShadow:_requestedShadow
1595
+ toRotation:_requestedRotation
1596
+ toOpacity:_requestedOpacity
1395
1597
  duration:duration
1396
1598
  sharedBeginTime:sharedBeginTime];
1397
1599
  return YES;
@@ -1465,6 +1667,168 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1465
1667
  }
1466
1668
  }
1467
1669
 
1670
+ - (BOOL)smoothClipPauseSpringAnimation:(int32_t)animationId
1671
+ atMediaTime:(CFTimeInterval)mediaTime {
1672
+ if (_activeAnimationId != animationId || _activeAnimationKind != 2 ||
1673
+ _springPausedForInactivity) {
1674
+ return NO;
1675
+ }
1676
+
1677
+ CALayer *geometryLayer = _clipContainer.layer;
1678
+ CALayer *contentLayer = _contentContainer.layer;
1679
+ CAAnimation *installedGeometry =
1680
+ [geometryLayer animationForKey:@"smoothClip.geometry"];
1681
+ CAAnimation *installedContent =
1682
+ [contentLayer animationForKey:@"smoothClip.content"];
1683
+ CAAnimation *installedAppearance = [_presentationContainer.layer animationForKey:@"smoothClip.appearance"];
1684
+ if ((_hasAppearanceAnimation && installedAppearance == nil) || installedGeometry == nil || installedContent == nil) return NO;
1685
+
1686
+ auto pausedCopy = ^CAAnimation *(CALayer *layer, CAAnimation *installed) {
1687
+ if (installed == nil) return (CAAnimation *)nil;
1688
+ CAAnimation *paused = [installed copy];
1689
+ const CFTimeInterval localNow =
1690
+ [layer convertTime:mediaTime fromLayer:nil];
1691
+ const CFTimeInterval elapsed = std::clamp(
1692
+ localNow - installed.beginTime,
1693
+ (CFTimeInterval)0,
1694
+ installed.duration);
1695
+ paused.speed = 0;
1696
+ paused.timeOffset = elapsed;
1697
+ paused.beginTime = 0;
1698
+ return paused;
1699
+ };
1700
+
1701
+ CAAnimation *appearance = pausedCopy(_presentationContainer.layer, installedAppearance);
1702
+ CAAnimation *geometry = pausedCopy(geometryLayer, installedGeometry);
1703
+ CAAnimation *content = pausedCopy(contentLayer, installedContent);
1704
+ CAAnimation *installedMask =
1705
+ [_unequalCornerMask animationForKey:@"smoothClip.mask"];
1706
+ CAAnimation *mask = pausedCopy(_unequalCornerMask, installedMask);
1707
+ CAAnimation *installedShadow =
1708
+ [_shadowLayer animationForKey:@"smoothClip.shadow"];
1709
+ CAAnimation *shadow = pausedCopy(_shadowLayer, installedShadow);
1710
+
1711
+ _animationDelegate.invalidated = YES;
1712
+ _ignoreAnimationCallback = YES;
1713
+ [geometryLayer removeAnimationForKey:@"smoothClip.geometry"];
1714
+ [contentLayer removeAnimationForKey:@"smoothClip.content"];
1715
+ [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1716
+ [_presentationContainer.layer removeAnimationForKey:@"smoothClip.appearance"];
1717
+ [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
1718
+
1719
+ _animationDelegate = [SmoothClipAnimationDelegate new];
1720
+ _animationDelegate.view = self;
1721
+ _animationDelegate.driverId = _driverId;
1722
+ _animationDelegate.animationId = animationId;
1723
+ geometry.delegate = _animationDelegate;
1724
+ if (appearance != nil) {
1725
+ [_presentationContainer.layer addAnimation:appearance forKey:@"smoothClip.appearance"];
1726
+ }
1727
+ [geometryLayer addAnimation:geometry forKey:@"smoothClip.geometry"];
1728
+ [contentLayer addAnimation:content forKey:@"smoothClip.content"];
1729
+ if (mask != nil) {
1730
+ [_unequalCornerMask addAnimation:mask forKey:@"smoothClip.mask"];
1731
+ }
1732
+ if (shadow != nil) {
1733
+ [_shadowLayer addAnimation:shadow forKey:@"smoothClip.shadow"];
1734
+ }
1735
+ _ignoreAnimationCallback = NO;
1736
+ _springPausedForInactivity = YES;
1737
+ _pausedSpringHadMaskAnimation = mask != nil;
1738
+ _pausedSpringHadShadowAnimation = shadow != nil;
1739
+ return YES;
1740
+ }
1741
+
1742
+ - (BOOL)smoothClipResumeSpringAnimation:(int32_t)animationId
1743
+ atMediaTime:(CFTimeInterval)mediaTime
1744
+ catchUpBy:(CFTimeInterval)catchUp {
1745
+ if (_activeAnimationId != animationId || _activeAnimationKind != 2 ||
1746
+ !_springPausedForInactivity) {
1747
+ return NO;
1748
+ }
1749
+
1750
+ CALayer *geometryLayer = _clipContainer.layer;
1751
+ CALayer *contentLayer = _contentContainer.layer;
1752
+ CAAnimation *installedGeometry =
1753
+ [geometryLayer animationForKey:@"smoothClip.geometry"];
1754
+ CAAnimation *installedContent =
1755
+ [contentLayer animationForKey:@"smoothClip.content"];
1756
+ CAAnimation *installedMask =
1757
+ [_unequalCornerMask animationForKey:@"smoothClip.mask"];
1758
+ CAAnimation *installedShadow =
1759
+ [_shadowLayer animationForKey:@"smoothClip.shadow"];
1760
+ CAAnimation *installedAppearance = [_presentationContainer.layer animationForKey:@"smoothClip.appearance"];
1761
+ if ((_hasAppearanceAnimation && installedAppearance == nil) || installedGeometry == nil || installedContent == nil ||
1762
+ (_pausedSpringHadMaskAnimation && installedMask == nil) ||
1763
+ (_pausedSpringHadShadowAnimation && installedShadow == nil)) {
1764
+ return NO;
1765
+ }
1766
+
1767
+ auto resumedCopy = ^CAAnimation *(CALayer *layer, CAAnimation *installed) {
1768
+ if (installed == nil) return (CAAnimation *)nil;
1769
+ CAAnimation *resumed = [installed copy];
1770
+ const CFTimeInterval localNow =
1771
+ [layer convertTime:mediaTime fromLayer:nil];
1772
+ const CFTimeInterval resumedTime = std::min(
1773
+ installed.duration,
1774
+ std::max((CFTimeInterval)0, installed.timeOffset + catchUp));
1775
+ resumed.speed = 1;
1776
+ resumed.timeOffset = 0;
1777
+ resumed.beginTime = localNow - resumedTime;
1778
+ return resumed;
1779
+ };
1780
+
1781
+ CAAnimation *appearance = resumedCopy(_presentationContainer.layer, installedAppearance);
1782
+ CAAnimation *geometry = resumedCopy(geometryLayer, installedGeometry);
1783
+ CAAnimation *content = resumedCopy(contentLayer, installedContent);
1784
+ CAAnimation *mask = resumedCopy(_unequalCornerMask, installedMask);
1785
+ CAAnimation *shadow = resumedCopy(_shadowLayer, installedShadow);
1786
+
1787
+ _animationDelegate.invalidated = YES;
1788
+ _ignoreAnimationCallback = YES;
1789
+ [geometryLayer removeAnimationForKey:@"smoothClip.geometry"];
1790
+ [contentLayer removeAnimationForKey:@"smoothClip.content"];
1791
+ [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1792
+ [_presentationContainer.layer removeAnimationForKey:@"smoothClip.appearance"];
1793
+ [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
1794
+
1795
+ _animationDelegate = [SmoothClipAnimationDelegate new];
1796
+ _animationDelegate.view = self;
1797
+ _animationDelegate.driverId = _driverId;
1798
+ _animationDelegate.animationId = animationId;
1799
+ geometry.delegate = _animationDelegate;
1800
+ if (appearance != nil) {
1801
+ [_presentationContainer.layer addAnimation:appearance forKey:@"smoothClip.appearance"];
1802
+ }
1803
+ [geometryLayer addAnimation:geometry forKey:@"smoothClip.geometry"];
1804
+ [contentLayer addAnimation:content forKey:@"smoothClip.content"];
1805
+ if (mask != nil) {
1806
+ [_unequalCornerMask addAnimation:mask forKey:@"smoothClip.mask"];
1807
+ }
1808
+ if (shadow != nil) {
1809
+ [_shadowLayer addAnimation:shadow forKey:@"smoothClip.shadow"];
1810
+ }
1811
+ _ignoreAnimationCallback = NO;
1812
+ _springPausedForInactivity = NO;
1813
+ _pausedSpringHadMaskAnimation = NO;
1814
+ _pausedSpringHadShadowAnimation = NO;
1815
+ return YES;
1816
+ }
1817
+
1818
+ - (BOOL)smoothClipHasInstalledAnimation:(int32_t)animationId {
1819
+ return _activeAnimationId == animationId &&
1820
+ (!_hasAppearanceAnimation ||
1821
+ [_presentationContainer.layer animationForKey:@"smoothClip.appearance"] != nil) &&
1822
+ [_clipContainer.layer animationForKey:@"smoothClip.geometry"] != nil &&
1823
+ [_contentContainer.layer animationForKey:@"smoothClip.content"] != nil &&
1824
+ (!_springPausedForInactivity ||
1825
+ (!_pausedSpringHadMaskAnimation ||
1826
+ [_unequalCornerMask animationForKey:@"smoothClip.mask"] != nil)) &&
1827
+ (!_springPausedForInactivity ||
1828
+ (!_pausedSpringHadShadowAnimation ||
1829
+ [_shadowLayer animationForKey:@"smoothClip.shadow"] != nil));
1830
+ }
1831
+
1468
1832
  - (void)smoothClipAnimationDidStopWithDriverId:(uint64_t)driverId
1469
1833
  animationId:(int32_t)animationId
1470
1834
  finished:(BOOL)finished {
@@ -1472,9 +1836,15 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1472
1836
  _activeAnimationId = 0;
1473
1837
  _activeAnimationKind = 0;
1474
1838
  _animationDelegate = nil;
1839
+ _springPausedForInactivity = NO;
1840
+ _pausedSpringHadMaskAnimation = NO;
1841
+ _pausedSpringHadShadowAnimation = NO;
1475
1842
  [_unequalCornerMask removeAnimationForKey:@"smoothClip.mask"];
1843
+ [_presentationContainer.layer removeAnimationForKey:@"smoothClip.appearance"];
1476
1844
  [_shadowLayer removeAnimationForKey:@"smoothClip.shadow"];
1477
- [self applyStaticCornerRepresentation:[self canonicalGeometryValue]];
1845
+ if (_hasMaskAnimation) {
1846
+ [self applyStaticCornerRepresentation:[self canonicalGeometryValue]];
1847
+ }
1478
1848
  [self writeShadow:_requestedShadow
1479
1849
  geometry:[self canonicalGeometryValue]];
1480
1850
  [self syncVisibilityForRect:_canonicalClip];
@@ -1516,6 +1886,8 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1516
1886
  newProps.initialClipBoxShadowOffsetY,
1517
1887
  newProps.initialClipBoxShadowBlurRadius,
1518
1888
  newProps.initialClipBoxShadowSpreadDistance,
1889
+ newProps.initialRotation,
1890
+ newProps.initialOpacity,
1519
1891
  &initial)) {
1520
1892
  // The initial presentation is atomic. Never apply a valid subset when one
1521
1893
  // field rejects.
@@ -1537,6 +1909,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1537
1909
  }
1538
1910
 
1539
1911
  - (void)layoutContentContainer {
1912
+ [self writeAppearanceRotation:_canonicalRotation opacity:_canonicalOpacity rect:_canonicalClip];
1540
1913
  _contentContainer.layer.bounds = CGRectMake(
1541
1914
  0, 0, MAX(0, self.bounds.size.width), MAX(0, self.bounds.size.height));
1542
1915
  _contentContainer.layer.position = CGPointMake(
@@ -1564,6 +1937,12 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1564
1937
  const CGRect clip = _activeAnimationId != 0
1565
1938
  ? [self presentationRectWithRadii:&radii]
1566
1939
  : _canonicalClip;
1940
+ if ([self visibleOpacity] <= 0) return NO;
1941
+ const double rotation = [self visibleRotation];
1942
+ const CGPoint center = CGPointMake(CGRectGetMidX(clip), CGRectGetMidY(clip));
1943
+ const double dx = point.x - center.x, dy = point.y - center.y;
1944
+ point = CGPointMake(center.x + cos(rotation) * dx + sin(rotation) * dy,
1945
+ center.y - sin(rotation) * dx + cos(rotation) * dy);
1567
1946
  if (CGRectIsEmpty(clip) || !CGRectContainsPoint(clip, point)) return NO;
1568
1947
  CGPathRef path = nil;
1569
1948
  if (_clipContainer.layer.mask == _unequalCornerMask) {
@@ -1606,7 +1985,9 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1606
1985
  shadowOffsetX:(double)shadowOffsetX
1607
1986
  shadowOffsetY:(double)shadowOffsetY
1608
1987
  shadowBlurRadius:(double)shadowBlurRadius
1609
- shadowSpreadDistance:(double)shadowSpreadDistance {
1988
+ shadowSpreadDistance:(double)shadowSpreadDistance
1989
+ rotation:(double)rotation
1990
+ opacity:(double)opacity {
1610
1991
  smoothclip::Presentation presentation{};
1611
1992
  if (!SmoothClipBuildPresentation(
1612
1993
  x,
@@ -1630,6 +2011,8 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1630
2011
  shadowOffsetY,
1631
2012
  shadowBlurRadius,
1632
2013
  shadowSpreadDistance,
2014
+ rotation,
2015
+ opacity,
1633
2016
  &presentation)) {
1634
2017
  return;
1635
2018
  }
@@ -1661,6 +2044,10 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1661
2044
  _canonicalContentScale = 1;
1662
2045
  _requestedShadow = {};
1663
2046
  _canonicalShadow = {};
2047
+ _requestedRotation = _canonicalRotation = 0;
2048
+ _requestedOpacity = _canonicalOpacity = 1;
2049
+ _presentationContainer.layer.affineTransform = CGAffineTransformIdentity;
2050
+ _presentationContainer.layer.opacity = 1;
1664
2051
  _hasLayout = NO;
1665
2052
  _commandIsAuthoritative = NO;
1666
2053
 
@@ -1672,6 +2059,7 @@ static smoothclip::Presentation SmoothClipCanonicalSnapshot(
1672
2059
  layer.mask = nil;
1673
2060
  _unequalCornerMask.path = nil;
1674
2061
  _contentContainer.layer.affineTransform = CGAffineTransformIdentity;
2062
+ _hasShadowModel = NO;
1675
2063
  _shadowLayer.shadowPath = nil;
1676
2064
  _shadowLayer.shadowOpacity = 0;
1677
2065
  _shadowLayer.shadowRadius = 0;