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 +39 -30
- package/android/src/main/java/com/melivalesca/morphcard/MorphCardSourceView.kt +22 -71
- package/android/src/main/java/com/melivalesca/morphcard/MorphCardTargetManager.kt +2 -2
- package/android/src/main/java/com/melivalesca/morphcard/MorphCardTargetView.kt +9 -38
- package/android/src/main/java/com/melivalesca/morphcard/MorphCardUtils.kt +43 -0
- package/ios/Fabric/RNCMorphCardSourceComponentView.h +0 -3
- package/ios/Fabric/RNCMorphCardSourceComponentView.mm +95 -79
- package/ios/Fabric/RNCMorphCardTargetComponentView.mm +5 -14
- package/ios/RNCMorphCardSource.m +1 -1
- package/package.json +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
|
-
|
|
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 {
|
|
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
|
|
102
|
-
|
|
103
|
-
| `width`
|
|
104
|
-
| `height`
|
|
105
|
-
| `borderRadius`
|
|
106
|
-
| `backgroundColor` | `string`
|
|
107
|
-
| `duration`
|
|
108
|
-
| `expandDuration`
|
|
109
|
-
| `scaleMode`
|
|
110
|
-
| `onPress`
|
|
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
|
|
117
|
-
|
|
118
|
-
| `sourceTag`
|
|
119
|
-
| `width`
|
|
120
|
-
| `height`
|
|
121
|
-
| `borderRadius`
|
|
122
|
-
| `collapseDuration` | `number`
|
|
123
|
-
| `contentOffsetY`
|
|
124
|
-
| `contentCentered`
|
|
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 {
|
|
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
|
|
239
|
-
|
|
240
|
-
| `pod install` fails
|
|
241
|
-
| Android build fails on first run
|
|
242
|
-
| Metro can't find `react-native-morph-card` | Run `yarn install` at the repo root first
|
|
243
|
-
| Duplicate module errors
|
|
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
|
-
|
|
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
|
|
391
|
-
//
|
|
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
|
-
|
|
404
|
-
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
249
|
+
UIView *sourceScreen = RNCMorphCardFindScreenContainer(self);
|
|
183
250
|
_sourceScreenContainer = sourceScreen;
|
|
184
|
-
_targetScreenContainer =
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
466
|
-
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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 *
|
|
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:
|
|
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
|
|
546
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/ios/RNCMorphCardSource.m
CHANGED
package/package.json
CHANGED