react-native-morph-card 0.2.7 → 0.3.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 (34) hide show
  1. package/README.md +40 -31
  2. package/android/src/main/java/com/melivalesca/morphcard/MorphCardSourceView.kt +27 -72
  3. package/android/src/main/java/com/melivalesca/morphcard/MorphCardTargetManager.kt +2 -2
  4. package/android/src/main/java/com/melivalesca/morphcard/MorphCardTargetView.kt +34 -39
  5. package/android/src/main/java/com/melivalesca/morphcard/MorphCardUtils.kt +43 -0
  6. package/ios/Fabric/RNCMorphCardSourceComponentView.h +0 -3
  7. package/ios/Fabric/RNCMorphCardSourceComponentView.mm +110 -80
  8. package/ios/Fabric/RNCMorphCardTargetComponentView.h +1 -0
  9. package/ios/Fabric/RNCMorphCardTargetComponentView.mm +26 -14
  10. package/ios/RNCMorphCardSource.m +1 -1
  11. package/lib/commonjs/MorphCardSource.js +25 -2
  12. package/lib/commonjs/MorphCardSource.js.map +1 -1
  13. package/lib/commonjs/MorphCardTarget.js +31 -7
  14. package/lib/commonjs/MorphCardTarget.js.map +1 -1
  15. package/lib/commonjs/MorphChildrenRegistry.js +34 -0
  16. package/lib/commonjs/MorphChildrenRegistry.js.map +1 -0
  17. package/lib/module/MorphCardSource.js +25 -2
  18. package/lib/module/MorphCardSource.js.map +1 -1
  19. package/lib/module/MorphCardTarget.js +31 -7
  20. package/lib/module/MorphCardTarget.js.map +1 -1
  21. package/lib/module/MorphChildrenRegistry.js +27 -0
  22. package/lib/module/MorphChildrenRegistry.js.map +1 -0
  23. package/lib/typescript/src/MorphCardSource.d.ts +4 -4
  24. package/lib/typescript/src/MorphCardSource.d.ts.map +1 -1
  25. package/lib/typescript/src/MorphCardTarget.d.ts.map +1 -1
  26. package/lib/typescript/src/MorphChildrenRegistry.d.ts +13 -0
  27. package/lib/typescript/src/MorphChildrenRegistry.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +1 -1
  29. package/lib/typescript/src/index.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/MorphCardSource.tsx +27 -5
  32. package/src/MorphCardTarget.tsx +44 -9
  33. package/src/MorphChildrenRegistry.ts +43 -0
  34. package/src/index.tsx +1 -1
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
@@ -46,12 +46,16 @@ No additional steps required.
46
46
 
47
47
  Wrap your card content in `MorphCardSource`. On the detail screen, use `MorphCardTarget` where the card should land. Use `useMorphTarget` for easy collapse handling.
48
48
 
49
- > **Important:** `MorphCardSource` can wrap any React Native component (images, views, text, etc.), but during the animation the content is captured as a **bitmap snapshot**. This means dynamic or observable values (timers, animated values, live data) will freeze at the moment of capture. Design your card content with this in mind.
49
+ > **Important:** `MorphCardSource` can wrap any React Native component (images, views, text, etc.). During the animation, the content is captured as a **bitmap snapshot** — but once the animation completes, the snapshot fades out and the source's children are automatically cloned into `MorphCardTarget` as live React components. The cloned children are rendered at the source card's original layout dimensions, so your component layout stays consistent. This means observable values (timers, animated values, live data) will update in real time after the transition finishes. If you use `resizeMode`, the bitmap is kept instead (native image scaling doesn't apply to React components).
50
50
 
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
+ | `resizeMode` | `'cover' \| 'contain' \| 'stretch'` | `'cover'` | How the snapshot scales during animation. When set, the bitmap is kept after expand (no live children). **Recommended when wrapping an `<Image>` — without it, the image may not scale properly during the 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
- Placed on the detail screen where the card should land. Triggers the expand animation on mount.
118
+ Placed on the detail screen where the card should land. Triggers the expand animation on mount. After the animation, the source's children are automatically cloned here as live React components.
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,6 +160,7 @@ await morphCollapse(sourceTag);
152
160
  // Get the native view tag from a ref
153
161
  const tag = getViewTag(viewRef);
154
162
  ```
163
+
155
164
  ```
156
165
 
157
166
  ## Running the example app
@@ -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,8 +653,10 @@ 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")
658
+ // Crossfade snapshot out to reveal live React children underneath
659
+ mainHandler.postDelayed({ target.fadeOutSnapshot() }, 50)
700
660
  }
701
661
 
702
662
  val fadeOut = ValueAnimator.ofFloat(1f, 0f)
@@ -768,8 +728,10 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
768
728
  val cardImage = captureSnapshot()
769
729
  alpha = 0f
770
730
 
771
- // Clear the snapshot from the target view
731
+ // Clear the snapshot and hide the target view so live children
732
+ // don't show behind the animating collapse overlay
772
733
  target?.clearSnapshot()
734
+ targetView?.visibility = View.INVISIBLE
773
735
 
774
736
  wrapper = FrameLayout(context)
775
737
  wrapper.layoutParams = FrameLayout.LayoutParams(twPx.toInt(), thPx.toInt())
@@ -806,13 +768,6 @@ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
806
768
  overlayContainer = wrapper
807
769
  }
808
770
 
809
- // Ensure wrapper is valid
810
- if (wrapper == null) {
811
- isExpanded = false
812
- promise.resolve(false)
813
- return
814
- }
815
-
816
771
  // Show source screen underneath
817
772
  val sourceScreen = sourceScreenContainerRef?.get()
818
773
  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) {
@@ -1,24 +1,18 @@
1
1
  package com.melivalesca.morphcard
2
2
 
3
+ import android.animation.ValueAnimator
3
4
  import android.content.Context
4
5
  import android.graphics.Bitmap
5
6
  import android.graphics.Canvas
6
- import android.graphics.Matrix
7
- import android.graphics.Outline
8
7
  import android.graphics.Paint
9
8
  import android.graphics.Path
10
9
  import android.graphics.RectF
11
10
  import android.util.Log
12
11
  import android.view.View
13
- import android.view.ViewGroup
14
- import android.view.ViewOutlineProvider
15
- import android.widget.ImageView
16
12
  import com.facebook.react.views.view.ReactViewGroup
17
13
 
18
14
  class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
19
15
 
20
- var targetWidth: Float = 0f
21
- var targetHeight: Float = 0f
22
16
  var targetBorderRadius: Float = -1f
23
17
  var collapseDuration: Double = 0.0
24
18
  var sourceTag: Int = 0
@@ -28,6 +22,7 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
28
22
  private var snapshotFrame: RectF? = null
29
23
  private var snapshotCornerRadius: Float = 0f
30
24
  private var snapshotBgColor: Int? = null
25
+ private var snapshotAlpha: Float = 1f
31
26
  private val snapshotPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
32
27
  private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
33
28
 
@@ -37,13 +32,13 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
37
32
  override fun onAttachedToWindow() {
38
33
  super.onAttachedToWindow()
39
34
  MorphCardViewRegistry.register(this, id)
40
- Log.d("MorphCard", "TargetView attached: id=$id sourceTag=$sourceTag")
35
+ Log.d(TAG, "TargetView attached: id=$id sourceTag=$sourceTag")
41
36
 
42
37
  if (sourceTag > 0) {
43
38
  val screenContainer = findScreenContainer(this)
44
39
  if (screenContainer != null) {
45
40
  screenContainer.visibility = View.INVISIBLE
46
- Log.d("MorphCard", "TargetView: set screen INVISIBLE")
41
+ Log.d(TAG, "TargetView: set screen INVISIBLE")
47
42
  }
48
43
  }
49
44
  }
@@ -65,7 +60,9 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
65
60
  override fun dispatchDraw(canvas: Canvas) {
66
61
  val bmp = snapshotBitmap
67
62
  val frame = snapshotFrame
68
- if (bmp != null && frame != null) {
63
+ if (bmp != null && frame != null && snapshotAlpha > 0f) {
64
+ snapshotPaint.alpha = (snapshotAlpha * 255).toInt()
65
+ bgPaint.alpha = (snapshotAlpha * 255).toInt()
69
66
  val radiusPx = snapshotCornerRadius
70
67
 
71
68
  // Clip to rounded rect if needed
@@ -104,42 +101,16 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
104
101
 
105
102
  private fun applyBorderRadiusClipping() {
106
103
  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
104
+ setRoundedCorners(this, radiusPx)
133
105
  }
134
106
 
135
107
  fun showSnapshot(
136
108
  image: Bitmap,
137
- scaleType: ImageView.ScaleType,
138
109
  frame: RectF,
139
110
  cornerRadius: Float,
140
111
  backgroundColor: Int?
141
112
  ) {
142
- Log.d("MorphCard", "showSnapshot: viewSize=${width}x${height} frame=$frame cornerR=$cornerRadius bg=$backgroundColor")
113
+ Log.d(TAG, "showSnapshot: viewSize=${width}x${height} frame=$frame cornerR=$cornerRadius bg=$backgroundColor")
143
114
  snapshotBitmap = image
144
115
  snapshotFrame = frame
145
116
  snapshotCornerRadius = cornerRadius
@@ -147,9 +118,29 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
147
118
  invalidate()
148
119
  }
149
120
 
121
+ fun fadeOutSnapshot() {
122
+ if (snapshotBitmap == null) return
123
+ // Only fade out if there are React children underneath to reveal.
124
+ // If no children (scaleMode bitmap-only), keep the snapshot.
125
+ if (childCount == 0) return
126
+ val anim = ValueAnimator.ofFloat(1f, 0f)
127
+ anim.duration = 150
128
+ anim.addUpdateListener {
129
+ snapshotAlpha = it.animatedValue as Float
130
+ invalidate()
131
+ }
132
+ anim.addListener(object : android.animation.AnimatorListenerAdapter() {
133
+ override fun onAnimationEnd(animation: android.animation.Animator) {
134
+ clearSnapshot()
135
+ snapshotAlpha = 1f
136
+ }
137
+ })
138
+ anim.start()
139
+ }
140
+
150
141
  fun clearSnapshot() {
151
142
  if (snapshotBitmap != null) {
152
- Log.d("MorphCard", "clearSnapshot: clearing bitmap")
143
+ Log.d(TAG, "clearSnapshot: clearing bitmap")
153
144
  snapshotBitmap = null
154
145
  snapshotFrame = null
155
146
  snapshotCornerRadius = 0f
@@ -157,4 +148,8 @@ class MorphCardTargetView(context: Context) : ReactViewGroup(context) {
157
148
  invalidate()
158
149
  }
159
150
  }
151
+
152
+ companion object {
153
+ private const val TAG = "MorphCard"
154
+ }
160
155
  }
@@ -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