react-native-morph-card 0.2.7 → 0.2.8

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Native card-to-modal morph transition for React Native. Smoothly animates a card from a list into a fullscreen detail view, morphing size, position, and corner radius — then collapses back.
4
4
 
5
- https://github.com/user-attachments/assets/93f0c1f6-e203-496e-bb56-c994e1500e32
5
+ <video src="assets/demo.mov">
6
6
 
7
7
  - Native animations on both platforms (UIKit `UIViewPropertyAnimator` / Android `ValueAnimator`)
8
8
  - No JS-driven animation, no webview, no experimental flags
@@ -51,7 +51,11 @@ Wrap your card content in `MorphCardSource`. On the detail screen, use `MorphCar
51
51
  ```tsx
52
52
  import React from 'react';
53
53
  import { View, Image, Text, Pressable } from 'react-native';
54
- import { MorphCardSource, MorphCardTarget, useMorphTarget } from 'react-native-morph-card';
54
+ import {
55
+ MorphCardSource,
56
+ MorphCardTarget,
57
+ useMorphTarget,
58
+ } from 'react-native-morph-card';
55
59
 
56
60
  // ── List screen ──
57
61
  const ListScreen = ({ navigation }) => {
@@ -98,30 +102,30 @@ const DetailScreen = ({ route, navigation }) => {
98
102
 
99
103
  Wraps the card content on the list/grid screen. Captures a snapshot and drives the expand animation.
100
104
 
101
- | Prop | Type | Default | Description |
102
- |------|------|---------|-------------|
103
- | `width` | `DimensionValue` | — | Card width |
104
- | `height` | `DimensionValue` | — | Card height |
105
- | `borderRadius` | `number` | `0` | Corner radius of the card |
106
- | `backgroundColor` | `string` | — | Background color (enables "wrapper mode" where the background expands separately from the content) |
107
- | `duration` | `number` | `300` | Default animation duration in ms (used for both expand and collapse if specific durations are not set) |
108
- | `expandDuration` | `number` | — | Duration of the expand animation in ms. Overrides `duration` for expand. |
109
- | `scaleMode` | `'aspectFill' \| 'aspectFit' \| 'stretch'` | `'aspectFill'` | How the snapshot scales during no-wrapper mode animation |
110
- | `onPress` | `(sourceTag: number) => void` | — | Called on tap with the native view tag. Use this to navigate to the detail screen. |
105
+ | Prop | Type | Default | Description |
106
+ | ----------------- | ------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------ |
107
+ | `width` | `DimensionValue` | — | Card width |
108
+ | `height` | `DimensionValue` | — | Card height |
109
+ | `borderRadius` | `number` | `0` | Corner radius of the card |
110
+ | `backgroundColor` | `string` | — | Background color (enables "wrapper mode" where the background expands separately from the content) |
111
+ | `duration` | `number` | `300` | Default animation duration in ms (used for both expand and collapse if specific durations are not set) |
112
+ | `expandDuration` | `number` | — | Duration of the expand animation in ms. Overrides `duration` for expand. |
113
+ | `scaleMode` | `'aspectFill' \| 'aspectFit' \| 'stretch'` | `'aspectFill'` | How the snapshot scales during no-wrapper mode animation |
114
+ | `onPress` | `(sourceTag: number) => void` | — | Called on tap with the native view tag. Use this to navigate to the detail screen. |
111
115
 
112
116
  ### `<MorphCardTarget>`
113
117
 
114
118
  Placed on the detail screen where the card should land. Triggers the expand animation on mount.
115
119
 
116
- | Prop | Type | Default | Description |
117
- |------|------|---------|-------------|
118
- | `sourceTag` | `number` | **required** | The source view tag from navigation params |
119
- | `width` | `DimensionValue` | source width | Target width after expand |
120
- | `height` | `DimensionValue` | source height | Target height after expand |
121
- | `borderRadius` | `number` | source radius | Target corner radius. Set to `0` for no rounding. |
122
- | `collapseDuration` | `number` | — | Duration of the collapse animation in ms. Falls back to the source's `duration`. |
123
- | `contentOffsetY` | `number` | `0` | Vertical offset for content snapshot in wrapper mode |
124
- | `contentCentered` | `boolean` | `false` | Center content snapshot horizontally in wrapper mode |
120
+ | Prop | Type | Default | Description |
121
+ | ------------------ | ---------------- | ------------- | -------------------------------------------------------------------------------- |
122
+ | `sourceTag` | `number` | **required** | The source view tag from navigation params |
123
+ | `width` | `DimensionValue` | source width | Target width after expand |
124
+ | `height` | `DimensionValue` | source height | Target height after expand |
125
+ | `borderRadius` | `number` | source radius | Target corner radius. Set to `0` for no rounding. |
126
+ | `collapseDuration` | `number` | — | Duration of the collapse animation in ms. Falls back to the source's `duration`. |
127
+ | `contentOffsetY` | `number` | `0` | Vertical offset for content snapshot in wrapper mode |
128
+ | `contentCentered` | `boolean` | `false` | Center content snapshot horizontally in wrapper mode |
125
129
 
126
130
  ### `useMorphTarget(options)`
127
131
 
@@ -141,7 +145,11 @@ const { dismiss } = useMorphTarget({
141
145
  For more control, use the imperative functions:
142
146
 
143
147
  ```tsx
144
- import { morphExpand, morphCollapse, getViewTag } from 'react-native-morph-card';
148
+ import {
149
+ morphExpand,
150
+ morphCollapse,
151
+ getViewTag,
152
+ } from 'react-native-morph-card';
145
153
 
146
154
  // Expand from source to target
147
155
  await morphExpand(sourceRef, targetRef);
@@ -152,7 +160,8 @@ await morphCollapse(sourceTag);
152
160
  // Get the native view tag from a ref
153
161
  const tag = getViewTag(viewRef);
154
162
  ```
155
- ```
163
+
164
+ ````
156
165
 
157
166
  ## Running the example app
158
167
 
@@ -183,7 +192,7 @@ yarn install
183
192
  # Install example app dependencies
184
193
  cd example
185
194
  yarn install
186
- ```
195
+ ````
187
196
 
188
197
  ### 2. Run on iOS
189
198
 
@@ -235,12 +244,12 @@ yarn build:android
235
244
 
236
245
  ### Troubleshooting
237
246
 
238
- | Problem | Fix |
239
- |---------|-----|
240
- | `pod install` fails | Run `bundle install` first, then `bundle exec pod install` |
241
- | Android build fails on first run | Make sure `ANDROID_HOME` is set and an emulator/device is available |
242
- | Metro can't find `react-native-morph-card` | Run `yarn install` at the repo root first |
243
- | Duplicate module errors | Delete `node_modules` in both root and `example/`, then reinstall |
247
+ | Problem | Fix |
248
+ | ------------------------------------------ | ------------------------------------------------------------------- |
249
+ | `pod install` fails | Run `bundle install` first, then `bundle exec pod install` |
250
+ | Android build fails on first run | Make sure `ANDROID_HOME` is set and an emulator/device is available |
251
+ | Metro can't find `react-native-morph-card` | Run `yarn install` at the repo root first |
252
+ | Duplicate module errors | Delete `node_modules` in both root and `example/`, then reinstall |
244
253
 
245
254
  ## Contributing
246
255
 
@@ -5,7 +5,6 @@ import android.content.Context
5
5
  import android.graphics.Bitmap
6
6
  import android.graphics.Canvas
7
7
  import android.graphics.Color
8
- import android.graphics.Outline
9
8
  import android.graphics.RectF
10
9
  import android.graphics.Paint
11
10
  import android.graphics.PorterDuff
@@ -17,7 +16,6 @@ import android.os.Looper
17
16
  import android.util.Log
18
17
  import android.view.View
19
18
  import android.view.ViewGroup
20
- import android.view.ViewOutlineProvider
21
19
  import android.view.ViewTreeObserver
22
20
  import android.view.animation.PathInterpolator
23
21
  import android.widget.FrameLayout
@@ -87,16 +85,7 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
87
85
 
88
86
  private fun applyBorderRadiusClipping() {
89
87
  val radiusPx = if (borderRadiusDp > 0f) borderRadiusDp * density else 0f
90
- if (radiusPx > 0f) {
91
- clipToOutline = true
92
- outlineProvider = object : ViewOutlineProvider() {
93
- override fun getOutline(v: View, outline: Outline) {
94
- outline.setRoundRect(0, 0, v.width, v.height, radiusPx)
95
- }
96
- }
97
- } else {
98
- clipToOutline = false
99
- }
88
+ setRoundedCorners(this, radiusPx)
100
89
  }
101
90
 
102
91
  // ── Snapshot ──
@@ -125,27 +114,6 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
125
114
  return loc
126
115
  }
127
116
 
128
- /**
129
- * Find the screen container for a view (ScreensCoordinatorLayout).
130
- * Walks up until parent is ScreenStack/ScreenContainer.
131
- */
132
- private fun findScreenContainer(view: View?): View? {
133
- if (view == null) return null
134
- var current: View? = view
135
- while (current != null) {
136
- val parent = current.parent
137
- if (parent is ViewGroup) {
138
- val parentName = parent.javaClass.name
139
- if (parentName.contains("ScreenStack") || parentName.contains("ScreenContainer")) {
140
- return current
141
- }
142
- }
143
- current = if (current.parent is View) current.parent as View else null
144
- }
145
- return null
146
- }
147
-
148
-
149
117
  private fun extractBackgroundColor(): Int? {
150
118
  val bg = background ?: return null
151
119
  if (bg is ColorDrawable) return bg.color
@@ -216,19 +184,6 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
216
184
  }
217
185
  }
218
186
 
219
- private fun setRoundedCorners(view: View, radiusPx: Float) {
220
- if (radiusPx <= 0f) {
221
- view.clipToOutline = false
222
- return
223
- }
224
- view.clipToOutline = true
225
- view.outlineProvider = object : ViewOutlineProvider() {
226
- override fun getOutline(v: View, outline: Outline) {
227
- outline.setRoundRect(0, 0, v.width, v.height, radiusPx)
228
- }
229
- }
230
- }
231
-
232
187
  /**
233
188
  * Walk the view tree and hide any screen container that isn't already known.
234
189
  * This catches modal screens added to separate ScreenStacks (e.g. transparentModal).
@@ -387,8 +342,9 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
387
342
  // Ensure the overlay renders above any views with elevation (e.g. ScreenStack children)
388
343
  fullScreenOverlay.translationZ = 1000f
389
344
 
390
- // PixelCopy captures from the surface (hardware-rendered, preserves outlines).
391
- // We use a background HandlerThread for the callback to avoid deadlocking main.
345
+ // PixelCopy captures the current screen with hardware rendering preserved
346
+ // (clipToOutline, borderRadius, etc.). The result is delivered on a background
347
+ // HandlerThread, then posted to main to add the blocker image.
392
348
  // Note: context may be ThemedReactContext, not Activity directly.
393
349
  val activity = (context as? android.app.Activity)
394
350
  ?: (context as? com.facebook.react.bridge.ReactContext)?.currentActivity
@@ -398,23 +354,25 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
398
354
  val copyThread = android.os.HandlerThread("PixelCopyThread")
399
355
  copyThread.start()
400
356
  val copyHandler = Handler(copyThread.looper)
401
- val latch = java.util.concurrent.CountDownLatch(1)
402
357
  android.view.PixelCopy.request(window, blockerBitmap, { result ->
403
- Log.d(TAG, "prepareExpand: PixelCopy result=$result (0=SUCCESS)")
404
- latch.countDown()
358
+ copyThread.quitSafely()
359
+ if (result == android.view.PixelCopy.SUCCESS) {
360
+ mainHandler.post {
361
+ val blockerImg = ImageView(context)
362
+ blockerImg.setImageBitmap(blockerBitmap)
363
+ blockerImg.scaleType = ImageView.ScaleType.FIT_XY
364
+ blockerImg.layoutParams = FrameLayout.LayoutParams(
365
+ FrameLayout.LayoutParams.MATCH_PARENT,
366
+ FrameLayout.LayoutParams.MATCH_PARENT
367
+ )
368
+ // Insert behind the card wrapper (index 0)
369
+ fullScreenOverlay.addView(blockerImg, 0)
370
+ }
371
+ } else {
372
+ Log.w(TAG, "prepareExpand: PixelCopy failed with result=$result")
373
+ blockerBitmap.recycle()
374
+ }
405
375
  }, copyHandler)
406
- // Wait for the copy (typically <5ms)
407
- try { latch.await(100, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
408
- copyThread.quitSafely()
409
-
410
- val blockerImg = ImageView(context)
411
- blockerImg.setImageBitmap(blockerBitmap)
412
- blockerImg.scaleType = ImageView.ScaleType.FIT_XY
413
- blockerImg.layoutParams = FrameLayout.LayoutParams(
414
- FrameLayout.LayoutParams.MATCH_PARENT,
415
- FrameLayout.LayoutParams.MATCH_PARENT
416
- )
417
- fullScreenOverlay.addView(blockerImg)
418
376
  }
419
377
 
420
378
  // Create card overlay at source position (on top of screen capture)
@@ -695,7 +653,7 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
695
653
  target.width.toFloat(), target.height.toFloat())
696
654
  }
697
655
 
698
- target.showSnapshot(bitmap, ImageView.ScaleType.FIT_XY, frame, cornerRadius, null)
656
+ target.showSnapshot(bitmap, frame, cornerRadius, null)
699
657
  Log.d(TAG, "transferSnapshot: handed snapshot to MorphCardTargetView")
700
658
  }
701
659
 
@@ -806,13 +764,6 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
806
764
  overlayContainer = wrapper
807
765
  }
808
766
 
809
- // Ensure wrapper is valid
810
- if (wrapper == null) {
811
- isExpanded = false
812
- promise.resolve(false)
813
- return
814
- }
815
-
816
767
  // Show source screen underneath
817
768
  val sourceScreen = sourceScreenContainerRef?.get()
818
769
  val targetScreen = targetScreenContainerRef?.get()
@@ -35,11 +35,11 @@ class MorphCardTargetManager :
35
35
  }
36
36
 
37
37
  override fun setTargetWidth(view: MorphCardTargetView, value: Double) {
38
- view.targetWidth = value.toFloat()
38
+ // Managed by the source view; required by codegen interface
39
39
  }
40
40
 
41
41
  override fun setTargetHeight(view: MorphCardTargetView, value: Double) {
42
- view.targetHeight = value.toFloat()
42
+ // Managed by the source view; required by codegen interface
43
43
  }
44
44
 
45
45
  override fun setTargetBorderRadius(view: MorphCardTargetView, value: Double) {
@@ -3,22 +3,15 @@ package com.melivalesca.morphcard
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.Canvas
6
- import android.graphics.Matrix
7
- import android.graphics.Outline
8
6
  import android.graphics.Paint
9
7
  import android.graphics.Path
10
8
  import android.graphics.RectF
11
9
  import android.util.Log
12
10
  import android.view.View
13
- import android.view.ViewGroup
14
- import android.view.ViewOutlineProvider
15
- import android.widget.ImageView
16
11
  import com.facebook.react.views.view.ReactViewGroup
17
12
 
18
13
  class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
19
14
 
20
- var targetWidth: Float = 0f
21
- var targetHeight: Float = 0f
22
15
  var targetBorderRadius: Float = -1f
23
16
  var collapseDuration: Double = 0.0
24
17
  var sourceTag: Int = 0
@@ -37,13 +30,13 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
37
30
  override fun onAttachedToWindow() {
38
31
  super.onAttachedToWindow()
39
32
  MorphCardViewRegistry.register(this, id)
40
- Log.d("MorphCard", "TargetView attached: id=$id sourceTag=$sourceTag")
33
+ Log.d(TAG, "TargetView attached: id=$id sourceTag=$sourceTag")
41
34
 
42
35
  if (sourceTag > 0) {
43
36
  val screenContainer = findScreenContainer(this)
44
37
  if (screenContainer != null) {
45
38
  screenContainer.visibility = View.INVISIBLE
46
- Log.d("MorphCard", "TargetView: set screen INVISIBLE")
39
+ Log.d(TAG, "TargetView: set screen INVISIBLE")
47
40
  }
48
41
  }
49
42
  }
@@ -104,42 +97,16 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
104
97
 
105
98
  private fun applyBorderRadiusClipping() {
106
99
  val radiusPx = if (targetBorderRadius > 0f) targetBorderRadius * density else 0f
107
- if (radiusPx > 0f) {
108
- clipToOutline = true
109
- outlineProvider = object : ViewOutlineProvider() {
110
- override fun getOutline(v: View, outline: Outline) {
111
- outline.setRoundRect(0, 0, v.width, v.height, radiusPx)
112
- }
113
- }
114
- } else {
115
- clipToOutline = false
116
- }
117
- }
118
-
119
- private fun findScreenContainer(view: View?): View? {
120
- if (view == null) return null
121
- var current: View? = view
122
- while (current != null) {
123
- val parent = current.parent
124
- if (parent is ViewGroup) {
125
- val parentName = parent.javaClass.name
126
- if (parentName.contains("ScreenStack") || parentName.contains("ScreenContainer")) {
127
- return current
128
- }
129
- }
130
- current = if (current.parent is View) current.parent as View else null
131
- }
132
- return null
100
+ setRoundedCorners(this, radiusPx)
133
101
  }
134
102
 
135
103
  fun showSnapshot(
136
104
  image: Bitmap,
137
- scaleType: ImageView.ScaleType,
138
105
  frame: RectF,
139
106
  cornerRadius: Float,
140
107
  backgroundColor: Int?
141
108
  ) {
142
- Log.d("MorphCard", "showSnapshot: viewSize=${width}x${height} frame=$frame cornerR=$cornerRadius bg=$backgroundColor")
109
+ Log.d(TAG, "showSnapshot: viewSize=${width}x${height} frame=$frame cornerR=$cornerRadius bg=$backgroundColor")
143
110
  snapshotBitmap = image
144
111
  snapshotFrame = frame
145
112
  snapshotCornerRadius = cornerRadius
@@ -149,7 +116,7 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
149
116
 
150
117
  fun clearSnapshot() {
151
118
  if (snapshotBitmap != null) {
152
- Log.d("MorphCard", "clearSnapshot: clearing bitmap")
119
+ Log.d(TAG, "clearSnapshot: clearing bitmap")
153
120
  snapshotBitmap = null
154
121
  snapshotFrame = null
155
122
  snapshotCornerRadius = 0f
@@ -157,4 +124,8 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
157
124
  invalidate()
158
125
  }
159
126
  }
127
+
128
+ companion object {
129
+ private const val TAG = "MorphCard"
130
+ }
160
131
  }
@@ -0,0 +1,43 @@
1
+ package com.melivalesca.morphcard
2
+
3
+ import android.graphics.Outline
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import android.view.ViewOutlineProvider
7
+
8
+ /**
9
+ * Walk up the view hierarchy until the parent is a ScreenStack or ScreenContainer,
10
+ * returning the immediate child (the "screen container" for that view).
11
+ */
12
+ internal fun findScreenContainer(view: View?): View? {
13
+ if (view == null) return null
14
+ var current: View? = view
15
+ while (current != null) {
16
+ val parent = current.parent
17
+ if (parent is ViewGroup) {
18
+ val parentName = parent.javaClass.name
19
+ if (parentName.contains("ScreenStack") || parentName.contains("ScreenContainer")) {
20
+ return current
21
+ }
22
+ }
23
+ current = if (current.parent is View) current.parent as View else null
24
+ }
25
+ return null
26
+ }
27
+
28
+ /**
29
+ * Apply rounded-corner clipping to a view via its outline provider.
30
+ * If [radiusPx] is <= 0, clipping is disabled.
31
+ */
32
+ internal fun setRoundedCorners(view: View, radiusPx: Float) {
33
+ if (radiusPx <= 0f) {
34
+ view.clipToOutline = false
35
+ return
36
+ }
37
+ view.clipToOutline = true
38
+ view.outlineProvider = object : ViewOutlineProvider() {
39
+ override fun getOutline(v: View, outline: Outline) {
40
+ outline.setRoundRect(0, 0, v.width, v.height, radiusPx)
41
+ }
42
+ }
43
+ }
@@ -14,9 +14,6 @@ NS_ASSUME_NONNULL_BEGIN
14
14
  - (void)expandToTarget:(nullable UIView *)targetView
15
15
  resolve:(RCTPromiseResolveBlock)resolve;
16
16
 
17
- - (void)collapseFromTarget:(nullable UIView *)targetView
18
- resolve:(RCTPromiseResolveBlock)resolve;
19
-
20
17
  /// Collapse using the stored target from the last expand call.
21
18
  - (void)collapseWithResolve:(RCTPromiseResolveBlock)resolve;
22
19
 
@@ -22,7 +22,7 @@ static UIWindow *getKeyWindow(void) {
22
22
  return nil;
23
23
  }
24
24
 
25
- static UIView *findScreenContainer(UIView *view) {
25
+ UIView *RNCMorphCardFindScreenContainer(UIView *view) {
26
26
  UIWindow *window = view.window;
27
27
  if (!window) return nil;
28
28
 
@@ -72,6 +72,15 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
72
72
  }
73
73
  }
74
74
 
75
+ #pragma mark - Private interface
76
+
77
+ @interface RNCMorphCardSourceComponentView ()
78
+
79
+ - (void)collapseFromTarget:(nullable UIView *)targetView
80
+ resolve:(RCTPromiseResolveBlock)resolve;
81
+
82
+ @end
83
+
75
84
  @implementation RNCMorphCardSourceComponentView {
76
85
  CGFloat _duration;
77
86
  CGFloat _expandDuration;
@@ -152,6 +161,64 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
152
161
  }];
153
162
  }
154
163
 
164
+ #pragma mark - Shared helpers
165
+
166
+ /// Compute the target frame and corner radius from the given target view
167
+ /// (or fall back to _cardFrame if targetView is nil).
168
+ - (CGRect)targetFrameForView:(UIView *)targetView
169
+ cornerRadius:(CGFloat *)outCornerRadius {
170
+ CGPoint targetOrigin = targetView
171
+ ? [targetView convertPoint:CGPointZero toView:nil]
172
+ : _cardFrame.origin;
173
+
174
+ CGFloat tw = self.pendingTargetWidth;
175
+ CGFloat th = self.pendingTargetHeight;
176
+ CGFloat tbr = self.pendingTargetBorderRadius;
177
+
178
+ CGRect targetFrame = CGRectMake(
179
+ targetOrigin.x,
180
+ targetOrigin.y,
181
+ tw > 0 ? tw : _cardFrame.size.width,
182
+ th > 0 ? th : _cardFrame.size.height);
183
+
184
+ if (outCornerRadius) {
185
+ *outCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
186
+ }
187
+ return targetFrame;
188
+ }
189
+
190
+ /// Shared cleanup performed at the end of every collapse animation.
191
+ - (void)collapseCleanupWithContainer:(UIView *)container
192
+ resolve:(RCTPromiseResolveBlock)resolve {
193
+ [container removeFromSuperview];
194
+ _wrapperView = nil;
195
+ _snapshot = nil;
196
+ self.alpha = 1;
197
+ _isExpanded = NO;
198
+ _sourceScreenContainer = nil;
199
+ _targetScreenContainer = nil;
200
+ resolve(@(YES));
201
+ }
202
+
203
+ /// Schedule the screen-fade dispatch_after used by both collapse modes.
204
+ /// Guards against the race where the animation completes before the
205
+ /// dispatch fires — if cleanup already ran, _isExpanded is NO.
206
+ - (void)scheduleScreenFadeOut:(UIView *)screenView
207
+ duration:(NSTimeInterval)dur {
208
+ __weak RNCMorphCardSourceComponentView *weakSelf = self;
209
+ dispatch_after(
210
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
211
+ dispatch_get_main_queue(), ^{
212
+ RNCMorphCardSourceComponentView *strongSelf = weakSelf;
213
+ if (!strongSelf || !strongSelf->_isExpanded) return;
214
+ [UIView animateWithDuration:dur * 0.65
215
+ animations:^{
216
+ if (screenView) { screenView.alpha = 0; }
217
+ }
218
+ completion:nil];
219
+ });
220
+ }
221
+
155
222
  #pragma mark - Expand
156
223
 
157
224
  - (void)expandToTarget:(UIView *)targetView
@@ -179,30 +246,18 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
179
246
  UIImage *cardImage = [self captureSnapshot];
180
247
 
181
248
  // ── 3. Keep source screen visible during navigation transition ──
182
- UIView *sourceScreen = findScreenContainer(self);
249
+ UIView *sourceScreen = RNCMorphCardFindScreenContainer(self);
183
250
  _sourceScreenContainer = sourceScreen;
184
- _targetScreenContainer = findScreenContainer(targetView);
251
+ _targetScreenContainer = RNCMorphCardFindScreenContainer(targetView);
185
252
 
186
253
  if (sourceScreen) {
187
254
  sourceScreen.alpha = 1;
188
255
  }
189
256
 
190
257
  // ── 4. Compute target frame and corner radius ──
191
- CGPoint targetOrigin = targetView
192
- ? [targetView convertPoint:CGPointZero toView:nil]
193
- : _cardFrame.origin;
194
-
195
- CGFloat tw = self.pendingTargetWidth;
196
- CGFloat th = self.pendingTargetHeight;
197
- CGFloat tbr = self.pendingTargetBorderRadius;
198
-
199
- CGRect targetFrame = CGRectMake(
200
- targetOrigin.x,
201
- targetOrigin.y,
202
- tw > 0 ? tw : _cardFrame.size.width,
203
- th > 0 ? th : _cardFrame.size.height);
204
-
205
- CGFloat targetCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
258
+ CGFloat targetCornerRadius = 0;
259
+ CGRect targetFrame = [self targetFrameForView:targetView
260
+ cornerRadius:&targetCornerRadius];
206
261
 
207
262
  NSTimeInterval dur = (_expandDuration > 0 ? _expandDuration : _duration) / 1000.0;
208
263
 
@@ -255,10 +310,13 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
255
310
  }
256
311
 
257
312
  // Start fading in screen content early (at 15% of the animation).
313
+ __weak RNCMorphCardSourceComponentView *weakSelf = self;
258
314
  dispatch_after(
259
315
  dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
260
316
  dispatch_get_main_queue(), ^{
261
- UIView *ts = self->_targetScreenContainer;
317
+ RNCMorphCardSourceComponentView *strongSelf = weakSelf;
318
+ if (!strongSelf || !strongSelf->_isExpanded) return;
319
+ UIView *ts = strongSelf->_targetScreenContainer;
262
320
  if (ts) {
263
321
  [UIView animateWithDuration:dur * 0.5
264
322
  animations:^{ ts.alpha = 1; }
@@ -334,10 +392,13 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
334
392
  }];
335
393
 
336
394
  // Start fading in screen content early (at 15% of the animation).
395
+ __weak RNCMorphCardSourceComponentView *weakSelf2 = self;
337
396
  dispatch_after(
338
397
  dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
339
398
  dispatch_get_main_queue(), ^{
340
- UIView *ts = self->_targetScreenContainer;
399
+ RNCMorphCardSourceComponentView *strongSelf2 = weakSelf2;
400
+ if (!strongSelf2 || !strongSelf2->_isExpanded) return;
401
+ UIView *ts = strongSelf2->_targetScreenContainer;
341
402
  if (ts) {
342
403
  [UIView animateWithDuration:dur * 0.5
343
404
  animations:^{ ts.alpha = 1; }
@@ -405,17 +466,9 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
405
466
  UIImage *cardImage = [self captureSnapshot];
406
467
  self.alpha = 0;
407
468
 
408
- CGPoint targetOrigin = targetView
409
- ? [targetView convertPoint:CGPointZero toView:nil]
410
- : _cardFrame.origin;
411
- CGFloat tw = self.pendingTargetWidth;
412
- CGFloat th = self.pendingTargetHeight;
413
- CGFloat tbr = self.pendingTargetBorderRadius;
414
- CGRect targetFrame = CGRectMake(
415
- targetOrigin.x, targetOrigin.y,
416
- tw > 0 ? tw : _cardFrame.size.width,
417
- th > 0 ? th : _cardFrame.size.height);
418
- CGFloat targetCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
469
+ CGFloat targetCornerRadius = 0;
470
+ CGRect targetFrame = [self targetFrameForView:targetView
471
+ cornerRadius:&targetCornerRadius];
419
472
 
420
473
  CGFloat contentOffsetY = self.pendingContentOffsetY;
421
474
  BOOL contentCentered = self.pendingContentCentered;
@@ -462,25 +515,11 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
462
515
  }
463
516
  }];
464
517
 
465
- // Fade out target screen concurrently at 15%, over 65% of duration
466
- dispatch_after(
467
- dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
468
- dispatch_get_main_queue(), ^{
469
- [UIView animateWithDuration:dur * 0.65
470
- animations:^{
471
- if (targetScreen) { targetScreen.alpha = 0; }
472
- }
473
- completion:nil];
474
- });
518
+ // Fade out target screen
519
+ [self scheduleScreenFadeOut:targetScreen duration:dur];
475
520
 
476
521
  [animator addCompletion:^(UIViewAnimatingPosition pos) {
477
- [wrapper removeFromSuperview];
478
- self->_wrapperView = nil;
479
- self.alpha = 1;
480
- self->_isExpanded = NO;
481
- self->_sourceScreenContainer = nil;
482
- self->_targetScreenContainer = nil;
483
- resolve(@(YES));
522
+ [self collapseCleanupWithContainer:wrapper resolve:resolve];
484
523
  }];
485
524
 
486
525
  [animator startAnimation];
@@ -495,17 +534,9 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
495
534
  UIImage *cardImage = [self captureSnapshot];
496
535
  self.alpha = 0;
497
536
 
498
- CGPoint targetOrigin = targetView
499
- ? [targetView convertPoint:CGPointZero toView:nil]
500
- : _cardFrame.origin;
501
- CGFloat tw = self.pendingTargetWidth;
502
- CGFloat th = self.pendingTargetHeight;
503
- CGFloat tbr = self.pendingTargetBorderRadius;
504
- CGRect targetFrame = CGRectMake(
505
- targetOrigin.x, targetOrigin.y,
506
- tw > 0 ? tw : _cardFrame.size.width,
507
- th > 0 ? th : _cardFrame.size.height);
508
- CGFloat targetCornerRadius = tbr >= 0 ? tbr : _cardCornerRadius;
537
+ CGFloat targetCornerRadius = 0;
538
+ CGRect targetFrame = [self targetFrameForView:targetView
539
+ cornerRadius:&targetCornerRadius];
509
540
 
510
541
  CGSize imageSize = cardImage.size;
511
542
  CGRect imageFrame = imageFrameForScaleMode(
@@ -530,38 +561,23 @@ static CGRect imageFrameForScaleMode(UIViewContentMode mode,
530
561
  sourceScreen.alpha = 1;
531
562
  }
532
563
 
533
- UICubicTimingParameters *timing2 = [[UICubicTimingParameters alloc]
564
+ UICubicTimingParameters *timing = [[UICubicTimingParameters alloc]
534
565
  initWithControlPoint1:CGPointMake(0.25, 1.0)
535
566
  controlPoint2:CGPointMake(0.5, 1.0)];
536
567
  UIViewPropertyAnimator *animator = [[UIViewPropertyAnimator alloc]
537
568
  initWithDuration:dur
538
- timingParameters:timing2];
569
+ timingParameters:timing];
539
570
  [animator addAnimations:^{
540
571
  container.frame = self->_cardFrame;
541
572
  container.layer.cornerRadius = self->_cardCornerRadius;
542
573
  snapshot.frame = (CGRect){CGPointZero, self->_cardFrame.size};
543
574
  }];
544
575
 
545
- // Fade out target screen concurrently at 15%, over 65% of duration
546
- dispatch_after(
547
- dispatch_time(DISPATCH_TIME_NOW, (int64_t)(dur * 0.15 * NSEC_PER_SEC)),
548
- dispatch_get_main_queue(), ^{
549
- [UIView animateWithDuration:dur * 0.65
550
- animations:^{
551
- if (targetScreen) { targetScreen.alpha = 0; }
552
- }
553
- completion:nil];
554
- });
576
+ // Fade out target screen
577
+ [self scheduleScreenFadeOut:targetScreen duration:dur];
555
578
 
556
579
  [animator addCompletion:^(UIViewAnimatingPosition pos) {
557
- [container removeFromSuperview];
558
- self->_wrapperView = nil;
559
- self->_snapshot = nil;
560
- self.alpha = 1;
561
- self->_isExpanded = NO;
562
- self->_sourceScreenContainer = nil;
563
- self->_targetScreenContainer = nil;
564
- resolve(@(YES));
580
+ [self collapseCleanupWithContainer:container resolve:resolve];
565
581
  }];
566
582
 
567
583
  [animator startAnimation];
@@ -1,4 +1,5 @@
1
1
  #import "RNCMorphCardTargetComponentView.h"
2
+ #import "RNCMorphCardSourceComponentView.h"
2
3
  #import "RNCMorphCardViewRegistry.h"
3
4
 
4
5
  #import <React/RCTFabricComponentsPlugins.h>
@@ -7,9 +8,11 @@
7
8
 
8
9
  using namespace facebook::react;
9
10
 
11
+ // Declared in RNCMorphCardSourceComponentView.mm
12
+ extern UIView *RNCMorphCardFindScreenContainer(UIView *view);
13
+
10
14
  @implementation RNCMorphCardTargetComponentView {
11
15
  UIView *_snapshotContainer; // our own view — Fabric can't reset its styles
12
- UIImageView *_snapshotView;
13
16
  }
14
17
 
15
18
  + (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -35,17 +38,7 @@ using namespace facebook::react;
35
38
 
36
39
  // Immediately hide the detail screen container to prevent flicker.
37
40
  // The expand animation will fade it back in.
38
- UIWindow *window = self.window;
39
- CGRect windowBounds = window.bounds;
40
- UIView *current = self.superview;
41
- UIView *screenContainer = nil;
42
- while (current && current != window) {
43
- CGRect frameInWindow = [current convertRect:current.bounds toView:nil];
44
- if (CGRectEqualToRect(frameInWindow, windowBounds)) {
45
- screenContainer = current;
46
- }
47
- current = current.superview;
48
- }
41
+ UIView *screenContainer = RNCMorphCardFindScreenContainer(self);
49
42
  if (screenContainer) {
50
43
  screenContainer.alpha = 0;
51
44
  }
@@ -77,14 +70,12 @@ using namespace facebook::react;
77
70
 
78
71
  [self addSubview:container];
79
72
  _snapshotContainer = container;
80
- _snapshotView = iv;
81
73
  }
82
74
 
83
75
  - (void)clearSnapshot {
84
76
  if (_snapshotContainer) {
85
77
  [_snapshotContainer removeFromSuperview];
86
78
  _snapshotContainer = nil;
87
- _snapshotView = nil;
88
79
  }
89
80
  }
90
81
 
@@ -24,7 +24,7 @@
24
24
  @implementation RNCMorphCardSource {
25
25
  // Saved state for collapsing back.
26
26
  CGRect _originalFrame;
27
- UIView *_originalSuperview;
27
+ __weak UIView *_originalSuperview;
28
28
  NSInteger _originalIndex;
29
29
  CGFloat _originalCornerRadius;
30
30
  BOOL _isExpanded;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-morph-card",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Native card-to-modal morph transition for React Native. iOS App Store-style expand animation.",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",