react-native-morph-card 0.1.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/android/build.gradle +59 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/melivalesca/morphcard/MorphCardModule.kt +120 -0
  6. package/android/src/main/java/com/melivalesca/morphcard/MorphCardPackage.kt +42 -0
  7. package/android/src/main/java/com/melivalesca/morphcard/MorphCardSourceManager.kt +40 -0
  8. package/android/src/main/java/com/melivalesca/morphcard/MorphCardSourceView.kt +755 -0
  9. package/android/src/main/java/com/melivalesca/morphcard/MorphCardTargetManager.kt +48 -0
  10. package/android/src/main/java/com/melivalesca/morphcard/MorphCardTargetView.kt +159 -0
  11. package/android/src/main/java/com/melivalesca/morphcard/MorphCardViewRegistry.kt +24 -0
  12. package/android/src/main/jni/CMakeLists.txt +62 -0
  13. package/common/cpp/react/renderer/components/morphcard/RNCMorphCardState.h +30 -0
  14. package/ios/Fabric/RNCMorphCardSourceComponentView.h +25 -0
  15. package/ios/Fabric/RNCMorphCardSourceComponentView.mm +582 -0
  16. package/ios/Fabric/RNCMorphCardTargetComponentView.h +20 -0
  17. package/ios/Fabric/RNCMorphCardTargetComponentView.mm +99 -0
  18. package/ios/RNCMorphCardModule.h +14 -0
  19. package/ios/RNCMorphCardModule.mm +126 -0
  20. package/ios/RNCMorphCardSource.h +23 -0
  21. package/ios/RNCMorphCardSource.m +144 -0
  22. package/ios/RNCMorphCardSourceManager.h +5 -0
  23. package/ios/RNCMorphCardSourceManager.m +17 -0
  24. package/ios/RNCMorphCardTarget.h +19 -0
  25. package/ios/RNCMorphCardTarget.m +27 -0
  26. package/ios/RNCMorphCardTargetManager.h +5 -0
  27. package/ios/RNCMorphCardTargetManager.m +16 -0
  28. package/ios/RNCMorphCardViewRegistry.h +35 -0
  29. package/ios/RNCMorphCardViewRegistry.m +40 -0
  30. package/lib/commonjs/MorphCard.types.js +6 -0
  31. package/lib/commonjs/MorphCard.types.js.map +1 -0
  32. package/lib/commonjs/MorphCardSource.js +95 -0
  33. package/lib/commonjs/MorphCardSource.js.map +1 -0
  34. package/lib/commonjs/MorphCardTarget.js +83 -0
  35. package/lib/commonjs/MorphCardTarget.js.map +1 -0
  36. package/lib/commonjs/index.js +45 -0
  37. package/lib/commonjs/index.js.map +1 -0
  38. package/lib/commonjs/package.json +1 -0
  39. package/lib/commonjs/specs/NativeMorphCardModule.js +9 -0
  40. package/lib/commonjs/specs/NativeMorphCardModule.js.map +1 -0
  41. package/lib/commonjs/specs/NativeMorphCardSource.js +10 -0
  42. package/lib/commonjs/specs/NativeMorphCardSource.js.map +1 -0
  43. package/lib/commonjs/specs/NativeMorphCardTarget.js +10 -0
  44. package/lib/commonjs/specs/NativeMorphCardTarget.js.map +1 -0
  45. package/lib/commonjs/useMorphTarget.js +28 -0
  46. package/lib/commonjs/useMorphTarget.js.map +1 -0
  47. package/lib/module/MorphCard.types.js +4 -0
  48. package/lib/module/MorphCard.types.js.map +1 -0
  49. package/lib/module/MorphCardSource.js +85 -0
  50. package/lib/module/MorphCardSource.js.map +1 -0
  51. package/lib/module/MorphCardTarget.js +76 -0
  52. package/lib/module/MorphCardTarget.js.map +1 -0
  53. package/lib/module/index.js +6 -0
  54. package/lib/module/index.js.map +1 -0
  55. package/lib/module/package.json +1 -0
  56. package/lib/module/specs/NativeMorphCardModule.js +5 -0
  57. package/lib/module/specs/NativeMorphCardModule.js.map +1 -0
  58. package/lib/module/specs/NativeMorphCardSource.js +5 -0
  59. package/lib/module/specs/NativeMorphCardSource.js.map +1 -0
  60. package/lib/module/specs/NativeMorphCardTarget.js +5 -0
  61. package/lib/module/specs/NativeMorphCardTarget.js.map +1 -0
  62. package/lib/module/useMorphTarget.js +22 -0
  63. package/lib/module/useMorphTarget.js.map +1 -0
  64. package/lib/typescript/src/MorphCard.types.d.ts +29 -0
  65. package/lib/typescript/src/MorphCard.types.d.ts.map +1 -0
  66. package/lib/typescript/src/MorphCardSource.d.ts +35 -0
  67. package/lib/typescript/src/MorphCardSource.d.ts.map +1 -0
  68. package/lib/typescript/src/MorphCardTarget.d.ts +20 -0
  69. package/lib/typescript/src/MorphCardTarget.d.ts.map +1 -0
  70. package/lib/typescript/src/index.d.ts +6 -0
  71. package/lib/typescript/src/index.d.ts.map +1 -0
  72. package/lib/typescript/src/specs/NativeMorphCardModule.d.ts +14 -0
  73. package/lib/typescript/src/specs/NativeMorphCardModule.d.ts.map +1 -0
  74. package/lib/typescript/src/specs/NativeMorphCardSource.d.ts +13 -0
  75. package/lib/typescript/src/specs/NativeMorphCardSource.d.ts.map +1 -0
  76. package/lib/typescript/src/specs/NativeMorphCardTarget.d.ts +25 -0
  77. package/lib/typescript/src/specs/NativeMorphCardTarget.d.ts.map +1 -0
  78. package/lib/typescript/src/useMorphTarget.d.ts +16 -0
  79. package/lib/typescript/src/useMorphTarget.d.ts.map +1 -0
  80. package/package.json +101 -0
  81. package/react-native-morph-card.podspec +41 -0
  82. package/react-native.config.js +13 -0
  83. package/src/MorphCard.types.ts +29 -0
  84. package/src/MorphCardSource.tsx +105 -0
  85. package/src/MorphCardTarget.tsx +127 -0
  86. package/src/index.tsx +10 -0
  87. package/src/specs/NativeMorphCardModule.ts +21 -0
  88. package/src/specs/NativeMorphCardSource.ts +20 -0
  89. package/src/specs/NativeMorphCardTarget.ts +38 -0
  90. package/src/useMorphTarget.ts +21 -0
@@ -0,0 +1,582 @@
1
+ #import "RNCMorphCardSourceComponentView.h"
2
+ #import "RNCMorphCardTargetComponentView.h"
3
+ #import "RNCMorphCardViewRegistry.h"
4
+
5
+ #import <React/RCTFabricComponentsPlugins.h>
6
+ #import <react/renderer/components/morphcard/ComponentDescriptors.h>
7
+ #import <react/renderer/components/morphcard/Props.h>
8
+
9
+ using namespace facebook::react;
10
+
11
+ static UIWindow *getKeyWindow(void) {
12
+ for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
13
+ if ([scene isKindOfClass:[UIWindowScene class]]) {
14
+ UIWindowScene *windowScene = (UIWindowScene *)scene;
15
+ for (UIWindow *w in windowScene.windows) {
16
+ if (w.isKeyWindow) {
17
+ return w;
18
+ }
19
+ }
20
+ }
21
+ }
22
+ return nil;
23
+ }
24
+
25
+ static UIView *findScreenContainer(UIView *view) {
26
+ UIWindow *window = view.window;
27
+ if (!window) return nil;
28
+
29
+ CGRect windowBounds = window.bounds;
30
+ UIView *current = view.superview;
31
+ UIView *result = nil;
32
+
33
+ while (current && current != window) {
34
+ CGRect frameInWindow = [current convertRect:current.bounds toView:nil];
35
+ if (CGRectEqualToRect(frameInWindow, windowBounds)) {
36
+ result = current;
37
+ }
38
+ current = current.superview;
39
+ }
40
+ return result;
41
+ }
42
+
43
+ static BOOL hasVisibleBackgroundColor(UIView *view) {
44
+ UIColor *bg = view.backgroundColor;
45
+ if (!bg) return NO;
46
+ CGFloat alpha = 0;
47
+ [bg getRed:nil green:nil blue:nil alpha:&alpha];
48
+ return alpha > 0.01;
49
+ }
50
+
51
+ /// Compute the image frame for a given scaleMode within a container of containerSize.
52
+ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
53
+ CGSize imageSize,
54
+ CGSize containerSize) {
55
+ if (mode == UIViewContentModeScaleAspectFit) {
56
+ CGFloat scale = MIN(containerSize.width / imageSize.width,
57
+ containerSize.height / imageSize.height);
58
+ CGFloat w = imageSize.width * scale;
59
+ CGFloat h = imageSize.height * scale;
60
+ return CGRectMake((containerSize.width - w) / 2,
61
+ (containerSize.height - h) / 2, w, h);
62
+ } else if (mode == UIViewContentModeScaleToFill) {
63
+ return (CGRect){CGPointZero, containerSize};
64
+ } else {
65
+ // AspectFill
66
+ CGFloat scale = MAX(containerSize.width / imageSize.width,
67
+ containerSize.height / imageSize.height);
68
+ CGFloat w = imageSize.width * scale;
69
+ CGFloat h = imageSize.height * scale;
70
+ return CGRectMake((containerSize.width - w) / 2,
71
+ (containerSize.height - h) / 2, w, h);
72
+ }
73
+ }
74
+
75
+ @implementation RNCMorphCardSourceComponentView {
76
+ CGFloat _duration;
77
+ UIViewContentMode _scaleMode;
78
+ BOOL _isExpanded;
79
+ BOOL _hasWrapper;
80
+ CGRect _cardFrame;
81
+ CGFloat _cardCornerRadius;
82
+ __weak UIView *_targetView;
83
+ __weak UIView *_sourceScreenContainer;
84
+ __weak UIView *_targetScreenContainer;
85
+ UIView *_wrapperView;
86
+ UIImageView *_snapshot;
87
+ }
88
+
89
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
90
+ return concreteComponentDescriptorProvider<
91
+ RNCMorphCardSourceComponentDescriptor>();
92
+ }
93
+
94
+ - (instancetype)initWithFrame:(CGRect)frame {
95
+ if (self = [super initWithFrame:frame]) {
96
+ _duration = 500.0;
97
+ _scaleMode = UIViewContentModeScaleAspectFill;
98
+ _pendingTargetBorderRadius = -1;
99
+ }
100
+ return self;
101
+ }
102
+
103
+ - (void)updateProps:(const Props::Shared &)props
104
+ oldProps:(const Props::Shared &)oldProps {
105
+ const auto &newProps =
106
+ *std::static_pointer_cast<const RNCMorphCardSourceProps>(props);
107
+ _duration = newProps.duration > 0 ? newProps.duration : 500.0;
108
+ auto sm = newProps.scaleMode;
109
+ if (sm == RNCMorphCardSourceScaleMode::AspectFit) {
110
+ _scaleMode = UIViewContentModeScaleAspectFit;
111
+ } else if (sm == RNCMorphCardSourceScaleMode::Stretch) {
112
+ _scaleMode = UIViewContentModeScaleToFill;
113
+ } else {
114
+ _scaleMode = UIViewContentModeScaleAspectFill;
115
+ }
116
+ [super updateProps:props oldProps:oldProps];
117
+ }
118
+
119
+ - (void)didMoveToWindow {
120
+ [super didMoveToWindow];
121
+ if (self.window) {
122
+ [[RNCMorphCardViewRegistry shared] registerView:self withTag:self.tag];
123
+ } else {
124
+ [[RNCMorphCardViewRegistry shared] unregisterViewWithTag:self.tag];
125
+ }
126
+ }
127
+
128
+ #pragma mark - Snapshot helper
129
+
130
+ - (UIImage *)captureSnapshot {
131
+ // Render children directly into a fresh context so the snapshot
132
+ // captures full rectangular content without the source view's
133
+ // cornerRadius clipping. No on-screen flash since we never
134
+ // modify visible properties.
135
+ UIGraphicsImageRendererFormat *format =
136
+ [UIGraphicsImageRendererFormat defaultFormat];
137
+ format.opaque = NO;
138
+ CGSize size = self.bounds.size;
139
+ UIGraphicsImageRenderer *renderer =
140
+ [[UIGraphicsImageRenderer alloc] initWithSize:size format:format];
141
+ return [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
142
+ for (UIView *child in self.subviews) {
143
+ CGContextSaveGState(ctx.CGContext);
144
+ CGContextTranslateCTM(ctx.CGContext, child.frame.origin.x, child.frame.origin.y);
145
+ [child drawViewHierarchyInRect:(CGRect){CGPointZero, child.frame.size}
146
+ afterScreenUpdates:NO];
147
+ CGContextRestoreGState(ctx.CGContext);
148
+ }
149
+ }];
150
+ }
151
+
152
+ #pragma mark - Expand
153
+
154
+ - (void)expandToTarget:(UIView *)targetView
155
+ resolve:(RCTPromiseResolveBlock)resolve {
156
+ if (_isExpanded) {
157
+ resolve(@(NO));
158
+ return;
159
+ }
160
+ _isExpanded = YES;
161
+
162
+ UIWindow *window = getKeyWindow();
163
+ if (!window) {
164
+ resolve(@(NO));
165
+ return;
166
+ }
167
+
168
+ _targetView = targetView;
169
+
170
+ // ── 1. Save card geometry ──
171
+ _cardFrame = [self convertRect:self.bounds toView:nil];
172
+ _cardCornerRadius = self.layer.cornerRadius;
173
+ _hasWrapper = hasVisibleBackgroundColor(self);
174
+
175
+ // ── 2. Snapshot the card ──
176
+ UIImage *cardImage = [self captureSnapshot];
177
+
178
+ // ── 3. Keep source screen visible during navigation transition ──
179
+ UIView *sourceScreen = findScreenContainer(self);
180
+ _sourceScreenContainer = sourceScreen;
181
+ _targetScreenContainer = findScreenContainer(targetView);
182
+
183
+ if (sourceScreen) {
184
+ sourceScreen.alpha = 1;
185
+ }
186
+
187
+ // ── 4. Compute target frame and corner radius ──
188
+ CGPoint targetOrigin = targetView
189
+ ? [targetView convertPoint:CGPointZero toView:nil]
190
+ : _cardFrame.origin;
191
+
192
+ CGFloat tw = self.pendingTargetWidth;
193
+ CGFloat th = self.pendingTargetHeight;
194
+ CGFloat tbr = self.pendingTargetBorderRadius;
195
+
196
+ CGRect targetFrame = CGRectMake(
197
+ targetOrigin.x,
198
+ targetOrigin.y,
199
+ tw > 0 ? tw : _cardFrame.size.width,
200
+ th > 0 ? th : _cardFrame.size.height);
201
+
202
+ CGFloat targetCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
203
+
204
+ NSTimeInterval dur = _duration / 1000.0;
205
+
206
+ if (_hasWrapper) {
207
+ // ══ WRAPPER MODE ══
208
+ // Wrapper view (with bg color, corner radius) expands.
209
+ // Content snapshot stays at original size on top.
210
+ UIView *wrapper = [[UIView alloc] initWithFrame:_cardFrame];
211
+ wrapper.backgroundColor = self.backgroundColor;
212
+ wrapper.layer.cornerRadius = _cardCornerRadius;
213
+ wrapper.clipsToBounds = YES;
214
+
215
+ UIImageView *content = [[UIImageView alloc] initWithImage:cardImage];
216
+ content.contentMode = UIViewContentModeTopLeft;
217
+ content.clipsToBounds = YES;
218
+ content.frame = (CGRect){CGPointZero, _cardFrame.size};
219
+ [wrapper addSubview:content];
220
+
221
+ [window addSubview:wrapper];
222
+ _wrapperView = wrapper;
223
+
224
+ // Hide source AFTER overlay is on screen to avoid flicker
225
+ self.alpha = 0;
226
+
227
+ CGFloat contentOffsetY = self.pendingContentOffsetY;
228
+ BOOL contentCentered = self.pendingContentCentered;
229
+ CGSize contentSize = _cardFrame.size;
230
+
231
+ CGFloat targetCx = contentCentered
232
+ ? (targetFrame.size.width - contentSize.width) / 2.0
233
+ : 0;
234
+ CGFloat targetCy = contentCentered
235
+ ? (targetFrame.size.height - contentSize.height) / 2.0
236
+ : contentOffsetY;
237
+
238
+ UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc]
239
+ initWithDuration:dur
240
+ dampingRatio:0.85
241
+ animations:^{
242
+ wrapper.frame = targetFrame;
243
+ wrapper.layer.cornerRadius = targetCornerRadius;
244
+ content.frame = CGRectMake(targetCx, targetCy,
245
+ contentSize.width,
246
+ contentSize.height);
247
+ }];
248
+
249
+ // Hide the target view itself so it doesn't double-render over the morph overlay.
250
+ if (targetView) {
251
+ targetView.hidden = YES;
252
+ }
253
+
254
+ // Start fading in screen content early (at 15% of the animation).
255
+ dispatch_after(
256
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
257
+ dispatch_get_main_queue(), ^{
258
+ UIView *ts = self->_targetScreenContainer;
259
+ if (ts) {
260
+ [UIView animateWithDuration:dur * 0.5
261
+ animations:^{
262
+ ts.alpha = 1;
263
+ }
264
+ completion:nil];
265
+ }
266
+ });
267
+
268
+ UIColor *wrapperBg = wrapper.backgroundColor;
269
+
270
+ [animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
271
+ if (targetView && [targetView isKindOfClass:[RNCMorphCardTargetComponentView class]]) {
272
+ RNCMorphCardTargetComponentView *target = (RNCMorphCardTargetComponentView *)targetView;
273
+ UIView *content = wrapper.subviews.firstObject;
274
+ if ([content isKindOfClass:[UIImageView class]]) {
275
+ CGRect contentFrame = content.frame;
276
+ [target showSnapshot:((UIImageView *)content).image
277
+ contentMode:UIViewContentModeTopLeft
278
+ frame:contentFrame
279
+ cornerRadius:targetCornerRadius
280
+ backgroundColor:wrapperBg];
281
+ }
282
+ }
283
+ if (targetView) { targetView.hidden = NO; }
284
+ self.alpha = 1;
285
+ UIView *ts = self->_targetScreenContainer;
286
+ if (ts) { ts.alpha = 1; }
287
+ [UIView animateWithDuration:0.2
288
+ animations:^{
289
+ wrapper.alpha = 0;
290
+ }
291
+ completion:^(BOOL finished) {
292
+ [wrapper removeFromSuperview];
293
+ self->_wrapperView = nil;
294
+ }];
295
+ resolve(@(YES));
296
+ }];
297
+
298
+ [animator startAnimation];
299
+
300
+ } else {
301
+ // ══ NO-WRAPPER MODE ══
302
+ // Container clips, image view inside respects scaleMode.
303
+ // We compute image frames ourselves so scaleMode works during animation.
304
+ UIViewContentMode scaleMode = _scaleMode;
305
+ CGSize imageSize = cardImage.size;
306
+
307
+ UIView *container = [[UIView alloc] initWithFrame:_cardFrame];
308
+ container.clipsToBounds = YES;
309
+ container.layer.cornerRadius = _cardCornerRadius;
310
+
311
+ UIImageView *snapshot = [[UIImageView alloc] initWithImage:cardImage];
312
+ snapshot.clipsToBounds = YES;
313
+ // Start: image fills container exactly (matches source card)
314
+ snapshot.frame = (CGRect){CGPointZero, _cardFrame.size};
315
+ [container addSubview:snapshot];
316
+
317
+ [window addSubview:container];
318
+ _snapshot = snapshot;
319
+ _wrapperView = container;
320
+
321
+ // Hide source AFTER overlay is on screen to avoid flicker
322
+ self.alpha = 0;
323
+
324
+ if (targetView) {
325
+ targetView.hidden = YES;
326
+ }
327
+
328
+ // Compute final image frame based on scaleMode
329
+ CGRect targetImageFrame = imageFrameForScaleMode(
330
+ scaleMode, imageSize, targetFrame.size);
331
+
332
+ UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc]
333
+ initWithDuration:dur
334
+ dampingRatio:0.85
335
+ animations:^{
336
+ container.frame = targetFrame;
337
+ container.layer.cornerRadius = targetCornerRadius;
338
+ snapshot.frame = targetImageFrame;
339
+ }];
340
+
341
+ // Start fading in screen content early (at 15% of the animation).
342
+ dispatch_after(
343
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
344
+ dispatch_get_main_queue(), ^{
345
+ UIView *ts = self->_targetScreenContainer;
346
+ if (ts) {
347
+ [UIView animateWithDuration:dur * 0.5
348
+ animations:^{
349
+ ts.alpha = 1;
350
+ }
351
+ completion:nil];
352
+ }
353
+ });
354
+
355
+ [animator addCompletion:^(UIViewAnimatingPosition finalPosition) {
356
+ if (targetView && [targetView isKindOfClass:[RNCMorphCardTargetComponentView class]]) {
357
+ RNCMorphCardTargetComponentView *target = (RNCMorphCardTargetComponentView *)targetView;
358
+ [target showSnapshot:snapshot.image
359
+ contentMode:scaleMode
360
+ frame:target.bounds
361
+ cornerRadius:targetCornerRadius
362
+ backgroundColor:nil];
363
+ }
364
+ if (targetView) { targetView.hidden = NO; }
365
+ self.alpha = 1;
366
+ UIView *ts = self->_targetScreenContainer;
367
+ if (ts) { ts.alpha = 1; }
368
+ [UIView animateWithDuration:0.2
369
+ animations:^{
370
+ container.alpha = 0;
371
+ }
372
+ completion:^(BOOL finished) {
373
+ [container removeFromSuperview];
374
+ self->_wrapperView = nil;
375
+ self->_snapshot = nil;
376
+ }];
377
+ resolve(@(YES));
378
+ }];
379
+
380
+ [animator startAnimation];
381
+ }
382
+ }
383
+
384
+ #pragma mark - Collapse
385
+
386
+ - (void)collapseFromTarget:(UIView *)targetView
387
+ resolve:(RCTPromiseResolveBlock)resolve {
388
+ if (!_isExpanded) {
389
+ resolve(@(NO));
390
+ return;
391
+ }
392
+
393
+ UIWindow *window = getKeyWindow();
394
+ if (!window) {
395
+ resolve(@(NO));
396
+ return;
397
+ }
398
+
399
+ UIView *targetScreen = _targetScreenContainer;
400
+ UIView *sourceScreen = _sourceScreenContainer;
401
+
402
+ // Clear the snapshot from the target view before re-creating the overlay
403
+ if (targetView && [targetView isKindOfClass:[RNCMorphCardTargetComponentView class]]) {
404
+ [(RNCMorphCardTargetComponentView *)targetView clearSnapshot];
405
+ }
406
+
407
+ NSTimeInterval dur = _duration / 1000.0;
408
+
409
+ if (_hasWrapper) {
410
+ // ══ WRAPPER MODE COLLAPSE ══
411
+ UIView *wrapper = _wrapperView;
412
+ if (!wrapper) {
413
+ self.alpha = 1;
414
+ UIImage *cardImage = [self captureSnapshot];
415
+ self.alpha = 0;
416
+
417
+ CGPoint targetOrigin = targetView
418
+ ? [targetView convertPoint:CGPointZero toView:nil]
419
+ : _cardFrame.origin;
420
+ CGFloat tw = self.pendingTargetWidth;
421
+ CGFloat th = self.pendingTargetHeight;
422
+ CGFloat tbr = self.pendingTargetBorderRadius;
423
+ CGRect targetFrame = CGRectMake(
424
+ targetOrigin.x, targetOrigin.y,
425
+ tw > 0 ? tw : _cardFrame.size.width,
426
+ th > 0 ? th : _cardFrame.size.height);
427
+ CGFloat targetCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
428
+
429
+ CGFloat contentOffsetY = self.pendingContentOffsetY;
430
+ BOOL contentCentered = self.pendingContentCentered;
431
+ CGSize contentSize = _cardFrame.size;
432
+ CGFloat cx = contentCentered
433
+ ? (targetFrame.size.width - contentSize.width) / 2.0 : 0;
434
+ CGFloat cy = contentCentered
435
+ ? (targetFrame.size.height - contentSize.height) / 2.0
436
+ : contentOffsetY;
437
+
438
+ wrapper = [[UIView alloc] initWithFrame:targetFrame];
439
+ wrapper.backgroundColor = self.backgroundColor;
440
+ wrapper.layer.cornerRadius = targetCornerRadius;
441
+ wrapper.clipsToBounds = YES;
442
+
443
+ UIImageView *content = [[UIImageView alloc] initWithImage:cardImage];
444
+ content.contentMode = UIViewContentModeTopLeft;
445
+ content.clipsToBounds = YES;
446
+ content.frame = CGRectMake(cx, cy, contentSize.width, contentSize.height);
447
+ [wrapper addSubview:content];
448
+
449
+ [window addSubview:wrapper];
450
+ _wrapperView = wrapper;
451
+ }
452
+
453
+ // Show source screen underneath before starting collapse
454
+ if (sourceScreen) {
455
+ sourceScreen.alpha = 1;
456
+ }
457
+
458
+ UIView *content = wrapper.subviews.firstObject;
459
+
460
+ UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc]
461
+ initWithDuration:dur
462
+ dampingRatio:0.85
463
+ animations:^{
464
+ wrapper.frame = self->_cardFrame;
465
+ wrapper.layer.cornerRadius = self->_cardCornerRadius;
466
+ if (content) {
467
+ content.frame = (CGRect){CGPointZero, content.frame.size};
468
+ }
469
+ }];
470
+
471
+ // Fade out target screen concurrently at 15% (mirrors expand's fade-in)
472
+ dispatch_after(
473
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
474
+ dispatch_get_main_queue(), ^{
475
+ [UIView animateWithDuration:dur * 0.5
476
+ animations:^{
477
+ if (targetScreen) { targetScreen.alpha = 0; }
478
+ }
479
+ completion:nil];
480
+ });
481
+
482
+ [animator addCompletion:^(UIViewAnimatingPosition pos) {
483
+ [wrapper removeFromSuperview];
484
+ self->_wrapperView = nil;
485
+ self.alpha = 1;
486
+ self->_isExpanded = NO;
487
+ self->_sourceScreenContainer = nil;
488
+ self->_targetScreenContainer = nil;
489
+ resolve(@(YES));
490
+ }];
491
+
492
+ [animator startAnimation];
493
+
494
+ } else {
495
+ // ══ NO-WRAPPER MODE COLLAPSE ══
496
+ UIView *container = _wrapperView;
497
+ UIImageView *snapshot = _snapshot;
498
+
499
+ if (!container) {
500
+ self.alpha = 1;
501
+ UIImage *cardImage = [self captureSnapshot];
502
+ self.alpha = 0;
503
+
504
+ CGPoint targetOrigin = targetView
505
+ ? [targetView convertPoint:CGPointZero toView:nil]
506
+ : _cardFrame.origin;
507
+ CGFloat tw = self.pendingTargetWidth;
508
+ CGFloat th = self.pendingTargetHeight;
509
+ CGFloat tbr = self.pendingTargetBorderRadius;
510
+ CGRect targetFrame = CGRectMake(
511
+ targetOrigin.x, targetOrigin.y,
512
+ tw > 0 ? tw : _cardFrame.size.width,
513
+ th > 0 ? th : _cardFrame.size.height);
514
+ CGFloat targetCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
515
+
516
+ CGSize imageSize = cardImage.size;
517
+ CGRect imageFrame = imageFrameForScaleMode(
518
+ _scaleMode, imageSize, targetFrame.size);
519
+
520
+ container = [[UIView alloc] initWithFrame:targetFrame];
521
+ container.clipsToBounds = YES;
522
+ container.layer.cornerRadius = targetCornerRadius;
523
+
524
+ snapshot = [[UIImageView alloc] initWithImage:cardImage];
525
+ snapshot.clipsToBounds = YES;
526
+ snapshot.frame = imageFrame;
527
+ [container addSubview:snapshot];
528
+
529
+ [window addSubview:container];
530
+ _wrapperView = container;
531
+ _snapshot = snapshot;
532
+ }
533
+
534
+ // Show source screen underneath before starting collapse
535
+ if (sourceScreen) {
536
+ sourceScreen.alpha = 1;
537
+ }
538
+
539
+ UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc]
540
+ initWithDuration:dur
541
+ dampingRatio:0.85
542
+ animations:^{
543
+ container.frame = self->_cardFrame;
544
+ container.layer.cornerRadius = self->_cardCornerRadius;
545
+ snapshot.frame = (CGRect){CGPointZero, self->_cardFrame.size};
546
+ }];
547
+
548
+ // Fade out target screen concurrently at 15% (mirrors expand's fade-in)
549
+ dispatch_after(
550
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
551
+ dispatch_get_main_queue(), ^{
552
+ [UIView animateWithDuration:dur * 0.5
553
+ animations:^{
554
+ if (targetScreen) { targetScreen.alpha = 0; }
555
+ }
556
+ completion:nil];
557
+ });
558
+
559
+ [animator addCompletion:^(UIViewAnimatingPosition pos) {
560
+ [container removeFromSuperview];
561
+ self->_wrapperView = nil;
562
+ self->_snapshot = nil;
563
+ self.alpha = 1;
564
+ self->_isExpanded = NO;
565
+ self->_sourceScreenContainer = nil;
566
+ self->_targetScreenContainer = nil;
567
+ resolve(@(YES));
568
+ }];
569
+
570
+ [animator startAnimation];
571
+ }
572
+ }
573
+
574
+ - (void)collapseWithResolve:(RCTPromiseResolveBlock)resolve {
575
+ [self collapseFromTarget:_targetView resolve:resolve];
576
+ }
577
+
578
+ Class<RCTComponentViewProtocol> RNCMorphCardSourceCls(void) {
579
+ return RNCMorphCardSourceComponentView.class;
580
+ }
581
+
582
+ @end
@@ -0,0 +1,20 @@
1
+ #import <React/RCTViewComponentView.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ @interface RNCMorphCardTargetComponentView : RCTViewComponentView
6
+
7
+ @property (nonatomic, assign) CGFloat targetWidth;
8
+ @property (nonatomic, assign) CGFloat targetHeight;
9
+ @property (nonatomic, assign) CGFloat targetBorderRadius;
10
+
11
+ - (void)showSnapshot:(UIImage *)image
12
+ contentMode:(UIViewContentMode)mode
13
+ frame:(CGRect)frame
14
+ cornerRadius:(CGFloat)cornerRadius
15
+ backgroundColor:(nullable UIColor *)bgColor;
16
+ - (void)clearSnapshot;
17
+
18
+ @end
19
+
20
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,99 @@
1
+ #import "RNCMorphCardTargetComponentView.h"
2
+ #import "RNCMorphCardViewRegistry.h"
3
+
4
+ #import <React/RCTFabricComponentsPlugins.h>
5
+ #import <react/renderer/components/morphcard/ComponentDescriptors.h>
6
+ #import <react/renderer/components/morphcard/Props.h>
7
+
8
+ using namespace facebook::react;
9
+
10
+ @implementation RNCMorphCardTargetComponentView {
11
+ UIView *_snapshotContainer; // our own view — Fabric can't reset its styles
12
+ UIImageView *_snapshotView;
13
+ }
14
+
15
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
16
+ return concreteComponentDescriptorProvider<
17
+ RNCMorphCardTargetComponentDescriptor>();
18
+ }
19
+
20
+ - (void)updateProps:(const Props::Shared &)props
21
+ oldProps:(const Props::Shared &)oldProps {
22
+ const auto &newProps =
23
+ *std::static_pointer_cast<const RNCMorphCardTargetProps>(props);
24
+ _targetWidth = newProps.targetWidth;
25
+ _targetHeight = newProps.targetHeight;
26
+ _targetBorderRadius = newProps.targetBorderRadius;
27
+ [super updateProps:props oldProps:oldProps];
28
+ }
29
+
30
+ - (void)didMoveToWindow {
31
+ [super didMoveToWindow];
32
+ if (self.window) {
33
+ [[RNCMorphCardViewRegistry shared] registerView:self withTag:self.tag];
34
+
35
+ // Immediately hide the detail screen container to prevent flicker.
36
+ // The expand animation will fade it back in.
37
+ UIWindow *window = self.window;
38
+ CGRect windowBounds = window.bounds;
39
+ UIView *current = self.superview;
40
+ UIView *screenContainer = nil;
41
+ while (current && current != window) {
42
+ CGRect frameInWindow = [current convertRect:current.bounds toView:nil];
43
+ if (CGRectEqualToRect(frameInWindow, windowBounds)) {
44
+ screenContainer = current;
45
+ }
46
+ current = current.superview;
47
+ }
48
+ if (screenContainer) {
49
+ screenContainer.alpha = 0;
50
+ }
51
+ } else {
52
+ [[RNCMorphCardViewRegistry shared] unregisterViewWithTag:self.tag];
53
+ }
54
+ }
55
+
56
+ - (void)showSnapshot:(UIImage *)image
57
+ contentMode:(UIViewContentMode)mode
58
+ frame:(CGRect)frame
59
+ cornerRadius:(CGFloat)cornerRadius
60
+ backgroundColor:(UIColor *)bgColor {
61
+ [self clearSnapshot];
62
+ // Use a dedicated container subview for all snapshot styling.
63
+ // Fabric manages self's properties and can reset them at any time,
64
+ // but it cannot touch our own subview's properties.
65
+ UIView *container = [[UIView alloc] initWithFrame:self.bounds];
66
+ container.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
67
+ container.clipsToBounds = YES;
68
+ container.layer.cornerRadius = cornerRadius;
69
+ if (bgColor) { container.backgroundColor = bgColor; }
70
+
71
+ UIImageView *iv = [[UIImageView alloc] initWithImage:image];
72
+ iv.contentMode = mode;
73
+ iv.clipsToBounds = YES;
74
+ iv.frame = frame;
75
+ [container addSubview:iv];
76
+
77
+ [self addSubview:container];
78
+ _snapshotContainer = container;
79
+ _snapshotView = iv;
80
+ }
81
+
82
+ - (void)clearSnapshot {
83
+ if (_snapshotContainer) {
84
+ [_snapshotContainer removeFromSuperview];
85
+ _snapshotContainer = nil;
86
+ _snapshotView = nil;
87
+ }
88
+ }
89
+
90
+ - (void)prepareForRecycle {
91
+ [super prepareForRecycle];
92
+ [self clearSnapshot];
93
+ }
94
+
95
+ Class<RCTComponentViewProtocol> RNCMorphCardTargetCls(void) {
96
+ return RNCMorphCardTargetComponentView.class;
97
+ }
98
+
99
+ @end