react-native-smooth-clip-view 0.1.0 → 0.2.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 (113) hide show
  1. package/README.md +164 -43
  2. package/SmoothClipView.podspec +8 -2
  3. package/android/CMakeLists.txt +39 -0
  4. package/android/build.gradle +69 -2
  5. package/android/proguard-rules.pro +6 -0
  6. package/android/src/main/cpp/SmoothClipAndroid.h +59 -0
  7. package/android/src/main/cpp/SmoothClipBindings.cpp +428 -0
  8. package/android/src/main/cpp/SmoothClipRegistry.cpp +913 -0
  9. package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +25 -0
  10. package/android/src/main/java/com/smoothclipview/SmoothClipBindings.kt +55 -0
  11. package/android/src/main/java/com/smoothclipview/SmoothClipModule.kt +128 -0
  12. package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +164 -30
  13. package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +107 -10
  14. package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +18 -2
  15. package/cpp/SmoothClipRegistry.h +93 -0
  16. package/cpp/SmoothClipSharedGeometry.h +50 -0
  17. package/ios/SmoothClipModuleProvider.h +9 -0
  18. package/ios/SmoothClipModuleProvider.mm +16 -0
  19. package/ios/SmoothClipRegistry.mm +717 -0
  20. package/ios/SmoothClipTurboModule.cpp +290 -0
  21. package/ios/SmoothClipTurboModule.h +132 -0
  22. package/ios/SmoothClipView.mm +976 -85
  23. package/ios/SmoothClipViewRegistry.h +27 -0
  24. package/lib/module/NativeSmoothClipModule.js +5 -0
  25. package/lib/module/NativeSmoothClipModule.js.map +1 -0
  26. package/lib/module/SmoothClipView.android.js +4 -0
  27. package/lib/module/SmoothClipView.android.js.map +1 -0
  28. package/lib/module/SmoothClipView.ios.js +34 -0
  29. package/lib/module/SmoothClipView.ios.js.map +1 -0
  30. package/lib/module/SmoothClipView.js +26 -12
  31. package/lib/module/SmoothClipView.js.map +1 -1
  32. package/lib/module/SmoothClipView.web.js +28 -12
  33. package/lib/module/SmoothClipView.web.js.map +1 -1
  34. package/lib/module/SmoothClipViewNativeComponent.ts +14 -1
  35. package/lib/module/driverState.js +102 -0
  36. package/lib/module/driverState.js.map +1 -0
  37. package/lib/module/driverTypes.js +4 -0
  38. package/lib/module/driverTypes.js.map +1 -0
  39. package/lib/module/drivers.android.js +4 -0
  40. package/lib/module/drivers.android.js.map +1 -0
  41. package/lib/module/drivers.ios.js +380 -0
  42. package/lib/module/drivers.ios.js.map +1 -0
  43. package/lib/module/drivers.js +276 -0
  44. package/lib/module/drivers.js.map +1 -0
  45. package/lib/module/geometry.js +19 -0
  46. package/lib/module/geometry.js.map +1 -1
  47. package/lib/module/index.js +2 -0
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/index.web.js +2 -0
  50. package/lib/module/index.web.js.map +1 -1
  51. package/lib/module/nativeCompletion.js +21 -0
  52. package/lib/module/nativeCompletion.js.map +1 -0
  53. package/lib/module/reactRequests.js +50 -0
  54. package/lib/module/reactRequests.js.map +1 -0
  55. package/lib/module/smoothClipNative.android.js +15 -0
  56. package/lib/module/smoothClipNative.android.js.map +1 -0
  57. package/lib/module/smoothClipNative.js +8 -0
  58. package/lib/module/smoothClipNative.js.map +1 -0
  59. package/lib/typescript/src/NativeSmoothClipModule.d.ts +19 -0
  60. package/lib/typescript/src/NativeSmoothClipModule.d.ts.map +1 -0
  61. package/lib/typescript/src/SmoothClipView.android.d.ts +3 -0
  62. package/lib/typescript/src/SmoothClipView.android.d.ts.map +1 -0
  63. package/lib/typescript/src/SmoothClipView.d.ts +5 -6
  64. package/lib/typescript/src/SmoothClipView.d.ts.map +1 -1
  65. package/lib/typescript/src/SmoothClipView.ios.d.ts +9 -0
  66. package/lib/typescript/src/SmoothClipView.ios.d.ts.map +1 -0
  67. package/lib/typescript/src/SmoothClipView.web.d.ts +5 -7
  68. package/lib/typescript/src/SmoothClipView.web.d.ts.map +1 -1
  69. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +4 -0
  70. package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -1
  71. package/lib/typescript/src/driverState.d.ts +31 -0
  72. package/lib/typescript/src/driverState.d.ts.map +1 -0
  73. package/lib/typescript/src/driverTypes.d.ts +61 -0
  74. package/lib/typescript/src/driverTypes.d.ts.map +1 -0
  75. package/lib/typescript/src/drivers.android.d.ts +3 -0
  76. package/lib/typescript/src/drivers.android.d.ts.map +1 -0
  77. package/lib/typescript/src/drivers.d.ts +5 -0
  78. package/lib/typescript/src/drivers.d.ts.map +1 -0
  79. package/lib/typescript/src/drivers.ios.d.ts +5 -0
  80. package/lib/typescript/src/drivers.ios.d.ts.map +1 -0
  81. package/lib/typescript/src/geometry.d.ts +8 -0
  82. package/lib/typescript/src/geometry.d.ts.map +1 -1
  83. package/lib/typescript/src/index.d.ts +4 -1
  84. package/lib/typescript/src/index.d.ts.map +1 -1
  85. package/lib/typescript/src/index.web.d.ts +4 -1
  86. package/lib/typescript/src/index.web.d.ts.map +1 -1
  87. package/lib/typescript/src/nativeCompletion.d.ts +7 -0
  88. package/lib/typescript/src/nativeCompletion.d.ts.map +1 -0
  89. package/lib/typescript/src/reactRequests.d.ts +7 -0
  90. package/lib/typescript/src/reactRequests.d.ts.map +1 -0
  91. package/lib/typescript/src/smoothClipNative.android.d.ts +4 -0
  92. package/lib/typescript/src/smoothClipNative.android.d.ts.map +1 -0
  93. package/lib/typescript/src/smoothClipNative.d.ts +3 -0
  94. package/lib/typescript/src/smoothClipNative.d.ts.map +1 -0
  95. package/package.json +12 -7
  96. package/src/NativeSmoothClipModule.ts +104 -0
  97. package/src/SmoothClipView.android.tsx +2 -0
  98. package/src/SmoothClipView.ios.tsx +36 -0
  99. package/src/SmoothClipView.tsx +31 -27
  100. package/src/SmoothClipView.web.tsx +26 -22
  101. package/src/SmoothClipViewNativeComponent.ts +14 -1
  102. package/src/driverState.ts +150 -0
  103. package/src/driverTypes.ts +91 -0
  104. package/src/drivers.android.ts +6 -0
  105. package/src/drivers.ios.ts +665 -0
  106. package/src/drivers.ts +416 -0
  107. package/src/geometry.ts +39 -0
  108. package/src/index.ts +19 -1
  109. package/src/index.web.ts +19 -1
  110. package/src/nativeCompletion.ts +37 -0
  111. package/src/reactRequests.ts +64 -0
  112. package/src/smoothClipNative.android.ts +20 -0
  113. package/src/smoothClipNative.ts +5 -0
@@ -1,26 +1,97 @@
1
1
  #import "SmoothClipView.h"
2
+
2
3
  #import "SmoothClipGeometry.h"
4
+ #import "SmoothClipViewRegistry.h"
3
5
 
4
6
  #import <QuartzCore/QuartzCore.h>
5
7
  #import <React/RCTConversions.h>
6
8
  #import <React/RCTFabricComponentsPlugins.h>
9
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
10
+ #import <os/signpost.h>
11
+ #endif
7
12
  #import <react/renderer/components/SmoothClipViewSpec/ComponentDescriptors.h>
8
13
  #import <react/renderer/components/SmoothClipViewSpec/Props.h>
9
14
  #import <react/renderer/components/SmoothClipViewSpec/RCTComponentViewHelpers.h>
10
15
 
16
+ #include <cfloat>
17
+
11
18
  using namespace facebook::react;
12
19
 
20
+ @class SmoothClipView;
21
+
22
+ @interface SmoothClipAnimationDelegate : NSObject <CAAnimationDelegate>
23
+ @property(nonatomic, weak) SmoothClipView *view;
24
+ @property(nonatomic, assign) uint64_t driverId;
25
+ @property(nonatomic, assign) int32_t animationId;
26
+ // Set when the animation is torn down deliberately. The running CAAnimation
27
+ // copies share this delegate object, so this suppresses their didStop even
28
+ // when Core Animation delivers it after the teardown returns.
29
+ @property(nonatomic, assign) BOOL invalidated;
30
+ @end
31
+
32
+ @interface SmoothClipContainerView : UIView
33
+ @end
34
+
35
+ @implementation SmoothClipContainerView
36
+
37
+ - (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
38
+ // Geometry is always driven explicitly, either by a HostFunction update or
39
+ // by the animations installed below. Avoid a CATransaction on every frame.
40
+ return (id<CAAction>)[NSNull null];
41
+ }
42
+
43
+ @end
44
+
13
45
  @interface SmoothClipView () <RCTSmoothClipViewViewProtocol>
46
+ - (void)smoothClipAnimationDidStopWithDriverId:(uint64_t)driverId
47
+ animationId:(int32_t)animationId
48
+ finished:(BOOL)finished;
14
49
  @end
15
50
 
16
51
  @implementation SmoothClipView {
17
- UIView *_clipContainer;
52
+ SmoothClipContainerView *_clipContainer;
53
+ SmoothClipContainerView *_contentContainer;
18
54
  CGRect _requestedClip;
19
55
  CGRect _normalizedClip;
20
56
  CGFloat _requestedRadius;
21
57
  CGFloat _normalizedRadius;
58
+ CGPoint _requestedContentTranslation;
59
+ CGPoint _normalizedContentTranslation;
60
+ uint64_t _driverId;
22
61
  BOOL _hasLayout;
23
62
  BOOL _commandIsAuthoritative;
63
+ // Mirrors _clipContainer.hidden/.accessibilityElementsHidden so the hot
64
+ // path can skip UIKit getters; every write goes through setClipContainerHidden.
65
+ BOOL _clipHidden;
66
+ BOOL _ignoreAnimationCallback;
67
+
68
+ int32_t _activeAnimationId;
69
+ NSInteger _activeAnimationKind;
70
+ // Set when a native animation install arrived before the first layout.
71
+ // The install is deferred and re-run through the registry at first layout
72
+ // so it joins from live presentation geometry with the true remaining time.
73
+ BOOL _pendingAnimationInstall;
74
+ CFTimeInterval _animationStartedAt;
75
+ CFTimeInterval _animationDuration;
76
+ smoothclip::TimingAnimation _timingAnimation;
77
+ smoothclip::SpringAnimation _springAnimation;
78
+ SmoothClipAnimationDelegate *_animationDelegate;
79
+ BOOL _animationPaused;
80
+ CGRect _pausedRect;
81
+ CGFloat _pausedRadius;
82
+ CGPoint _pausedContentTranslation;
83
+ CFTimeInterval _pausedRemaining;
84
+
85
+ BOOL _hasPreviousInteractiveSample;
86
+ BOOL _hasInteractiveSample;
87
+ CGRect _previousInteractiveRect;
88
+ CGRect _interactiveRect;
89
+ CGFloat _previousInteractiveRadius;
90
+ CGFloat _interactiveRadius;
91
+ CGPoint _previousInteractiveContentTranslation;
92
+ CGPoint _interactiveContentTranslation;
93
+ CFTimeInterval _previousInteractiveTime;
94
+ CFTimeInterval _interactiveTime;
24
95
  }
25
96
 
26
97
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -31,110 +102,790 @@ using namespace facebook::react;
31
102
  return NO;
32
103
  }
33
104
 
105
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
106
+ + (os_log_t)signpostLog {
107
+ static os_log_t log = os_log_create(
108
+ "com.smoothclipview", "clip-view");
109
+ return log;
110
+ }
111
+ #endif
112
+
34
113
  - (instancetype)initWithFrame:(CGRect)frame {
35
114
  if (self = [super initWithFrame:frame]) {
36
115
  static const auto defaultProps =
37
116
  std::make_shared<const SmoothClipViewProps>();
38
117
  _props = defaultProps;
39
118
 
40
- _clipContainer = [[UIView alloc] initWithFrame:CGRectZero];
41
- _clipContainer.clipsToBounds = YES;
119
+ _clipContainer = [[SmoothClipContainerView alloc] initWithFrame:CGRectZero];
120
+ _clipContainer.layer.masksToBounds = YES;
121
+ _clipContainer.layer.needsDisplayOnBoundsChange = NO;
122
+ _clipContainer.autoresizesSubviews = NO;
42
123
  _clipContainer.hidden = YES;
43
124
  _clipContainer.accessibilityElementsHidden = YES;
125
+ _clipHidden = YES;
126
+ _contentContainer =
127
+ [[SmoothClipContainerView alloc] initWithFrame:CGRectZero];
128
+ _contentContainer.layer.needsDisplayOnBoundsChange = NO;
129
+ _contentContainer.autoresizesSubviews = NO;
130
+ [_clipContainer addSubview:_contentContainer];
44
131
  [self addSubview:_clipContainer];
45
132
 
46
133
  _requestedClip = CGRectZero;
47
134
  _normalizedClip = CGRectZero;
48
135
  _requestedRadius = 0;
49
136
  _normalizedRadius = 0;
137
+ _requestedContentTranslation = CGPointZero;
138
+ _normalizedContentTranslation = CGPointZero;
139
+ _driverId = 0;
50
140
  _hasLayout = NO;
51
141
  _commandIsAuthoritative = NO;
142
+ _ignoreAnimationCallback = NO;
143
+ _activeAnimationId = 0;
144
+ _activeAnimationKind = 0;
145
+ _pendingAnimationInstall = NO;
146
+ _animationPaused = NO;
147
+ _hasPreviousInteractiveSample = NO;
148
+ _hasInteractiveSample = NO;
149
+ [[NSNotificationCenter defaultCenter]
150
+ addObserver:self
151
+ selector:@selector(applicationWillResignActive)
152
+ name:UIApplicationWillResignActiveNotification
153
+ object:nil];
154
+ [[NSNotificationCenter defaultCenter]
155
+ addObserver:self
156
+ selector:@selector(applicationDidBecomeActive)
157
+ name:UIApplicationDidBecomeActiveNotification
158
+ object:nil];
52
159
  }
53
160
  return self;
54
161
  }
55
162
 
163
+ - (void)dealloc {
164
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
165
+ if (_driverId != 0) {
166
+ smoothclip::unregisterView(_driverId, self);
167
+ }
168
+ }
169
+
56
170
  - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
57
171
  index:(NSInteger)index {
58
172
  NSAssert(childComponentView.superview == nil,
59
173
  @"SmoothClipView attempted to mount an already-mounted child");
60
- [_clipContainer insertSubview:childComponentView atIndex:index];
174
+ [_contentContainer insertSubview:childComponentView atIndex:index];
61
175
  }
62
176
 
63
177
  - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView
64
178
  index:(NSInteger)index {
65
- NSAssert(childComponentView.superview == _clipContainer,
179
+ NSAssert(childComponentView.superview == _contentContainer,
66
180
  @"SmoothClipView attempted to unmount a child from another parent");
67
- NSAssert(index < _clipContainer.subviews.count &&
68
- _clipContainer.subviews[index] == childComponentView,
181
+ NSAssert(index < _contentContainer.subviews.count &&
182
+ _contentContainer.subviews[index] == childComponentView,
69
183
  @"SmoothClipView child index mismatch");
70
184
  [childComponentView removeFromSuperview];
71
185
  }
72
186
 
73
- - (void)setRequestedClipX:(CGFloat)x
74
- y:(CGFloat)y
75
- width:(CGFloat)width
76
- height:(CGFloat)height
77
- radius:(CGFloat)radius {
78
- if (!isfinite(x) || !isfinite(y) || !isfinite(width) ||
79
- !isfinite(height) || !isfinite(radius)) {
80
- return;
187
+ - (void)storeRequestedPresentation:(smoothclip::Presentation)presentation {
188
+ const smoothclip::Geometry geometry = presentation.clip;
189
+ _requestedClip = CGRectMake(
190
+ geometry.x,
191
+ geometry.y,
192
+ MAX(0, geometry.width),
193
+ MAX(0, geometry.height));
194
+ _requestedRadius = MAX(0, geometry.radius);
195
+ _requestedContentTranslation = CGPointMake(
196
+ presentation.contentTranslateX,
197
+ presentation.contentTranslateY);
198
+ }
199
+
200
+ - (BOOL)normalizedRequestedGeometry:(SmoothNormalizedClipGeometry *)geometry {
201
+ if (!_hasLayout) return NO;
202
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
203
+ const bool signpostsEnabled =
204
+ os_signpost_enabled([SmoothClipView signpostLog]);
205
+ os_signpost_id_t identifier = OS_SIGNPOST_ID_NULL;
206
+ if (signpostsEnabled) {
207
+ identifier = os_signpost_id_generate([SmoothClipView signpostLog]);
208
+ os_signpost_interval_begin(
209
+ [SmoothClipView signpostLog], identifier, "normalization");
81
210
  }
211
+ #endif
212
+ const CGSize hostSize = self.bounds.size;
213
+ const BOOL valid = SmoothClipNormalizeGeometry(
214
+ CGRectGetMinX(_requestedClip),
215
+ CGRectGetMinY(_requestedClip),
216
+ CGRectGetWidth(_requestedClip),
217
+ CGRectGetHeight(_requestedClip),
218
+ _requestedRadius,
219
+ CGSizeMake(MAX(0, hostSize.width), MAX(0, hostSize.height)),
220
+ geometry);
221
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
222
+ if (signpostsEnabled) {
223
+ os_signpost_interval_end(
224
+ [SmoothClipView signpostLog], identifier, "normalization");
225
+ }
226
+ #endif
227
+ return valid;
228
+ }
82
229
 
83
- _requestedClip = CGRectMake(x, y, MAX(0, width), MAX(0, height));
84
- _requestedRadius = MAX(0, radius);
85
- [self applyRequestedClip];
230
+ - (void)setClipContainerHidden:(BOOL)hidden {
231
+ if (_clipHidden == hidden) return;
232
+ _clipHidden = hidden;
233
+ _clipContainer.hidden = hidden;
234
+ _clipContainer.accessibilityElementsHidden = hidden;
235
+ }
236
+
237
+ - (void)syncVisibilityForRect:(CGRect)rect {
238
+ [self setClipContainerHidden:CGRectIsEmpty(rect)];
239
+ }
240
+
241
+ - (void)writeLayerRect:(CGRect)rect radius:(CGFloat)radius {
242
+ const BOOL rectChanged = !CGRectEqualToRect(_normalizedClip, rect);
243
+ const BOOL radiusChanged = _normalizedRadius != radius;
244
+ _normalizedClip = rect;
245
+ _normalizedRadius = radius;
246
+ if (!rectChanged && !radiusChanged) return;
247
+
248
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
249
+ const bool signpostsEnabled =
250
+ os_signpost_enabled([SmoothClipView signpostLog]);
251
+ os_signpost_id_t identifier = OS_SIGNPOST_ID_NULL;
252
+ if (signpostsEnabled) {
253
+ identifier = os_signpost_id_generate([SmoothClipView signpostLog]);
254
+ os_signpost_interval_begin(
255
+ [SmoothClipView signpostLog], identifier, "layer-application");
256
+ }
257
+ #endif
258
+ CALayer *layer = _clipContainer.layer;
259
+ if (rectChanged) {
260
+ layer.bounds = rect;
261
+ layer.position = CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect));
262
+ }
263
+ if (radiusChanged) {
264
+ layer.cornerRadius = radius;
265
+ }
266
+ #if defined(SMOOTH_CLIP_ENABLE_SIGNPOSTS) && SMOOTH_CLIP_ENABLE_SIGNPOSTS
267
+ if (signpostsEnabled) {
268
+ os_signpost_interval_end(
269
+ [SmoothClipView signpostLog], identifier, "layer-application");
270
+ }
271
+ #endif
272
+ }
273
+
274
+ - (void)writeContentTranslation:(CGPoint)translation {
275
+ if (CGPointEqualToPoint(_normalizedContentTranslation, translation)) return;
276
+ _normalizedContentTranslation = translation;
277
+ _contentContainer.layer.affineTransform =
278
+ CGAffineTransformMakeTranslation(translation.x, translation.y);
86
279
  }
87
280
 
88
281
  - (void)applyRequestedClip {
89
- if (!_hasLayout) {
282
+ SmoothNormalizedClipGeometry geometry;
283
+ if (![self normalizedRequestedGeometry:&geometry]) return;
284
+
285
+ const BOOL visibilityChanged = _clipHidden != CGRectIsEmpty(geometry.rect);
286
+ if (CGRectEqualToRect(_normalizedClip, geometry.rect) &&
287
+ _normalizedRadius == geometry.radius &&
288
+ CGPointEqualToPoint(
289
+ _normalizedContentTranslation, _requestedContentTranslation) &&
290
+ !visibilityChanged) {
90
291
  return;
91
292
  }
293
+ [self writeLayerRect:geometry.rect radius:geometry.radius];
294
+ [self writeContentTranslation:_requestedContentTranslation];
295
+ [self syncVisibilityForRect:geometry.rect];
296
+ }
92
297
 
93
- const CGFloat hostWidth = MAX(0, self.bounds.size.width);
94
- const CGFloat hostHeight = MAX(0, self.bounds.size.height);
95
- SmoothNormalizedClipGeometry geometry;
96
- if (!SmoothClipNormalizeGeometry(
97
- CGRectGetMinX(_requestedClip),
98
- CGRectGetMinY(_requestedClip),
99
- CGRectGetWidth(_requestedClip),
100
- CGRectGetHeight(_requestedClip),
101
- _requestedRadius,
102
- CGSizeMake(hostWidth, hostHeight),
103
- &geometry)) {
298
+ - (void)recordInteractiveRect:(CGRect)rect
299
+ radius:(CGFloat)radius
300
+ contentTranslation:(CGPoint)contentTranslation {
301
+ if (_hasInteractiveSample &&
302
+ CGRectEqualToRect(_interactiveRect, rect) &&
303
+ _interactiveRadius == radius &&
304
+ CGPointEqualToPoint(
305
+ _interactiveContentTranslation, contentTranslation)) {
104
306
  return;
105
307
  }
308
+ if (_hasInteractiveSample) {
309
+ _hasPreviousInteractiveSample = YES;
310
+ _previousInteractiveRect = _interactiveRect;
311
+ _previousInteractiveRadius = _interactiveRadius;
312
+ _previousInteractiveContentTranslation =
313
+ _interactiveContentTranslation;
314
+ _previousInteractiveTime = _interactiveTime;
315
+ }
316
+ _hasInteractiveSample = YES;
317
+ _interactiveRect = rect;
318
+ _interactiveRadius = radius;
319
+ _interactiveContentTranslation = contentTranslation;
320
+ _interactiveTime = CACurrentMediaTime();
321
+ }
322
+
323
+ - (CGFloat)inheritedVelocityToRect:(CGRect)target
324
+ radius:(CGFloat)targetRadius
325
+ contentTranslation:(CGPoint)targetContentTranslation {
326
+ if (!_hasPreviousInteractiveSample || !_hasInteractiveSample) return 0;
327
+ const CFTimeInterval elapsed =
328
+ _interactiveTime - _previousInteractiveTime;
329
+ if (elapsed <= 0 || CACurrentMediaTime() - _interactiveTime > 0.1) return 0;
330
+
331
+ const CGFloat current[] = {
332
+ CGRectGetMinX(_interactiveRect),
333
+ CGRectGetMinY(_interactiveRect),
334
+ CGRectGetWidth(_interactiveRect),
335
+ CGRectGetHeight(_interactiveRect),
336
+ _interactiveRadius,
337
+ _interactiveContentTranslation.x,
338
+ _interactiveContentTranslation.y};
339
+ const CGFloat previous[] = {
340
+ CGRectGetMinX(_previousInteractiveRect),
341
+ CGRectGetMinY(_previousInteractiveRect),
342
+ CGRectGetWidth(_previousInteractiveRect),
343
+ CGRectGetHeight(_previousInteractiveRect),
344
+ _previousInteractiveRadius,
345
+ _previousInteractiveContentTranslation.x,
346
+ _previousInteractiveContentTranslation.y};
347
+ const CGFloat destination[] = {
348
+ CGRectGetMinX(target),
349
+ CGRectGetMinY(target),
350
+ CGRectGetWidth(target),
351
+ CGRectGetHeight(target),
352
+ targetRadius,
353
+ targetContentTranslation.x,
354
+ targetContentTranslation.y};
355
+ double numerator = 0;
356
+ double denominator = 0;
357
+ for (NSUInteger index = 0; index < 7; index += 1) {
358
+ const double velocity = (current[index] - previous[index]) / elapsed;
359
+ const double displacement = destination[index] - current[index];
360
+ numerator += velocity * displacement;
361
+ denominator += displacement * displacement;
362
+ }
363
+ if (denominator <= DBL_EPSILON) return 0;
364
+ const double result = numerator / denominator;
365
+ return isfinite(result) ? result : 0;
366
+ }
367
+
368
+ - (void)smoothClipApplyPresentation:(smoothclip::Presentation)presentation {
369
+ _animationPaused = NO;
370
+ [self stopLayerAnimationWithoutCallback];
371
+ [self storeRequestedPresentation:presentation];
372
+ [self applyRequestedClip];
373
+ if (_hasLayout) {
374
+ [self recordInteractiveRect:_normalizedClip
375
+ radius:_normalizedRadius
376
+ contentTranslation:_normalizedContentTranslation];
377
+ }
378
+ }
379
+
380
+ - (smoothclip::Presentation)smoothClipCurrentPresentation {
381
+ CGFloat visibleRadius = _normalizedRadius;
382
+ const CGRect visibleRect = _activeAnimationId != 0
383
+ ? [self presentationRectWithRadius:&visibleRadius]
384
+ : _normalizedClip;
385
+ CALayer *contentLayer = _activeAnimationId != 0
386
+ ? (CALayer *)_contentContainer.layer.presentationLayer
387
+ : _contentContainer.layer;
388
+ if (contentLayer == nil) contentLayer = _contentContainer.layer;
389
+ const CGAffineTransform transform = contentLayer.affineTransform;
390
+ return {
391
+ {CGRectGetMinX(visibleRect),
392
+ CGRectGetMinY(visibleRect),
393
+ CGRectGetWidth(visibleRect),
394
+ CGRectGetHeight(visibleRect),
395
+ visibleRadius},
396
+ transform.tx,
397
+ transform.ty};
398
+ }
399
+
400
+ - (BOOL)smoothClipIsJoinable {
401
+ // A view without layout reports zero geometry from
402
+ // smoothClipCurrentPresentation and must not be used as a join reference.
403
+ return _hasLayout;
404
+ }
405
+
406
+ - (smoothclip::Presentation)smoothClipFreezePresentation {
407
+ const smoothclip::Presentation visible =
408
+ [self smoothClipCurrentPresentation];
409
+ const CGRect visibleRect = CGRectMake(
410
+ visible.clip.x,
411
+ visible.clip.y,
412
+ visible.clip.width,
413
+ visible.clip.height);
414
+ const CGPoint visibleContentTranslation = CGPointMake(
415
+ visible.contentTranslateX,
416
+ visible.contentTranslateY);
417
+ _animationPaused = NO;
418
+ [self stopLayerAnimationWithoutCallback];
419
+ _requestedClip = visibleRect;
420
+ _requestedRadius = visible.clip.radius;
421
+ _requestedContentTranslation = visibleContentTranslation;
422
+ [self writeLayerRect:visibleRect radius:visible.clip.radius];
423
+ [self writeContentTranslation:visibleContentTranslation];
424
+ [self syncVisibilityForRect:visibleRect];
425
+ [self recordInteractiveRect:visibleRect
426
+ radius:visible.clip.radius
427
+ contentTranslation:visibleContentTranslation];
428
+ return visible;
429
+ }
430
+
431
+ - (CGRect)presentationRectWithRadius:(CGFloat *)radius {
432
+ CALayer *layer = (CALayer *)_clipContainer.layer.presentationLayer;
433
+ if (layer == nil) layer = _clipContainer.layer;
434
+ const CGRect bounds = layer.bounds;
435
+ const CGPoint position = layer.position;
436
+ if (radius != nullptr) *radius = layer.cornerRadius;
437
+ return CGRectMake(
438
+ position.x - bounds.size.width * layer.anchorPoint.x,
439
+ position.y - bounds.size.height * layer.anchorPoint.y,
440
+ bounds.size.width,
441
+ bounds.size.height);
442
+ }
443
+
444
+ - (void)stopLayerAnimationWithoutCallback {
445
+ _pendingAnimationInstall = NO;
446
+ if (_activeAnimationId == 0) return;
447
+ // Invalidate before removing: CA may deliver didStop for the removed
448
+ // animation after this method returns, when _ignoreAnimationCallback has
449
+ // already been reset — and the id-preserving reinstall paths reuse the
450
+ // same animation id, so the id check alone cannot filter it.
451
+ _animationDelegate.invalidated = YES;
452
+ _ignoreAnimationCallback = YES;
453
+ [_clipContainer.layer removeAnimationForKey:@"smoothClip.geometry"];
454
+ [_contentContainer.layer removeAnimationForKey:@"smoothClip.content"];
455
+ _ignoreAnimationCallback = NO;
456
+ _activeAnimationId = 0;
457
+ _activeAnimationKind = 0;
458
+ _animationPaused = NO;
459
+ _animationDelegate = nil;
460
+ }
461
+
462
+ - (CABasicAnimation *)basicAnimationForKeyPath:(NSString *)keyPath
463
+ fromValue:(id)fromValue
464
+ toValue:(id)toValue
465
+ timingFunction:(CAMediaTimingFunction *)timing {
466
+ CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:keyPath];
467
+ animation.fromValue = fromValue;
468
+ animation.toValue = toValue;
469
+ animation.timingFunction = timing;
470
+ return animation;
471
+ }
472
+
473
+ - (CFTimeInterval)springSettlingDurationWithVelocity:(double)velocity {
474
+ // Every key path in the group shares mass/stiffness/damping and the same
475
+ // normalized initialVelocity, so one settling solve covers all of them.
476
+ CASpringAnimation *probe =
477
+ [CASpringAnimation animationWithKeyPath:@"bounds.origin.x"];
478
+ probe.mass = _springAnimation.mass;
479
+ probe.stiffness = _springAnimation.stiffness;
480
+ probe.damping = _springAnimation.damping;
481
+ probe.initialVelocity = velocity;
482
+ return probe.settlingDuration;
483
+ }
484
+
485
+ - (CASpringAnimation *)springAnimationForKeyPath:(NSString *)keyPath
486
+ fromValue:(id)fromValue
487
+ toValue:(id)toValue
488
+ velocity:(double)velocity
489
+ duration:(CFTimeInterval)duration {
490
+ CASpringAnimation *animation =
491
+ [CASpringAnimation animationWithKeyPath:keyPath];
492
+ animation.fromValue = fromValue;
493
+ animation.toValue = toValue;
494
+ animation.mass = _springAnimation.mass;
495
+ animation.stiffness = _springAnimation.stiffness;
496
+ animation.damping = _springAnimation.damping;
497
+ animation.initialVelocity = velocity;
498
+ animation.duration = duration;
499
+ return animation;
500
+ }
501
+
502
+ - (void)installAnimationFromRect:(CGRect)fromRect
503
+ fromRadius:(CGFloat)fromRadius
504
+ fromContentTranslation:(CGPoint)fromContentTranslation
505
+ toRect:(CGRect)toRect
506
+ toRadius:(CGFloat)toRadius
507
+ toContentTranslation:(CGPoint)toContentTranslation
508
+ duration:(CFTimeInterval)duration {
509
+ const int32_t animationId = _activeAnimationId;
510
+ const NSInteger animationKind = _activeAnimationKind;
511
+ [self stopLayerAnimationWithoutCallback];
512
+ _activeAnimationId = animationId;
513
+ _activeAnimationKind = animationKind;
514
+ // Unhide while any animated frame can show content; an empty-to-empty
515
+ // transition must stay hidden for accessibility.
516
+ if (!CGRectIsEmpty(toRect) || !CGRectIsEmpty(fromRect)) {
517
+ [self setClipContainerHidden:NO];
518
+ }
519
+
520
+ CALayer *layer = _clipContainer.layer;
521
+ const CGRect toBounds = toRect;
522
+ const CGPoint fromPosition =
523
+ CGPointMake(CGRectGetMidX(fromRect), CGRectGetMidY(fromRect));
524
+ const CGPoint toPosition =
525
+ CGPointMake(CGRectGetMidX(toRect), CGRectGetMidY(toRect));
526
+ layer.bounds = toBounds;
527
+ layer.position = toPosition;
528
+ layer.cornerRadius = toRadius;
529
+ _contentContainer.layer.affineTransform = CGAffineTransformMakeTranslation(
530
+ toContentTranslation.x,
531
+ toContentTranslation.y);
532
+ _normalizedClip = toRect;
533
+ _normalizedRadius = toRadius;
534
+ _normalizedContentTranslation = toContentTranslation;
535
+
536
+ CAAnimationGroup *group = [CAAnimationGroup animation];
537
+ CAAnimationGroup *contentGroup = [CAAnimationGroup animation];
538
+ if (_activeAnimationKind == 1) {
539
+ CAMediaTimingFunction *timing = [CAMediaTimingFunction
540
+ functionWithControlPoints:_timingAnimation.controlPoint1X
541
+ :_timingAnimation.controlPoint1Y
542
+ :_timingAnimation.controlPoint2X
543
+ :_timingAnimation.controlPoint2Y];
544
+ group.animations = @[
545
+ [self basicAnimationForKeyPath:@"bounds"
546
+ fromValue:[NSValue valueWithCGRect:fromRect]
547
+ toValue:[NSValue valueWithCGRect:toBounds]
548
+ timingFunction:timing],
549
+ [self basicAnimationForKeyPath:@"position"
550
+ fromValue:[NSValue valueWithCGPoint:fromPosition]
551
+ toValue:[NSValue valueWithCGPoint:toPosition]
552
+ timingFunction:timing],
553
+ [self basicAnimationForKeyPath:@"cornerRadius"
554
+ fromValue:@(fromRadius)
555
+ toValue:@(toRadius)
556
+ timingFunction:timing],
557
+ ];
558
+ group.duration = duration;
559
+ contentGroup.animations = @[
560
+ [self basicAnimationForKeyPath:@"transform.translation.x"
561
+ fromValue:@(fromContentTranslation.x)
562
+ toValue:@(toContentTranslation.x)
563
+ timingFunction:timing],
564
+ [self basicAnimationForKeyPath:@"transform.translation.y"
565
+ fromValue:@(fromContentTranslation.y)
566
+ toValue:@(toContentTranslation.y)
567
+ timingFunction:timing],
568
+ ];
569
+ contentGroup.duration = duration;
570
+ } else {
571
+ // `_springAnimation.initialVelocity` is the interactive motion projected
572
+ // onto the current-to-target trajectory, normalized by that distance
573
+ // (units 1/s). CASpringAnimation.initialVelocity uses the same normalized
574
+ // convention — its settlingDuration is independent of the from/to
575
+ // distance — so every key path receives the scalar unchanged. Multiplying
576
+ // by per-property deltas would overstate the launch velocity by the pixel
577
+ // distance and blow out the settling time.
578
+ const double velocity = _springAnimation.initialVelocity;
579
+ const CFTimeInterval springDuration =
580
+ [self springSettlingDurationWithVelocity:velocity];
581
+ group.animations = @[
582
+ [self springAnimationForKeyPath:@"bounds.origin.x"
583
+ fromValue:@(fromRect.origin.x)
584
+ toValue:@(toBounds.origin.x)
585
+ velocity:velocity
586
+ duration:springDuration],
587
+ [self springAnimationForKeyPath:@"bounds.origin.y"
588
+ fromValue:@(fromRect.origin.y)
589
+ toValue:@(toBounds.origin.y)
590
+ velocity:velocity
591
+ duration:springDuration],
592
+ [self springAnimationForKeyPath:@"bounds.size.width"
593
+ fromValue:@(fromRect.size.width)
594
+ toValue:@(toBounds.size.width)
595
+ velocity:velocity
596
+ duration:springDuration],
597
+ [self springAnimationForKeyPath:@"bounds.size.height"
598
+ fromValue:@(fromRect.size.height)
599
+ toValue:@(toBounds.size.height)
600
+ velocity:velocity
601
+ duration:springDuration],
602
+ [self springAnimationForKeyPath:@"position.x"
603
+ fromValue:@(fromPosition.x)
604
+ toValue:@(toPosition.x)
605
+ velocity:velocity
606
+ duration:springDuration],
607
+ [self springAnimationForKeyPath:@"position.y"
608
+ fromValue:@(fromPosition.y)
609
+ toValue:@(toPosition.y)
610
+ velocity:velocity
611
+ duration:springDuration],
612
+ [self springAnimationForKeyPath:@"cornerRadius"
613
+ fromValue:@(fromRadius)
614
+ toValue:@(toRadius)
615
+ velocity:velocity
616
+ duration:springDuration],
617
+ ];
618
+ // A rebuilt spring (layout change, foreground resume) must run its own
619
+ // settling time; clamping to the interrupted run's remaining wall-clock
620
+ // time would truncate the oscillation and snap to the target.
621
+ group.duration = springDuration;
622
+ contentGroup.animations = @[
623
+ [self springAnimationForKeyPath:@"transform.translation.x"
624
+ fromValue:@(fromContentTranslation.x)
625
+ toValue:@(toContentTranslation.x)
626
+ velocity:velocity
627
+ duration:springDuration],
628
+ [self springAnimationForKeyPath:@"transform.translation.y"
629
+ fromValue:@(fromContentTranslation.y)
630
+ toValue:@(toContentTranslation.y)
631
+ velocity:velocity
632
+ duration:springDuration],
633
+ ];
634
+ contentGroup.duration = springDuration;
635
+ }
106
636
 
107
- const BOOL rectChanged = !CGRectEqualToRect(_normalizedClip, geometry.rect);
108
- const BOOL radiusChanged = _normalizedRadius != geometry.radius;
109
- const BOOL isEmpty = CGRectIsEmpty(geometry.rect);
110
- const BOOL hiddenChanged = _clipContainer.hidden != isEmpty;
111
- const BOOL accessibilityChanged =
112
- _clipContainer.accessibilityElementsHidden != isEmpty;
637
+ _animationDuration = group.duration;
638
+ _animationStartedAt = CACurrentMediaTime();
639
+ _animationDelegate = [SmoothClipAnimationDelegate new];
640
+ _animationDelegate.view = self;
641
+ _animationDelegate.driverId = _driverId;
642
+ _animationDelegate.animationId = _activeAnimationId;
643
+ group.delegate = _animationDelegate;
644
+ [layer addAnimation:group forKey:@"smoothClip.geometry"];
645
+ [_contentContainer.layer addAnimation:contentGroup
646
+ forKey:@"smoothClip.content"];
647
+ }
113
648
 
114
- _normalizedClip = geometry.rect;
115
- _normalizedRadius = geometry.radius;
116
- if (!rectChanged && !radiusChanged && !hiddenChanged &&
117
- !accessibilityChanged) {
649
+ - (void)startAnimationToRequestedGeometryWithDuration:(CFTimeInterval)duration {
650
+ if (!_hasLayout) {
651
+ _pendingAnimationInstall = YES;
118
652
  return;
119
653
  }
654
+ SmoothNormalizedClipGeometry target;
655
+ if (![self normalizedRequestedGeometry:&target]) return;
656
+ const smoothclip::Presentation current = [self smoothClipCurrentPresentation];
657
+ [self installAnimationFromRect:CGRectMake(
658
+ current.clip.x,
659
+ current.clip.y,
660
+ current.clip.width,
661
+ current.clip.height)
662
+ fromRadius:current.clip.radius
663
+ fromContentTranslation:CGPointMake(
664
+ current.contentTranslateX,
665
+ current.contentTranslateY)
666
+ toRect:target.rect
667
+ toRadius:target.radius
668
+ toContentTranslation:_requestedContentTranslation
669
+ duration:duration];
670
+ }
120
671
 
121
- [CATransaction begin];
122
- [CATransaction setDisableActions:YES];
123
- if (rectChanged) {
124
- _clipContainer.bounds = _normalizedClip;
125
- _clipContainer.center = CGPointMake(
126
- CGRectGetMidX(_normalizedClip), CGRectGetMidY(_normalizedClip));
672
+ - (void)smoothClipAnimateTiming:(smoothclip::Presentation)presentation
673
+ animation:(smoothclip::TimingAnimation)animation
674
+ animationId:(int32_t)animationId {
675
+ [self storeRequestedPresentation:presentation];
676
+ _timingAnimation = animation;
677
+ _activeAnimationKind = 1;
678
+ _activeAnimationId = animationId;
679
+ [self startAnimationToRequestedGeometryWithDuration:
680
+ MAX(0, animation.durationMs) / 1000.0];
681
+ }
682
+
683
+ - (void)smoothClipAnimateSpring:(smoothclip::Presentation)presentation
684
+ animation:(smoothclip::SpringAnimation)animation
685
+ animationId:(int32_t)animationId {
686
+ [self storeRequestedPresentation:presentation];
687
+ _springAnimation = animation;
688
+ if (animation.inheritVelocity) {
689
+ SmoothNormalizedClipGeometry target;
690
+ if ([self normalizedRequestedGeometry:&target]) {
691
+ _springAnimation.initialVelocity =
692
+ [self inheritedVelocityToRect:target.rect
693
+ radius:target.radius
694
+ contentTranslation:_requestedContentTranslation];
695
+ } else {
696
+ _springAnimation.initialVelocity = 0;
697
+ }
127
698
  }
128
- if (radiusChanged) {
129
- _clipContainer.layer.cornerRadius = _normalizedRadius;
699
+ _activeAnimationKind = 2;
700
+ _activeAnimationId = animationId;
701
+ [self startAnimationToRequestedGeometryWithDuration:0];
702
+ }
703
+
704
+ - (CAKeyframeAnimation *)keyframeAnimationForKeyPath:(NSString *)keyPath
705
+ values:(NSArray *)values
706
+ keyTimes:(NSArray<NSNumber *> *)keyTimes {
707
+ CAKeyframeAnimation *animation =
708
+ [CAKeyframeAnimation animationWithKeyPath:keyPath];
709
+ animation.values = values;
710
+ animation.keyTimes = keyTimes;
711
+ animation.calculationMode = kCAAnimationLinear;
712
+ return animation;
713
+ }
714
+
715
+ - (void)smoothClipAnimateKeyframes:(smoothclip::Presentation)presentation
716
+ keyframes:(const std::vector<smoothclip::Keyframe> &)keyframes
717
+ durationMs:(double)durationMs
718
+ animationId:(int32_t)animationId {
719
+ if (keyframes.size() < 2) return;
720
+ [self storeRequestedPresentation:presentation];
721
+ _activeAnimationKind = 3;
722
+ _activeAnimationId = animationId;
723
+ if (!_hasLayout) {
724
+ _pendingAnimationInstall = YES;
725
+ return;
130
726
  }
131
- if (hiddenChanged) {
132
- _clipContainer.hidden = isEmpty;
727
+ [self stopLayerAnimationWithoutCallback];
728
+ _activeAnimationKind = 3;
729
+ _activeAnimationId = animationId;
730
+
731
+ const NSUInteger frameCount = keyframes.size();
732
+ const CGSize hostSize = self.bounds.size;
733
+ NSMutableArray<NSNumber *> *times =
734
+ [NSMutableArray arrayWithCapacity:frameCount];
735
+ NSMutableArray *boundsValues = [NSMutableArray arrayWithCapacity:frameCount];
736
+ NSMutableArray *positionValues =
737
+ [NSMutableArray arrayWithCapacity:frameCount];
738
+ NSMutableArray *radiusValues = [NSMutableArray arrayWithCapacity:frameCount];
739
+ NSMutableArray *translateXValues =
740
+ [NSMutableArray arrayWithCapacity:frameCount];
741
+ NSMutableArray *translateYValues =
742
+ [NSMutableArray arrayWithCapacity:frameCount];
743
+ for (const smoothclip::Keyframe &frame : keyframes) {
744
+ SmoothNormalizedClipGeometry normalized;
745
+ if (!SmoothClipNormalizeGeometry(
746
+ frame.presentation.clip.x,
747
+ frame.presentation.clip.y,
748
+ frame.presentation.clip.width,
749
+ frame.presentation.clip.height,
750
+ frame.presentation.clip.radius,
751
+ hostSize,
752
+ &normalized)) return;
753
+ [times addObject:@(frame.offset)];
754
+ [boundsValues addObject:[NSValue valueWithCGRect:normalized.rect]];
755
+ [positionValues addObject:[NSValue valueWithCGPoint:CGPointMake(
756
+ CGRectGetMidX(normalized.rect), CGRectGetMidY(normalized.rect))]];
757
+ [radiusValues addObject:@(normalized.radius)];
758
+ [translateXValues addObject:@(frame.presentation.contentTranslateX)];
759
+ [translateYValues addObject:@(frame.presentation.contentTranslateY)];
133
760
  }
134
- if (accessibilityChanged) {
135
- _clipContainer.accessibilityElementsHidden = isEmpty;
761
+
762
+ SmoothNormalizedClipGeometry target;
763
+ if (![self normalizedRequestedGeometry:&target]) return;
764
+ [self writeLayerRect:target.rect radius:target.radius];
765
+ [self writeContentTranslation:_requestedContentTranslation];
766
+ [self setClipContainerHidden:NO];
767
+
768
+ CAAnimationGroup *group = [CAAnimationGroup animation];
769
+ group.animations = @[
770
+ [self keyframeAnimationForKeyPath:@"bounds"
771
+ values:boundsValues
772
+ keyTimes:times],
773
+ [self keyframeAnimationForKeyPath:@"position"
774
+ values:positionValues
775
+ keyTimes:times],
776
+ [self keyframeAnimationForKeyPath:@"cornerRadius"
777
+ values:radiusValues
778
+ keyTimes:times],
779
+ ];
780
+ group.duration = MAX(0, durationMs) / 1000.0;
781
+ CAAnimationGroup *contentGroup = [CAAnimationGroup animation];
782
+ contentGroup.animations = @[
783
+ [self keyframeAnimationForKeyPath:@"transform.translation.x"
784
+ values:translateXValues
785
+ keyTimes:times],
786
+ [self keyframeAnimationForKeyPath:@"transform.translation.y"
787
+ values:translateYValues
788
+ keyTimes:times],
789
+ ];
790
+ contentGroup.duration = group.duration;
791
+
792
+ _animationDuration = group.duration;
793
+ _animationStartedAt = CACurrentMediaTime();
794
+ _animationDelegate = [SmoothClipAnimationDelegate new];
795
+ _animationDelegate.view = self;
796
+ _animationDelegate.driverId = _driverId;
797
+ _animationDelegate.animationId = _activeAnimationId;
798
+ group.delegate = _animationDelegate;
799
+ [_clipContainer.layer addAnimation:group forKey:@"smoothClip.geometry"];
800
+ [_contentContainer.layer addAnimation:contentGroup
801
+ forKey:@"smoothClip.content"];
802
+ }
803
+
804
+ - (void)smoothClipCancelAnimationUsingTarget:(BOOL)useTarget {
805
+ if (_activeAnimationId == 0) return;
806
+ const smoothclip::Presentation visible =
807
+ [self smoothClipCurrentPresentation];
808
+ const CGRect visibleRect = CGRectMake(
809
+ visible.clip.x,
810
+ visible.clip.y,
811
+ visible.clip.width,
812
+ visible.clip.height);
813
+ const CGPoint visibleContentTranslation = CGPointMake(
814
+ visible.contentTranslateX,
815
+ visible.contentTranslateY);
816
+ _animationPaused = NO;
817
+ [self stopLayerAnimationWithoutCallback];
818
+ if (useTarget) {
819
+ [self applyRequestedClip];
820
+ } else {
821
+ _requestedClip = visibleRect;
822
+ _requestedRadius = visible.clip.radius;
823
+ _requestedContentTranslation = visibleContentTranslation;
824
+ [self writeLayerRect:visibleRect radius:visible.clip.radius];
825
+ [self writeContentTranslation:visibleContentTranslation];
826
+ [self syncVisibilityForRect:visibleRect];
827
+ [self recordInteractiveRect:visibleRect
828
+ radius:visible.clip.radius
829
+ contentTranslation:visibleContentTranslation];
830
+ }
831
+ }
832
+
833
+ - (void)applicationWillResignActive {
834
+ if (_activeAnimationId == 0 || _animationPaused) return;
835
+ const smoothclip::Presentation visible =
836
+ [self smoothClipCurrentPresentation];
837
+ _pausedRect = CGRectMake(
838
+ visible.clip.x,
839
+ visible.clip.y,
840
+ visible.clip.width,
841
+ visible.clip.height);
842
+ _pausedRadius = visible.clip.radius;
843
+ _pausedContentTranslation = CGPointMake(
844
+ visible.contentTranslateX,
845
+ visible.contentTranslateY);
846
+ _pausedRemaining = MAX(
847
+ 0, _animationDuration - (CACurrentMediaTime() - _animationStartedAt));
848
+ const int32_t animationId = _activeAnimationId;
849
+ const NSInteger animationKind = _activeAnimationKind;
850
+ [self stopLayerAnimationWithoutCallback];
851
+ _activeAnimationId = animationId;
852
+ _activeAnimationKind = animationKind;
853
+ [self writeLayerRect:_pausedRect radius:_pausedRadius];
854
+ [self writeContentTranslation:_pausedContentTranslation];
855
+ _animationPaused = YES;
856
+ }
857
+
858
+ - (void)applicationDidBecomeActive {
859
+ if (!_animationPaused || _activeAnimationId == 0) return;
860
+ _animationPaused = NO;
861
+ SmoothNormalizedClipGeometry target;
862
+ if ([self normalizedRequestedGeometry:&target]) {
863
+ if (_activeAnimationKind == 3) {
864
+ _activeAnimationKind = 1;
865
+ _timingAnimation = {
866
+ _pausedRemaining * 1000.0, 0, 0, 1, 1, 2};
867
+ }
868
+ [self installAnimationFromRect:_pausedRect
869
+ fromRadius:_pausedRadius
870
+ fromContentTranslation:_pausedContentTranslation
871
+ toRect:target.rect
872
+ toRadius:target.radius
873
+ toContentTranslation:_requestedContentTranslation
874
+ duration:_pausedRemaining];
136
875
  }
137
- [CATransaction commit];
876
+ }
877
+
878
+ - (void)smoothClipAnimationDidStopWithDriverId:(uint64_t)driverId
879
+ animationId:(int32_t)animationId
880
+ finished:(BOOL)finished {
881
+ if (_ignoreAnimationCallback || animationId != _activeAnimationId) return;
882
+ _activeAnimationId = 0;
883
+ _activeAnimationKind = 0;
884
+ _animationPaused = NO;
885
+ _animationDelegate = nil;
886
+ [self syncVisibilityForRect:_normalizedClip];
887
+ smoothclip::viewAnimationDidStop(
888
+ driverId, animationId, self, finished);
138
889
  }
139
890
 
140
891
  - (void)updateProps:(const Props::Shared &)props
@@ -143,38 +894,143 @@ using namespace facebook::react;
143
894
  *std::static_pointer_cast<const SmoothClipViewProps>(props);
144
895
  [super updateProps:props oldProps:oldProps];
145
896
 
146
- if (!_commandIsAuthoritative) {
147
- [self setRequestedClipX:newProps.initialClipX
148
- y:newProps.initialClipY
149
- width:newProps.initialClipWidth
150
- height:newProps.initialClipHeight
151
- radius:newProps.initialClipRadius];
897
+ const uint64_t nextDriverId =
898
+ isfinite(newProps.driverId) && newProps.driverId > 0
899
+ ? static_cast<uint64_t>(newProps.driverId)
900
+ : 0;
901
+ const smoothclip::Presentation initial{
902
+ {newProps.initialClipX,
903
+ newProps.initialClipY,
904
+ newProps.initialClipWidth,
905
+ newProps.initialClipHeight,
906
+ newProps.initialClipRadius},
907
+ newProps.initialContentTranslateX,
908
+ newProps.initialContentTranslateY};
909
+
910
+ if (nextDriverId != _driverId) {
911
+ if (_driverId != 0) {
912
+ smoothclip::unregisterView(_driverId, self);
913
+ }
914
+ _driverId = nextDriverId;
915
+ _commandIsAuthoritative = NO;
916
+ if (_driverId != 0) {
917
+ smoothclip::registerView(_driverId, self, initial);
918
+ }
919
+ } else if (_driverId == 0 && !_commandIsAuthoritative) {
920
+ [self smoothClipApplyPresentation:initial];
152
921
  }
153
922
  }
154
923
 
924
+ - (void)layoutContentContainer {
925
+ _contentContainer.layer.bounds = CGRectMake(
926
+ 0, 0, MAX(0, self.bounds.size.width), MAX(0, self.bounds.size.height));
927
+ _contentContainer.layer.position = CGPointMake(
928
+ MAX(0, self.bounds.size.width) / 2,
929
+ MAX(0, self.bounds.size.height) / 2);
930
+ }
931
+
155
932
  - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
156
- oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
933
+ oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
934
+ if (!_hasLayout && _pendingAnimationInstall && _driverId != 0) {
935
+ // A native animation install arrived before the first layout. Join it now
936
+ // through the registry so it starts from live presentation geometry with
937
+ // the true remaining duration instead of jumping to the target.
938
+ [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
939
+ _hasLayout = YES;
940
+ _pendingAnimationInstall = NO;
941
+ [self layoutContentContainer];
942
+ if (!smoothclip::joinActiveAnimation(_driverId, self)) {
943
+ // The animation finished or was cancelled while awaiting layout.
944
+ _activeAnimationId = 0;
945
+ _activeAnimationKind = 0;
946
+ [self applyRequestedClip];
947
+ }
948
+ return;
949
+ }
950
+ CGFloat visibleRadius = 0;
951
+ const BOOL wasAnimating = _activeAnimationId != 0;
952
+ const smoothclip::Presentation visible = wasAnimating
953
+ ? [self smoothClipCurrentPresentation]
954
+ : smoothclip::Presentation{{0, 0, 0, 0, 0}, 0, 0};
955
+ const CGRect visibleRect = CGRectMake(
956
+ visible.clip.x,
957
+ visible.clip.y,
958
+ visible.clip.width,
959
+ visible.clip.height);
960
+ visibleRadius = visible.clip.radius;
961
+ const CGPoint visibleContentTranslation = CGPointMake(
962
+ visible.contentTranslateX,
963
+ visible.contentTranslateY);
964
+ const CFTimeInterval remaining = wasAnimating
965
+ ? MAX(0, _animationDuration -
966
+ (CACurrentMediaTime() - _animationStartedAt))
967
+ : 0;
968
+
157
969
  [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
158
970
  _hasLayout = YES;
159
- [self applyRequestedClip];
971
+ [self layoutContentContainer];
972
+ if (wasAnimating && _animationPaused) {
973
+ SmoothNormalizedClipGeometry paused;
974
+ if (SmoothClipNormalizeGeometry(
975
+ CGRectGetMinX(_pausedRect),
976
+ CGRectGetMinY(_pausedRect),
977
+ CGRectGetWidth(_pausedRect),
978
+ CGRectGetHeight(_pausedRect),
979
+ _pausedRadius,
980
+ self.bounds.size,
981
+ &paused)) {
982
+ _pausedRect = paused.rect;
983
+ _pausedRadius = paused.radius;
984
+ [self writeLayerRect:paused.rect radius:paused.radius];
985
+ }
986
+ } else if (wasAnimating) {
987
+ const int32_t animationId = _activeAnimationId;
988
+ const NSInteger animationKind = _activeAnimationKind;
989
+ [self stopLayerAnimationWithoutCallback];
990
+ _activeAnimationId = animationId;
991
+ _activeAnimationKind = animationKind;
992
+ if (_activeAnimationKind == 3) {
993
+ _activeAnimationKind = 1;
994
+ _timingAnimation = {remaining * 1000.0, 0, 0, 1, 1, 2};
995
+ }
996
+ SmoothNormalizedClipGeometry current;
997
+ SmoothClipNormalizeGeometry(
998
+ CGRectGetMinX(visibleRect),
999
+ CGRectGetMinY(visibleRect),
1000
+ CGRectGetWidth(visibleRect),
1001
+ CGRectGetHeight(visibleRect),
1002
+ visibleRadius,
1003
+ self.bounds.size,
1004
+ &current);
1005
+ SmoothNormalizedClipGeometry target;
1006
+ if ([self normalizedRequestedGeometry:&target]) {
1007
+ [self installAnimationFromRect:current.rect
1008
+ fromRadius:current.radius
1009
+ fromContentTranslation:visibleContentTranslation
1010
+ toRect:target.rect
1011
+ toRadius:target.radius
1012
+ toContentTranslation:_requestedContentTranslation
1013
+ duration:remaining];
1014
+ }
1015
+ } else {
1016
+ [self applyRequestedClip];
1017
+ }
160
1018
  }
161
1019
 
162
1020
  - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
163
- if (![super pointInside:point withEvent:event] ||
164
- CGRectIsEmpty(_normalizedClip) ||
165
- !CGRectContainsPoint(_normalizedClip, point)) {
166
- return NO;
167
- }
168
-
169
- const CGFloat radius = _normalizedRadius;
170
- if (radius <= 0) {
171
- return YES;
172
- }
1021
+ if (![super pointInside:point withEvent:event]) return NO;
1022
+ CGFloat radius = 0;
1023
+ const CGRect clip = _activeAnimationId != 0
1024
+ ? [self presentationRectWithRadius:&radius]
1025
+ : _normalizedClip;
1026
+ if (_activeAnimationId == 0) radius = _normalizedRadius;
1027
+ if (CGRectIsEmpty(clip) || !CGRectContainsPoint(clip, point)) return NO;
1028
+ if (radius <= 0) return YES;
173
1029
 
174
- const CGFloat innerLeft = CGRectGetMinX(_normalizedClip) + radius;
175
- const CGFloat innerRight = CGRectGetMaxX(_normalizedClip) - radius;
176
- const CGFloat innerTop = CGRectGetMinY(_normalizedClip) + radius;
177
- const CGFloat innerBottom = CGRectGetMaxY(_normalizedClip) - radius;
1030
+ const CGFloat innerLeft = CGRectGetMinX(clip) + radius;
1031
+ const CGFloat innerRight = CGRectGetMaxX(clip) - radius;
1032
+ const CGFloat innerTop = CGRectGetMinY(clip) + radius;
1033
+ const CGFloat innerBottom = CGRectGetMaxY(clip) - radius;
178
1034
  if ((point.x >= innerLeft && point.x <= innerRight) ||
179
1035
  (point.y >= innerTop && point.y <= innerBottom)) {
180
1036
  return YES;
@@ -187,7 +1043,7 @@ using namespace facebook::react;
187
1043
  return dx * dx + dy * dy <= radius * radius;
188
1044
  }
189
1045
 
190
- #pragma mark - Native commands
1046
+ #pragma mark - Legacy native command (Android parity and benchmark baseline)
191
1047
 
192
1048
  - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
193
1049
  RCTSmoothClipViewHandleCommand(self, commandName, args);
@@ -199,26 +1055,61 @@ using namespace facebook::react;
199
1055
  height:(double)height
200
1056
  radius:(double)radius {
201
1057
  _commandIsAuthoritative = YES;
202
- [self setRequestedClipX:x y:y width:width height:height radius:radius];
1058
+ [self smoothClipApplyPresentation:
1059
+ {{x, y, width, height, radius}, 0, 0}];
1060
+ }
1061
+
1062
+ - (void)setClipPresentation:(double)x
1063
+ y:(double)y
1064
+ width:(double)width
1065
+ height:(double)height
1066
+ radius:(double)radius
1067
+ contentTranslateX:(double)contentTranslateX
1068
+ contentTranslateY:(double)contentTranslateY {
1069
+ _commandIsAuthoritative = YES;
1070
+ [self smoothClipApplyPresentation:
1071
+ {{x, y, width, height, radius},
1072
+ contentTranslateX,
1073
+ contentTranslateY}];
203
1074
  }
204
1075
 
205
1076
  - (void)prepareForRecycle {
1077
+ if (_driverId != 0) {
1078
+ smoothclip::unregisterView(_driverId, self);
1079
+ _driverId = 0;
1080
+ }
1081
+ [self stopLayerAnimationWithoutCallback];
1082
+ _pendingAnimationInstall = NO;
1083
+ _animationPaused = NO;
1084
+ _hasPreviousInteractiveSample = NO;
1085
+ _hasInteractiveSample = NO;
206
1086
  [super prepareForRecycle];
207
1087
  _requestedClip = CGRectZero;
208
1088
  _normalizedClip = CGRectZero;
209
1089
  _requestedRadius = 0;
210
1090
  _normalizedRadius = 0;
1091
+ _requestedContentTranslation = CGPointZero;
1092
+ _normalizedContentTranslation = CGPointZero;
211
1093
  _hasLayout = NO;
212
1094
  _commandIsAuthoritative = NO;
213
1095
 
214
- [CATransaction begin];
215
- [CATransaction setDisableActions:YES];
216
- _clipContainer.bounds = CGRectZero;
217
- _clipContainer.center = CGPointZero;
218
- _clipContainer.layer.cornerRadius = 0;
219
- _clipContainer.hidden = YES;
220
- _clipContainer.accessibilityElementsHidden = YES;
221
- [CATransaction commit];
1096
+ CALayer *layer = _clipContainer.layer;
1097
+ layer.bounds = CGRectZero;
1098
+ layer.position = CGPointZero;
1099
+ layer.cornerRadius = 0;
1100
+ _contentContainer.layer.affineTransform = CGAffineTransformIdentity;
1101
+ [self setClipContainerHidden:YES];
1102
+ }
1103
+
1104
+ @end
1105
+
1106
+ @implementation SmoothClipAnimationDelegate
1107
+
1108
+ - (void)animationDidStop:(CAAnimation *)animation finished:(BOOL)finished {
1109
+ if (self.invalidated) return;
1110
+ [self.view smoothClipAnimationDidStopWithDriverId:self.driverId
1111
+ animationId:self.animationId
1112
+ finished:finished];
222
1113
  }
223
1114
 
224
1115
  @end