Skip to content

Commit c89f5eb

Browse files
feat: add transformPerspective prop and card flip demo (#34)
* feat: add transformPerspective prop and card flip demo - Add `transformPerspective` prop for configurable 3D perspective on all platforms - iOS: use individual key-path animations for transform sub-properties, apply perspective to parent via didMoveToSuperview - Android: fix cameraDistance formula to match RN (density² × perspective × √5), add backface visibility updates during rotation animations - Web: add perspective() to CSS transform when 3D rotations are active, always include rotateX/rotateY in transform for correct CSS transitions - Add CardFlipDemo example with 3D card flip animation - Update default perspective from 850 to 1280 (matches RN default) - Document iOS caveat about Fabric view flattening * chore: use 800 perspective in card flip demo
1 parent 2e609d8 commit c89f5eb

14 files changed

Lines changed: 363 additions & 34 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran
5454
7. Add tests and update README
5555
8. Add an example/demo in the example app (`example/src/App.tsx` or a new screen)
5656

57+
**Important:** When adding or changing props/features, also update:
58+
- `README.md` (props table + usage section)
59+
- `docs/docs/usage.mdx` (usage guide)
60+
- `docs/docs/api-reference.mdx` (API reference table)
61+
5762
## Development Commands
5863

5964
```sh

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,23 @@ By default, scale and rotation animate from the view's center. Use `transformOri
426426
| `{ x: 0.5, y: 0.5 }` | Center (default) |
427427
| `{ x: 1, y: 1 }` | Bottom-right |
428428

429+
### Transform Perspective
430+
431+
Control the 3D perspective depth for `rotateX` and `rotateY` animations. Lower values create a more dramatic 3D effect; higher values look flatter.
432+
433+
```tsx
434+
<EaseView
435+
animate={{ rotateY: flipped ? 180 : 0 }}
436+
transformPerspective={800}
437+
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
438+
style={styles.card}
439+
/>
440+
```
441+
442+
Default is `1280`, matching React Native's default perspective.
443+
444+
> **iOS note:** On iOS, the parent view must not be flattened by Fabric for perspective to render correctly. Ensure the parent has `collapsable={false}` or a style that prevents flattening (e.g. `transform`, `opacity`, `zIndex`).
445+
429446
### Style Handling
430447

431448
`EaseView` accepts all standard `ViewStyle` properties. If a property appears in both `style` and `animate`, the animated value takes priority and the style value is stripped. A dev warning is logged when this happens.
@@ -465,6 +482,7 @@ A `View` that animates property changes using native platform APIs.
465482
| `transition` | `Transition` | Animation configuration — a single config (timing, spring, or none) or a [per-property map](#per-property-transitions) |
466483
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
467484
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }` (center) |
485+
| `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). See [Transform Perspective](#transform-perspective). Default: `1280` |
468486
| `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations. See [Hardware Layers](#hardware-layers-android). Default: `false` |
469487
| `className` | `string` | NativeWind / Uniwind / Tailwind CSS class string. Requires NativeWind or Uniwind in your project. |
470488
| `style` | `ViewStyle` | Non-animated styles (layout, colors, borders, etc.) |

android/src/main/java/com/ease/EaseView.kt

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import com.facebook.react.bridge.ReadableMap
1717
import com.facebook.react.views.view.ReactViewGroup
1818
import kotlin.math.sqrt
1919

20+
// Matches React Native's camera distance normalization.
21+
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L58
22+
private val CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER = sqrt(5.0).toFloat()
23+
2024
class EaseView(context: Context) : ReactViewGroup(context) {
2125

2226
// --- Previous animate values (for change detection) ---
@@ -194,9 +198,22 @@ class EaseView(context: Context) : ReactViewGroup(context) {
194198
// --- Animated properties bitmask (set by ViewManager) ---
195199
var animatedProperties: Int = 0
196200

201+
// --- Transform perspective (camera distance for 3D rotations) ---
202+
var transformPerspective: Float = 1280f
203+
set(value) {
204+
field = value
205+
applyCameraDistance(value)
206+
}
207+
208+
private fun applyCameraDistance(perspective: Float) {
209+
// Match React Native's conversion from CSS perspective to Android cameraDistance.
210+
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L626-L637
211+
val density = resources.displayMetrics.density
212+
cameraDistance = density * density * perspective * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER
213+
}
214+
197215
init {
198-
// Set camera distance for 3D perspective rotations (rotateX/rotateY)
199-
cameraDistance = resources.displayMetrics.density * 850f
216+
applyCameraDistance(1280f)
200217

201218
// ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame.
202219
outlineProvider = object : ViewOutlineProvider() {
@@ -345,6 +362,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
345362
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
346363
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
347364
}
365+
366+
// Update backface visibility after setting initial rotation values.
367+
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt#L967-L985
368+
if (mask and (MASK_ROTATE or MASK_ROTATE_X or MASK_ROTATE_Y) != 0) {
369+
setBackfaceVisibilityDependantOpacity()
370+
}
348371
} else if (allTransitionsNone()) {
349372
// No transition (scalar) — set values immediately, cancel running animations
350373
cancelAllAnimations()
@@ -613,12 +636,19 @@ class EaseView(context: Context) : ReactViewGroup(context) {
613636
}
614637
}
615638

639+
// React Native's backfaceVisibility on Android checks rotationX/rotationY and sets alpha=0
640+
// when the back face is showing. We must call setBackfaceVisibilityDependantOpacity() during
641+
// rotation animations so the check runs each frame.
642+
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt#L967-L985
643+
private val isRotationProperty = setOf("rotation", "rotationX", "rotationY")
644+
616645
private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, config: TransitionConfig, loop: Boolean = false) {
617646
cancelSpringForProperty(propertyName)
618647
runningAnimators[propertyName]?.cancel()
619648

620649
val batchId = animationBatchId
621650
pendingBatchAnimationCount++
651+
val needsBackfaceUpdate = propertyName in isRotationProperty
622652

623653
val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
624654
duration = config.duration.toLong()
@@ -636,6 +666,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
636666
ObjectAnimator.RESTART
637667
}
638668
}
669+
if (needsBackfaceUpdate) {
670+
addUpdateListener { this@EaseView.setBackfaceVisibilityDependantOpacity() }
671+
}
639672
addListener(object : AnimatorListenerAdapter() {
640673
private var cancelled = false
641674
override fun onAnimationStart(animation: Animator) {
@@ -675,6 +708,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
675708
val batchId = animationBatchId
676709
pendingBatchAnimationCount++
677710

711+
val needsBackfaceUpdate = viewProperty == DynamicAnimation.ROTATION ||
712+
viewProperty == DynamicAnimation.ROTATION_X ||
713+
viewProperty == DynamicAnimation.ROTATION_Y
714+
678715
val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
679716
.coerceAtLeast(0.01f)
680717

@@ -688,6 +725,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
688725
if (activeAnimationCount == 0) {
689726
this@EaseView.onEaseAnimationStart()
690727
}
728+
if (needsBackfaceUpdate) {
729+
this@EaseView.setBackfaceVisibilityDependantOpacity()
730+
}
691731
}
692732
addEndListener { _, canceled, _, _ ->
693733
this@EaseView.onEaseAnimationEnd()
@@ -809,6 +849,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
809849
setAnimateBorderRadius(0f)
810850
applyBackgroundColor(Color.TRANSPARENT)
811851

852+
transformPerspective = 1280f
812853
isFirstMount = true
813854
transitionConfigs = emptyMap()
814855
}

android/src/main/java/com/ease/EaseViewManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@ class EaseViewManager : ReactViewManager() {
172172
view.transformOriginY = value
173173
}
174174

175+
// --- Transform perspective ---
176+
177+
@ReactProp(name = "transformPerspective", defaultFloat = 1280f)
178+
fun setTransformPerspective(view: EaseView, value: Float) {
179+
view.transformPerspective = value
180+
}
181+
175182
// --- Lifecycle ---
176183

177184
override fun onAfterUpdateTransaction(view: ReactViewGroup) {

docs/docs/api-reference.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A `View` that animates property changes using native platform APIs.
1414
| `transition` | `Transition` | Single config or per-property map |
1515
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
1616
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions |
17+
| `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). Default: `1280` |
1718
| `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations |
1819
| `className` | `string` | NativeWind / Uniwind / Tailwind CSS class string |
1920
| `style` | `ViewStyle` | Non-animated styles |

docs/docs/usage.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,25 @@ Animations are interruptible by default. Changing `animate` values mid-animation
205205
| `{ x: 0.5, y: 0.5 }` | Center (default) |
206206
| `{ x: 1, y: 1 }` | Bottom-right |
207207

208+
## Transform perspective
209+
210+
Control the 3D perspective depth for `rotateX` and `rotateY` animations. Lower values create a more dramatic 3D effect; higher values look flatter.
211+
212+
```tsx
213+
<EaseView
214+
animate={{ rotateY: flipped ? 180 : 0 }}
215+
transformPerspective={800}
216+
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
217+
style={styles.card}
218+
/>
219+
```
220+
221+
Default is `1280`, matching React Native's default perspective.
222+
223+
:::note[iOS]
224+
On iOS, the parent view must not be flattened by Fabric for perspective to render correctly. Ensure the parent has `collapsable={false}` or a style that prevents flattening (e.g. `transform`, `opacity`, `zIndex`).
225+
:::
226+
208227
## Style handling
209228

210229
If a property appears in both `style` and `animate`, the animated value takes priority and the style value is stripped.

example/src/demos/CardFlipDemo.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useState } from 'react';
2+
import { View, Text, StyleSheet } from 'react-native';
3+
import { EaseView } from 'react-native-ease';
4+
5+
import { Section } from '../components/Section';
6+
import { Button } from '../components/Button';
7+
8+
export function CardFlipDemo() {
9+
const [flipped, setFlipped] = useState(false);
10+
return (
11+
<Section title="Card Flip">
12+
<View style={styles.container} collapsable={false}>
13+
{/* Front face */}
14+
<EaseView
15+
animate={{ rotateY: flipped ? 180 : 0 }}
16+
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
17+
transformPerspective={800}
18+
style={[styles.card, styles.front]}
19+
>
20+
<Text style={styles.emoji}>{' '}</Text>
21+
<Text style={styles.title}>Front</Text>
22+
<Text style={styles.subtitle}>Tap to flip</Text>
23+
</EaseView>
24+
{/* Back face — starts at -180 so backface is hidden, flips to 0 */}
25+
<EaseView
26+
animate={{ rotateY: flipped ? 0 : -180 }}
27+
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
28+
transformPerspective={800}
29+
style={[styles.card, styles.back]}
30+
>
31+
<Text style={styles.emoji}>{' '}</Text>
32+
<Text style={styles.title}>Back</Text>
33+
<Text style={styles.subtitle}>3D perspective flip</Text>
34+
</EaseView>
35+
</View>
36+
<Button label="Flip" onPress={() => setFlipped((v) => !v)} />
37+
</Section>
38+
);
39+
}
40+
41+
const styles = StyleSheet.create({
42+
container: {
43+
width: 200,
44+
height: 260,
45+
},
46+
card: {
47+
position: 'absolute',
48+
width: '100%',
49+
height: '100%',
50+
borderRadius: 16,
51+
alignItems: 'center',
52+
justifyContent: 'center',
53+
backfaceVisibility: 'hidden',
54+
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
55+
},
56+
front: {
57+
backgroundColor: '#4a90d9',
58+
},
59+
back: {
60+
backgroundColor: '#d94a90',
61+
},
62+
emoji: {
63+
fontSize: 48,
64+
marginBottom: 12,
65+
},
66+
title: {
67+
color: '#fff',
68+
fontSize: 22,
69+
fontWeight: '700',
70+
},
71+
subtitle: {
72+
color: 'rgba(255, 255, 255, 0.7)',
73+
fontSize: 13,
74+
marginTop: 4,
75+
},
76+
});

example/src/demos/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Platform } from 'react-native';
44
import { BackgroundColorDemo } from './BackgroundColorDemo';
55
import { BenchmarkDemo } from './BenchmarkDemo';
66
import { BannerDemo } from './BannerDemo';
7+
import { CardFlipDemo } from './CardFlipDemo';
78
import { BorderRadiusDemo } from './BorderRadiusDemo';
89
import { ButtonDemo } from './ButtonDemo';
910
import { CombinedDemo } from './CombinedDemo';
@@ -39,6 +40,11 @@ export const demos: Record<string, DemoEntry> = {
3940
'exit': { component: ExitDemo, title: 'Exit', section: 'Basic' },
4041
'rotate': { component: RotateDemo, title: 'Rotate', section: 'Transform' },
4142
'scale': { component: ScaleDemo, title: 'Scale', section: 'Transform' },
43+
'card-flip': {
44+
component: CardFlipDemo,
45+
title: 'Card Flip',
46+
section: 'Transform',
47+
},
4248
'transform-origin': {
4349
component: TransformOriginDemo,
4450
title: 'Transform Origin',

0 commit comments

Comments
 (0)