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,755 @@
1
+ package com.melivalesca.morphcard
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.content.Context
5
+ import android.graphics.Bitmap
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Outline
9
+ import android.graphics.RectF
10
+ import android.graphics.drawable.ColorDrawable
11
+ import android.os.Handler
12
+ import android.os.Looper
13
+ import android.util.Log
14
+ import android.view.View
15
+ import android.view.ViewGroup
16
+ import android.view.ViewOutlineProvider
17
+ import android.view.animation.PathInterpolator
18
+ import android.widget.FrameLayout
19
+ import android.widget.ImageView
20
+ import com.facebook.react.bridge.Promise
21
+ import com.facebook.react.views.view.ReactViewGroup
22
+ import java.lang.ref.WeakReference
23
+ import kotlin.math.max
24
+ import kotlin.math.min
25
+
26
+ class MorphCardSourceView(context: Context) : ReactViewGroup(context) {
27
+
28
+ // ── Props (all in dp) ──
29
+ var duration: Double = 500.0
30
+ var scaleMode: String = "aspectFill"
31
+ var borderRadiusDp: Float = 0f
32
+
33
+ // ── Target config (set by module, in dp from JS) ──
34
+ var pendingTargetWidth: Float = 0f
35
+ var pendingTargetHeight: Float = 0f
36
+ var pendingTargetBorderRadius: Float = -1f
37
+ var pendingContentOffsetY: Float = 0f
38
+ var pendingContentCentered: Boolean = false
39
+
40
+ // ── Internal state (all in px) ──
41
+ var isExpanded = false
42
+ private set
43
+ private var hasWrapper = false
44
+ private var cardLeft = 0f
45
+ private var cardTop = 0f
46
+ private var cardWidth = 0f
47
+ private var cardHeight = 0f
48
+ private var cardCornerRadiusPx = 0f
49
+ private var cardBgColor: Int? = null
50
+ private var targetViewRef: View? = null
51
+ private var overlayContainer: FrameLayout? = null
52
+ val hasOverlay: Boolean get() = overlayContainer != null
53
+ private var sourceScreenContainerRef: WeakReference<View>? = null
54
+ private var targetScreenContainerRef: WeakReference<View>? = null
55
+ private var screenStackRef: WeakReference<ViewGroup>? = null
56
+ private var hierarchyListener: ViewGroup.OnHierarchyChangeListener? = null
57
+
58
+ // Spring-like interpolator (approximates iOS dampingRatio:0.85)
59
+ private val springInterpolator = PathInterpolator(0.25f, 1.0f, 0.5f, 1.0f)
60
+
61
+ private val mainHandler = Handler(Looper.getMainLooper())
62
+
63
+ private val density: Float
64
+ get() = resources.displayMetrics.density
65
+
66
+ override fun onAttachedToWindow() {
67
+ super.onAttachedToWindow()
68
+ MorphCardViewRegistry.register(this, id)
69
+ }
70
+
71
+ override fun onDetachedFromWindow() {
72
+ super.onDetachedFromWindow()
73
+ MorphCardViewRegistry.unregister(id)
74
+ }
75
+
76
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
77
+ super.onLayout(changed, left, top, right, bottom)
78
+ applyBorderRadiusClipping()
79
+ }
80
+
81
+ private fun applyBorderRadiusClipping() {
82
+ val radiusPx = if (borderRadiusDp > 0f) borderRadiusDp * density else 0f
83
+ if (radiusPx > 0f) {
84
+ clipToOutline = true
85
+ outlineProvider = object : ViewOutlineProvider() {
86
+ override fun getOutline(v: View, outline: Outline) {
87
+ outline.setRoundRect(0, 0, v.width, v.height, radiusPx)
88
+ }
89
+ }
90
+ } else {
91
+ clipToOutline = false
92
+ }
93
+ }
94
+
95
+ // ── Snapshot ──
96
+
97
+ private fun captureSnapshot(): Bitmap {
98
+ val w = width
99
+ val h = height
100
+ val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
101
+ val canvas = Canvas(bitmap)
102
+ for (i in 0 until childCount) {
103
+ val child = getChildAt(i)
104
+ if (child.visibility != VISIBLE) continue
105
+ canvas.save()
106
+ canvas.translate(child.left.toFloat(), child.top.toFloat())
107
+ child.draw(canvas)
108
+ canvas.restore()
109
+ }
110
+ return bitmap
111
+ }
112
+
113
+ // ── Helpers ──
114
+
115
+ private fun getLocationInWindow(view: View): IntArray {
116
+ val loc = IntArray(2)
117
+ view.getLocationInWindow(loc)
118
+ return loc
119
+ }
120
+
121
+ /**
122
+ * Find the screen container for a view (ScreensCoordinatorLayout).
123
+ * Walks up until parent is ScreenStack/ScreenContainer.
124
+ */
125
+ private fun findScreenContainer(view: View?): View? {
126
+ if (view == null) return null
127
+ var current: View? = view
128
+ while (current != null) {
129
+ val parent = current.parent
130
+ if (parent is ViewGroup) {
131
+ val parentName = parent.javaClass.name
132
+ if (parentName.contains("ScreenStack") || parentName.contains("ScreenContainer")) {
133
+ return current
134
+ }
135
+ }
136
+ current = if (current.parent is View) current.parent as View else null
137
+ }
138
+ return null
139
+ }
140
+
141
+
142
+ private fun extractBackgroundColor(): Int? {
143
+ val bg = background ?: return null
144
+ if (bg is ColorDrawable) return bg.color
145
+ try {
146
+ val clazz = bg.javaClass
147
+ try {
148
+ val bgField = clazz.getDeclaredField("background")
149
+ bgField.isAccessible = true
150
+ val bgDrawable = bgField.get(bg)
151
+ if (bgDrawable != null) {
152
+ val colorField = bgDrawable.javaClass.getDeclaredField("backgroundColor")
153
+ colorField.isAccessible = true
154
+ val color = colorField.getInt(bgDrawable)
155
+ if (Color.alpha(color) > 3) return color
156
+ }
157
+ } catch (_: Exception) {}
158
+ try {
159
+ val cssField = clazz.getDeclaredField("cssBackground")
160
+ cssField.isAccessible = true
161
+ val cssBg = cssField.get(bg)
162
+ if (cssBg != null) {
163
+ val colorField = cssBg.javaClass.getDeclaredField("mColor")
164
+ colorField.isAccessible = true
165
+ val color = colorField.getInt(cssBg)
166
+ if (Color.alpha(color) > 3) return color
167
+ }
168
+ } catch (_: Exception) {}
169
+ } catch (_: Exception) {}
170
+ return null
171
+ }
172
+
173
+ private fun getDecorView(): ViewGroup? {
174
+ var v: View = this
175
+ while (v.parent is View) {
176
+ v = v.parent as View
177
+ }
178
+ return v as? ViewGroup
179
+ }
180
+
181
+ private fun getCornerRadiusPx(): Float {
182
+ if (borderRadiusDp > 0f) return borderRadiusDp * density
183
+ return 0f
184
+ }
185
+
186
+ private fun imageFrameForScaleMode(
187
+ mode: String,
188
+ imageWidth: Float,
189
+ imageHeight: Float,
190
+ containerWidth: Float,
191
+ containerHeight: Float
192
+ ): RectF {
193
+ return when (mode) {
194
+ "aspectFit" -> {
195
+ val scale = min(containerWidth / imageWidth, containerHeight / imageHeight)
196
+ val w = imageWidth * scale
197
+ val h = imageHeight * scale
198
+ RectF((containerWidth - w) / 2f, (containerHeight - h) / 2f,
199
+ (containerWidth + w) / 2f, (containerHeight + h) / 2f)
200
+ }
201
+ "stretch" -> RectF(0f, 0f, containerWidth, containerHeight)
202
+ else -> {
203
+ val scale = max(containerWidth / imageWidth, containerHeight / imageHeight)
204
+ val w = imageWidth * scale
205
+ val h = imageHeight * scale
206
+ RectF((containerWidth - w) / 2f, (containerHeight - h) / 2f,
207
+ (containerWidth + w) / 2f, (containerHeight + h) / 2f)
208
+ }
209
+ }
210
+ }
211
+
212
+ private fun setRoundedCorners(view: View, radiusPx: Float) {
213
+ if (radiusPx <= 0f) {
214
+ view.clipToOutline = false
215
+ return
216
+ }
217
+ view.clipToOutline = true
218
+ view.outlineProvider = object : ViewOutlineProvider() {
219
+ override fun getOutline(v: View, outline: Outline) {
220
+ outline.setRoundRect(0, 0, v.width, v.height, radiusPx)
221
+ }
222
+ }
223
+ }
224
+
225
+ private fun removeHierarchyListener() {
226
+ screenStackRef?.get()?.setOnHierarchyChangeListener(null)
227
+ screenStackRef = null
228
+ hierarchyListener = null
229
+ }
230
+
231
+ // ══════════════════════════════════════════════════════════════
232
+ // PHASE 1: prepareExpand — called IMMEDIATELY, before delay
233
+ // Creates overlay at source position + hides target screen.
234
+ // ══════════════════════════════════════════════════════════════
235
+
236
+ fun prepareExpand(targetView: View?) {
237
+ Log.d(TAG, "=== prepareExpand START === isExpanded=$isExpanded targetView=$targetView targetView.id=${targetView?.id}")
238
+ if (isExpanded) {
239
+ Log.d(TAG, "prepareExpand: SKIPPED — already expanded")
240
+ return
241
+ }
242
+
243
+ val decorView = getDecorView() ?: return
244
+
245
+ // Clean up any stale overlay from a previous cycle
246
+ overlayContainer?.let { stale ->
247
+ Log.d(TAG, "prepareExpand: removing stale overlay")
248
+ decorView.removeView(stale)
249
+ overlayContainer = null
250
+ }
251
+ // Clear snapshot from previous target view if any
252
+ (targetViewRef as? MorphCardTargetView)?.clearSnapshot()
253
+
254
+ targetViewRef = targetView
255
+ cardBgColor = extractBackgroundColor()
256
+ hasWrapper = cardBgColor != null
257
+
258
+ // Save card geometry
259
+ val loc = getLocationInWindow(this)
260
+ cardLeft = loc[0].toFloat()
261
+ cardTop = loc[1].toFloat()
262
+ cardWidth = width.toFloat()
263
+ cardHeight = height.toFloat()
264
+ cardCornerRadiusPx = getCornerRadiusPx()
265
+ Log.d(TAG, "prepareExpand: source card=[${cardLeft},${cardTop},${cardWidth}x${cardHeight}] cornerR=$cardCornerRadiusPx hasWrapper=$hasWrapper")
266
+
267
+ // Find screen containers
268
+ val sourceScreen = findScreenContainer(this)
269
+ val targetScreen = findScreenContainer(targetView)
270
+ sourceScreenContainerRef = if (sourceScreen != null) WeakReference(sourceScreen) else null
271
+ targetScreenContainerRef = if (targetScreen != null) WeakReference(targetScreen) else null
272
+
273
+ // Hide target screen with INVISIBLE (can't be overridden by alpha resets)
274
+ if (targetScreen != null && targetScreen !== sourceScreen) {
275
+ targetScreen.visibility = View.INVISIBLE
276
+ Log.d(TAG, "prepareExpand: set target screen INVISIBLE")
277
+ }
278
+
279
+ // Watch the ScreenStack for new screens being added and hide them
280
+ // immediately. This catches the target screen BEFORE it renders,
281
+ // even before MorphCardTargetView.onAttachedToWindow fires.
282
+ removeHierarchyListener()
283
+ val screenStack = sourceScreen?.parent as? ViewGroup
284
+ if (screenStack != null) {
285
+ val listener = object : ViewGroup.OnHierarchyChangeListener {
286
+ override fun onChildViewAdded(parent: View?, child: View?) {
287
+ if (child != null && child !== sourceScreen) {
288
+ child.visibility = View.INVISIBLE
289
+ Log.d(TAG, "prepareExpand: intercepted new screen, set INVISIBLE")
290
+ }
291
+ }
292
+ override fun onChildViewRemoved(parent: View?, child: View?) {}
293
+ }
294
+ screenStack.setOnHierarchyChangeListener(listener)
295
+ screenStackRef = WeakReference(screenStack)
296
+ hierarchyListener = listener
297
+ }
298
+
299
+ // Capture snapshot
300
+ val cardImage = captureSnapshot()
301
+
302
+ // Create overlay at source position
303
+ val bgColor = cardBgColor
304
+ val wrapper = FrameLayout(context)
305
+ wrapper.layoutParams = FrameLayout.LayoutParams(cardWidth.toInt(), cardHeight.toInt())
306
+ wrapper.x = cardLeft
307
+ wrapper.y = cardTop
308
+ wrapper.clipChildren = true
309
+ wrapper.clipToPadding = true
310
+ setRoundedCorners(wrapper, cardCornerRadiusPx)
311
+ if (bgColor != null) {
312
+ wrapper.setBackgroundColor(bgColor)
313
+ }
314
+
315
+ val content = ImageView(context)
316
+ content.setImageBitmap(cardImage)
317
+ content.scaleType = ImageView.ScaleType.FIT_XY
318
+ content.layoutParams = FrameLayout.LayoutParams(cardWidth.toInt(), cardHeight.toInt())
319
+ wrapper.addView(content)
320
+
321
+ decorView.addView(wrapper)
322
+ overlayContainer = wrapper
323
+
324
+ // Hide source card — overlay covers it
325
+ alpha = 0f
326
+
327
+ Log.d(TAG, "=== prepareExpand DONE === overlay at [${cardLeft},${cardTop}]")
328
+ }
329
+
330
+ /**
331
+ * Check if the target view's screen container is set up and positioned.
332
+ * Returns false if the screen container can't be found yet.
333
+ */
334
+ fun isTargetScreenReady(targetView: View?): Boolean {
335
+ if (targetView == null) return true
336
+ val screenContainer = findScreenContainer(targetView) ?: return false
337
+ // Also check position isn't the same as source (stale layout)
338
+ val loc = IntArray(2)
339
+ targetView.getLocationInWindow(loc)
340
+ val targetAtSource = loc[0].toFloat() == cardLeft && loc[1].toFloat() == cardTop
341
+ if (targetAtSource && targetView.width > 0) {
342
+ Log.d(TAG, "isTargetScreenReady: target still at source position [${loc[0]},${loc[1]}], waiting...")
343
+ return false
344
+ }
345
+ return true
346
+ }
347
+
348
+ // ══════════════════════════════════════════════════════════════
349
+ // PHASE 2: animateExpand — called after delay, positions stable
350
+ // Animates overlay from source to target position.
351
+ // ══════════════════════════════════════════════════════════════
352
+
353
+ fun animateExpand(targetView: View?, promise: Promise) {
354
+ Log.d(TAG, "=== animateExpand START ===")
355
+ val wrapper = overlayContainer
356
+ if (wrapper == null) {
357
+ Log.d(TAG, "animateExpand: NO OVERLAY — falling back to expandToTarget")
358
+ expandToTarget(targetView, promise)
359
+ return
360
+ }
361
+
362
+ isExpanded = true
363
+ // Save target view reference for collapse (prepareExpand may have been called with null)
364
+ if (targetView != null) {
365
+ targetViewRef = targetView
366
+ }
367
+
368
+ val decorView = getDecorView()
369
+ if (decorView == null) {
370
+ promise.resolve(false)
371
+ return
372
+ }
373
+
374
+ // Re-hide target screen right before animation starts (belt-and-suspenders)
375
+ val preTargetScreen = targetScreenContainerRef?.get()
376
+ val preSourceScreen = sourceScreenContainerRef?.get()
377
+ // Ensure target screen stays INVISIBLE (belt-and-suspenders)
378
+ if (preTargetScreen != null && preTargetScreen !== preSourceScreen) {
379
+ preTargetScreen.visibility = View.INVISIBLE
380
+ }
381
+
382
+ // Stop intercepting new screens — animation is taking over
383
+ removeHierarchyListener()
384
+
385
+ // Read target position (now settled after delay)
386
+ val d = density
387
+ val targetLoc = if (targetView != null) getLocationInWindow(targetView) else intArrayOf(cardLeft.toInt(), cardTop.toInt())
388
+ val twPx = if (pendingTargetWidth > 0) pendingTargetWidth * d else cardWidth
389
+ val thPx = if (pendingTargetHeight > 0) pendingTargetHeight * d else cardHeight
390
+ val tbrPx = if (pendingTargetBorderRadius >= 0) pendingTargetBorderRadius * d else cardCornerRadiusPx
391
+
392
+ val targetLeft = targetLoc[0].toFloat()
393
+ val targetTop = targetLoc[1].toFloat()
394
+ val targetWidthPx = twPx
395
+ val targetHeightPx = thPx
396
+ val targetCornerRadiusPx = tbrPx
397
+
398
+ // Log target view details
399
+ if (targetView != null) {
400
+ Log.d(TAG, "animateExpand: targetView.id=${targetView.id} isAttached=${targetView.isAttachedToWindow} isLaidOut=${targetView.isLaidOut}")
401
+ Log.d(TAG, "animateExpand: targetView size=${targetView.width}x${targetView.height}")
402
+ }
403
+ Log.d(TAG, "animateExpand: source=[${cardLeft},${cardTop},${cardWidth}x${cardHeight}]")
404
+ Log.d(TAG, "animateExpand: target=[${targetLeft},${targetTop},${targetWidthPx}x${targetHeightPx}] cornerR=$targetCornerRadiusPx")
405
+ Log.d(TAG, "animateExpand: pendingTarget w=${pendingTargetWidth} h=${pendingTargetHeight} br=${pendingTargetBorderRadius}")
406
+
407
+ val dur = duration.toLong()
408
+ val content = if (wrapper.childCount > 0) wrapper.getChildAt(0) else null
409
+
410
+ // Compute content offset for wrapper mode
411
+ val contentCx = if (hasWrapper && pendingContentCentered) (targetWidthPx - cardWidth) / 2f else 0f
412
+ val contentCy = if (hasWrapper && pendingContentCentered) (targetHeightPx - cardHeight) / 2f
413
+ else if (hasWrapper) pendingContentOffsetY * d else 0f
414
+
415
+ // For no-wrapper mode, compute image frame
416
+ val targetImageFrame = if (!hasWrapper && content != null) {
417
+ imageFrameForScaleMode(scaleMode, cardWidth, cardHeight, targetWidthPx, targetHeightPx)
418
+ } else null
419
+
420
+ val animator = ValueAnimator.ofFloat(0f, 1f)
421
+ animator.duration = dur
422
+ animator.interpolator = springInterpolator
423
+
424
+ animator.addUpdateListener { anim ->
425
+ val t = anim.animatedValue as Float
426
+ wrapper.x = lerp(cardLeft, targetLeft, t)
427
+ wrapper.y = lerp(cardTop, targetTop, t)
428
+ val lp = wrapper.layoutParams
429
+ lp.width = lerp(cardWidth, targetWidthPx, t).toInt()
430
+ lp.height = lerp(cardHeight, targetHeightPx, t).toInt()
431
+ wrapper.layoutParams = lp
432
+ setRoundedCorners(wrapper, lerp(cardCornerRadiusPx, targetCornerRadiusPx, t))
433
+
434
+ if (content != null) {
435
+ if (hasWrapper) {
436
+ content.x = lerp(0f, contentCx, t)
437
+ content.y = lerp(0f, contentCy, t)
438
+ } else if (targetImageFrame != null) {
439
+ val slp = content.layoutParams as FrameLayout.LayoutParams
440
+ slp.width = lerp(cardWidth, targetImageFrame.width(), t).toInt()
441
+ slp.height = lerp(cardHeight, targetImageFrame.height(), t).toInt()
442
+ content.layoutParams = slp
443
+ content.x = lerp(0f, targetImageFrame.left, t)
444
+ content.y = lerp(0f, targetImageFrame.top, t)
445
+ }
446
+ }
447
+ }
448
+
449
+ // Crossfade: at 15% of animation, make target screen VISIBLE with alpha=0
450
+ // then fade alpha to 1 over 50% of duration
451
+ val targetScreen = targetScreenContainerRef?.get()
452
+ val sourceScreen = sourceScreenContainerRef?.get()
453
+ if (targetScreen != null && targetScreen !== sourceScreen) {
454
+ mainHandler.postDelayed({
455
+ // Switch from INVISIBLE to VISIBLE but with alpha=0
456
+ targetScreen.alpha = 0f
457
+ targetScreen.visibility = View.VISIBLE
458
+ val fadeAnimator = ValueAnimator.ofFloat(0f, 1f)
459
+ fadeAnimator.duration = (dur * 0.5f).toLong()
460
+ fadeAnimator.addUpdateListener { a ->
461
+ targetScreen.alpha = a.animatedValue as Float
462
+ }
463
+ fadeAnimator.start()
464
+ }, (dur * 0.15f).toLong())
465
+ }
466
+
467
+ animator.addListener(object : android.animation.AnimatorListenerAdapter() {
468
+ override fun onAnimationEnd(animation: android.animation.Animator) {
469
+ Log.d(TAG, "=== animateExpand COMPLETE ===")
470
+ targetScreenContainerRef?.get()?.let {
471
+ it.visibility = View.VISIBLE
472
+ it.alpha = 1f
473
+ }
474
+ this@MorphCardSourceView.alpha = 1f
475
+
476
+ transferSnapshotToTarget(decorView, wrapper, targetView,
477
+ targetWidthPx, targetHeightPx, targetCornerRadiusPx, 200L)
478
+
479
+ promise.resolve(true)
480
+ }
481
+ })
482
+
483
+ animator.start()
484
+ }
485
+
486
+ /**
487
+ * Transfer the snapshot INTO the MorphCardTargetView via showSnapshot(),
488
+ * then make the target screen VISIBLE and fade out the DecorView overlay.
489
+ * This allows absolutely positioned elements (X button, etc.) to render
490
+ * on top of the snapshot, just like iOS.
491
+ */
492
+ private fun transferSnapshotToTarget(
493
+ decorView: ViewGroup,
494
+ overlay: FrameLayout,
495
+ targetView: View?,
496
+ targetWidthPx: Float,
497
+ targetHeightPx: Float,
498
+ cornerRadius: Float,
499
+ fadeDuration: Long = 100
500
+ ) {
501
+ val target = targetView as? MorphCardTargetView
502
+ if (target == null) {
503
+ Log.d(TAG, "transferSnapshot: targetView is not MorphCardTargetView, removing overlay")
504
+ decorView.removeView(overlay)
505
+ overlayContainer = null
506
+ return
507
+ }
508
+
509
+ // Get the bitmap from the overlay
510
+ val origImg = if (overlay.childCount > 0) overlay.getChildAt(0) as? ImageView else null
511
+ val bitmap = if (origImg != null) {
512
+ // Extract the bitmap from the drawable
513
+ val drawable = origImg.drawable
514
+ if (drawable is android.graphics.drawable.BitmapDrawable) {
515
+ drawable.bitmap
516
+ } else {
517
+ // Fallback: render the overlay content to a bitmap
518
+ val bmp = Bitmap.createBitmap(overlay.width, overlay.height, Bitmap.Config.ARGB_8888)
519
+ val canvas = Canvas(bmp)
520
+ overlay.draw(canvas)
521
+ bmp
522
+ }
523
+ } else null
524
+
525
+ if (bitmap != null) {
526
+ // Compute the image frame within the target view
527
+ val frame = if (hasWrapper) {
528
+ val cx = if (pendingContentCentered) (targetWidthPx - cardWidth) / 2f else 0f
529
+ val cy = if (pendingContentCentered) (targetHeightPx - cardHeight) / 2f
530
+ else pendingContentOffsetY * density
531
+ RectF(cx, cy, cx + cardWidth, cy + cardHeight)
532
+ } else {
533
+ imageFrameForScaleMode(scaleMode, cardWidth, cardHeight,
534
+ target.width.toFloat(), target.height.toFloat())
535
+ }
536
+
537
+ target.showSnapshot(bitmap, ImageView.ScaleType.FIT_XY, frame, cornerRadius, cardBgColor)
538
+ Log.d(TAG, "transferSnapshot: handed snapshot to MorphCardTargetView")
539
+ }
540
+
541
+ val fadeOut = ValueAnimator.ofFloat(1f, 0f)
542
+ fadeOut.duration = fadeDuration
543
+ fadeOut.addUpdateListener { anim ->
544
+ overlay.alpha = anim.animatedValue as Float
545
+ }
546
+ fadeOut.addListener(object : android.animation.AnimatorListenerAdapter() {
547
+ override fun onAnimationEnd(animation: android.animation.Animator) {
548
+ decorView.removeView(overlay)
549
+ overlayContainer = null
550
+ Log.d(TAG, "transferSnapshot: overlay fade-out complete")
551
+ }
552
+ })
553
+ fadeOut.start()
554
+ }
555
+
556
+ // ══════════════════════════════════════════════════════════════
557
+ // Fallback: expandToTarget (direct, used if prepareExpand wasn't called)
558
+ // ══════════════════════════════════════════════════════════════
559
+
560
+ fun expandToTarget(targetView: View?, promise: Promise) {
561
+ Log.d(TAG, "expandToTarget: fallback path")
562
+ if (isExpanded) {
563
+ promise.resolve(false)
564
+ return
565
+ }
566
+ prepareExpand(targetView)
567
+ animateExpand(targetView, promise)
568
+ }
569
+
570
+ // ══════════════════════════════════════════════════════════════
571
+ // COLLAPSE
572
+ // ══════════════════════════════════════════════════════════════
573
+
574
+ fun collapseWithResolve(promise: Promise) {
575
+ collapseFromTarget(targetViewRef, promise)
576
+ }
577
+
578
+ private fun collapseFromTarget(targetView: View?, promise: Promise) {
579
+ Log.d(TAG, "=== collapseFromTarget START === isExpanded=$isExpanded hasWrapper=$hasWrapper overlayContainer=${overlayContainer != null}")
580
+ if (!isExpanded) {
581
+ promise.resolve(false)
582
+ return
583
+ }
584
+
585
+ val decorView = getDecorView()
586
+ if (decorView == null) {
587
+ promise.resolve(false)
588
+ return
589
+ }
590
+
591
+ val d = density
592
+ val dur = duration.toLong()
593
+
594
+ // Create DecorView overlay for collapse animation.
595
+ // Get snapshot from MorphCardTargetView if available, otherwise recapture.
596
+ var wrapper = overlayContainer
597
+ if (wrapper == null) {
598
+ val target = targetView as? MorphCardTargetView
599
+ val targetLoc = if (targetView != null) getLocationInWindow(targetView) else intArrayOf(cardLeft.toInt(), cardTop.toInt())
600
+ val twPx = if (pendingTargetWidth > 0) pendingTargetWidth * d else cardWidth
601
+ val thPx = if (pendingTargetHeight > 0) pendingTargetHeight * d else cardHeight
602
+ val tbrPx = if (pendingTargetBorderRadius >= 0) pendingTargetBorderRadius * d else cardCornerRadiusPx
603
+
604
+ // Recapture snapshot from source (the image hasn't changed)
605
+ alpha = 1f
606
+ val cardImage = captureSnapshot()
607
+ alpha = 0f
608
+
609
+ // Clear the snapshot from the target view
610
+ target?.clearSnapshot()
611
+
612
+ wrapper = FrameLayout(context)
613
+ wrapper.layoutParams = FrameLayout.LayoutParams(twPx.toInt(), thPx.toInt())
614
+ wrapper.x = targetLoc[0].toFloat()
615
+ wrapper.y = targetLoc[1].toFloat()
616
+ wrapper.clipChildren = true
617
+ wrapper.clipToPadding = true
618
+ setRoundedCorners(wrapper, tbrPx)
619
+
620
+ val bgColor = cardBgColor
621
+ if (bgColor != null) {
622
+ wrapper.setBackgroundColor(bgColor)
623
+ }
624
+
625
+ val content = ImageView(context)
626
+ content.setImageBitmap(cardImage)
627
+ content.scaleType = ImageView.ScaleType.FIT_XY
628
+
629
+ if (hasWrapper) {
630
+ val cx = if (pendingContentCentered) (twPx - cardWidth) / 2f else 0f
631
+ val cy = if (pendingContentCentered) (thPx - cardHeight) / 2f else pendingContentOffsetY * d
632
+ content.layoutParams = FrameLayout.LayoutParams(cardWidth.toInt(), cardHeight.toInt())
633
+ content.x = cx
634
+ content.y = cy
635
+ } else {
636
+ val imageFrame = imageFrameForScaleMode(scaleMode, cardWidth, cardHeight, twPx, thPx)
637
+ content.layoutParams = FrameLayout.LayoutParams(imageFrame.width().toInt(), imageFrame.height().toInt())
638
+ content.x = imageFrame.left
639
+ content.y = imageFrame.top
640
+ }
641
+
642
+ wrapper.addView(content)
643
+ decorView.addView(wrapper)
644
+ overlayContainer = wrapper
645
+ }
646
+
647
+ // Ensure wrapper is valid
648
+ if (wrapper == null) {
649
+ isExpanded = false
650
+ promise.resolve(false)
651
+ return
652
+ }
653
+
654
+ // Show source screen underneath
655
+ val sourceScreen = sourceScreenContainerRef?.get()
656
+ val targetScreen = targetScreenContainerRef?.get()
657
+ sourceScreen?.alpha = 1f
658
+
659
+ val content = if (wrapper.childCount > 0) wrapper.getChildAt(0) else null
660
+
661
+ val startLeft = wrapper.x
662
+ val startTop = wrapper.y
663
+ val startWidth = wrapper.layoutParams.width.toFloat()
664
+ val startHeight = wrapper.layoutParams.height.toFloat()
665
+ val startCx = content?.x ?: 0f
666
+ val startCy = content?.y ?: 0f
667
+ val startCr = if (pendingTargetBorderRadius >= 0) pendingTargetBorderRadius * d else cardCornerRadiusPx
668
+
669
+ val startImgW = content?.layoutParams?.width?.toFloat() ?: cardWidth
670
+ val startImgH = content?.layoutParams?.height?.toFloat() ?: cardHeight
671
+
672
+ val animator = ValueAnimator.ofFloat(0f, 1f)
673
+ animator.duration = dur
674
+ animator.interpolator = springInterpolator
675
+
676
+ animator.addUpdateListener { anim ->
677
+ val t = anim.animatedValue as Float
678
+ wrapper.x = lerp(startLeft, cardLeft, t)
679
+ wrapper.y = lerp(startTop, cardTop, t)
680
+ val lp = wrapper.layoutParams
681
+ lp.width = lerp(startWidth, cardWidth, t).toInt()
682
+ lp.height = lerp(startHeight, cardHeight, t).toInt()
683
+ wrapper.layoutParams = lp
684
+ setRoundedCorners(wrapper, lerp(startCr, cardCornerRadiusPx, t))
685
+
686
+ if (content != null) {
687
+ if (hasWrapper) {
688
+ content.x = lerp(startCx, 0f, t)
689
+ content.y = lerp(startCy, 0f, t)
690
+ } else {
691
+ content.x = lerp(startCx, 0f, t)
692
+ content.y = lerp(startCy, 0f, t)
693
+ val slp = content.layoutParams
694
+ slp.width = lerp(startImgW, cardWidth, t).toInt()
695
+ slp.height = lerp(startImgH, cardHeight, t).toInt()
696
+ content.layoutParams = slp
697
+ }
698
+ }
699
+ }
700
+
701
+ // Crossfade: fade out target screen starting at 10%, over 65% of duration
702
+ if (targetScreen != null && targetScreen !== sourceScreen) {
703
+ mainHandler.postDelayed({
704
+ val fadeAnimator = ValueAnimator.ofFloat(1f, 0f)
705
+ fadeAnimator.duration = (dur * 0.65f).toLong()
706
+ fadeAnimator.addUpdateListener { a ->
707
+ targetScreen.alpha = a.animatedValue as Float
708
+ }
709
+ fadeAnimator.addListener(object : android.animation.AnimatorListenerAdapter() {
710
+ override fun onAnimationEnd(animation: android.animation.Animator) {
711
+ targetScreen.visibility = View.INVISIBLE
712
+ }
713
+ })
714
+ fadeAnimator.start()
715
+ }, (dur * 0.15f).toLong())
716
+ }
717
+
718
+ animator.addListener(object : android.animation.AnimatorListenerAdapter() {
719
+ override fun onAnimationEnd(animation: android.animation.Animator) {
720
+ decorView.removeView(wrapper)
721
+ overlayContainer = null
722
+ removeHierarchyListener()
723
+ this@MorphCardSourceView.alpha = 1f
724
+ isExpanded = false
725
+ sourceScreenContainerRef = null
726
+ targetScreenContainerRef = null
727
+ promise.resolve(true)
728
+ }
729
+ })
730
+
731
+ animator.start()
732
+ }
733
+
734
+ /**
735
+ * Hide the target screen container. Called by MorphCardModule after the
736
+ * retry loop finds the target view, in case prepareExpand ran before
737
+ * the target was registered.
738
+ */
739
+ fun hideTargetScreen(targetView: View?) {
740
+ val targetScreen = findScreenContainer(targetView)
741
+ val sourceScreen = sourceScreenContainerRef?.get()
742
+ targetScreenContainerRef = if (targetScreen != null) WeakReference(targetScreen) else null
743
+ if (targetScreen != null && targetScreen !== sourceScreen) {
744
+ targetScreen.visibility = View.INVISIBLE
745
+ Log.d(TAG, "hideTargetScreen: set target screen INVISIBLE")
746
+ }
747
+ }
748
+
749
+ companion object {
750
+ private const val TAG = "MorphCard"
751
+ private fun lerp(start: Float, end: Float, fraction: Float): Float {
752
+ return start + (end - start) * fraction
753
+ }
754
+ }
755
+ }