Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran
7. Add tests and update README
8. Add an example/demo in the example app (`example/src/App.tsx` or a new screen)

**Important:** When adding or changing props/features, also update:
- `README.md` (props table + usage section)
- `docs/docs/usage.mdx` (usage guide)
- `docs/docs/api-reference.mdx` (API reference table)

## Development Commands

```sh
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,23 @@ By default, scale and rotation animate from the view's center. Use `transformOri
| `{ x: 0.5, y: 0.5 }` | Center (default) |
| `{ x: 1, y: 1 }` | Bottom-right |

### Transform Perspective

Control the 3D perspective depth for `rotateX` and `rotateY` animations. Lower values create a more dramatic 3D effect; higher values look flatter.

```tsx
<EaseView
animate={{ rotateY: flipped ? 180 : 0 }}
transformPerspective={800}
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
style={styles.card}
/>
```

Default is `1280`, matching React Native's default perspective.

> **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`).

### Style Handling

`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.
Expand Down Expand Up @@ -465,6 +482,7 @@ A `View` that animates property changes using native platform APIs.
| `transition` | `Transition` | Animation configuration — a single config (timing, spring, or none) or a [per-property map](#per-property-transitions) |
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }` (center) |
| `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). See [Transform Perspective](#transform-perspective). Default: `1280` |
| `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations. See [Hardware Layers](#hardware-layers-android). Default: `false` |
| `className` | `string` | NativeWind / Uniwind / Tailwind CSS class string. Requires NativeWind or Uniwind in your project. |
| `style` | `ViewStyle` | Non-animated styles (layout, colors, borders, etc.) |
Expand Down
45 changes: 43 additions & 2 deletions android/src/main/java/com/ease/EaseView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.views.view.ReactViewGroup
import kotlin.math.sqrt

// Matches React Native's camera distance normalization.
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L58
private val CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER = sqrt(5.0).toFloat()

class EaseView(context: Context) : ReactViewGroup(context) {

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

// --- Transform perspective (camera distance for 3D rotations) ---
var transformPerspective: Float = 1280f
set(value) {
field = value
applyCameraDistance(value)
}

private fun applyCameraDistance(perspective: Float) {
// Match React Native's conversion from CSS perspective to Android cameraDistance.
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L626-L637
val density = resources.displayMetrics.density
cameraDistance = density * density * perspective * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER
}

init {
// Set camera distance for 3D perspective rotations (rotateX/rotateY)
cameraDistance = resources.displayMetrics.density * 850f
applyCameraDistance(1280f)

// ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame.
outlineProvider = object : ViewOutlineProvider() {
Expand Down Expand Up @@ -345,6 +362,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
}

// Update backface visibility after setting initial rotation values.
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt#L967-L985
if (mask and (MASK_ROTATE or MASK_ROTATE_X or MASK_ROTATE_Y) != 0) {
setBackfaceVisibilityDependantOpacity()
}
} else if (allTransitionsNone()) {
// No transition (scalar) — set values immediately, cancel running animations
cancelAllAnimations()
Expand Down Expand Up @@ -613,12 +636,19 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}
}

// React Native's backfaceVisibility on Android checks rotationX/rotationY and sets alpha=0
// when the back face is showing. We must call setBackfaceVisibilityDependantOpacity() during
// rotation animations so the check runs each frame.
// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt#L967-L985
private val isRotationProperty = setOf("rotation", "rotationX", "rotationY")

private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, config: TransitionConfig, loop: Boolean = false) {
cancelSpringForProperty(propertyName)
runningAnimators[propertyName]?.cancel()

val batchId = animationBatchId
pendingBatchAnimationCount++
val needsBackfaceUpdate = propertyName in isRotationProperty

val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
duration = config.duration.toLong()
Expand All @@ -636,6 +666,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
ObjectAnimator.RESTART
}
}
if (needsBackfaceUpdate) {
addUpdateListener { this@EaseView.setBackfaceVisibilityDependantOpacity() }
}
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationStart(animation: Animator) {
Expand Down Expand Up @@ -675,6 +708,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
val batchId = animationBatchId
pendingBatchAnimationCount++

val needsBackfaceUpdate = viewProperty == DynamicAnimation.ROTATION ||
viewProperty == DynamicAnimation.ROTATION_X ||
viewProperty == DynamicAnimation.ROTATION_Y

val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
.coerceAtLeast(0.01f)

Expand All @@ -688,6 +725,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (activeAnimationCount == 0) {
this@EaseView.onEaseAnimationStart()
}
if (needsBackfaceUpdate) {
this@EaseView.setBackfaceVisibilityDependantOpacity()
}
}
addEndListener { _, canceled, _, _ ->
this@EaseView.onEaseAnimationEnd()
Expand Down Expand Up @@ -809,6 +849,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
setAnimateBorderRadius(0f)
applyBackgroundColor(Color.TRANSPARENT)

transformPerspective = 1280f
isFirstMount = true
transitionConfigs = emptyMap()
}
Expand Down
7 changes: 7 additions & 0 deletions android/src/main/java/com/ease/EaseViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ class EaseViewManager : ReactViewManager() {
view.transformOriginY = value
}

// --- Transform perspective ---

@ReactProp(name = "transformPerspective", defaultFloat = 1280f)
fun setTransformPerspective(view: EaseView, value: Float) {
view.transformPerspective = value
}

// --- Lifecycle ---

override fun onAfterUpdateTransaction(view: ReactViewGroup) {
Expand Down
1 change: 1 addition & 0 deletions docs/docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A `View` that animates property changes using native platform APIs.
| `transition` | `Transition` | Single config or per-property map |
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions |
| `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). Default: `1280` |
| `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations |
| `className` | `string` | NativeWind / Uniwind / Tailwind CSS class string |
| `style` | `ViewStyle` | Non-animated styles |
Expand Down
19 changes: 19 additions & 0 deletions docs/docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,25 @@ Animations are interruptible by default. Changing `animate` values mid-animation
| `{ x: 0.5, y: 0.5 }` | Center (default) |
| `{ x: 1, y: 1 }` | Bottom-right |

## Transform perspective

Control the 3D perspective depth for `rotateX` and `rotateY` animations. Lower values create a more dramatic 3D effect; higher values look flatter.

```tsx
<EaseView
animate={{ rotateY: flipped ? 180 : 0 }}
transformPerspective={800}
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
style={styles.card}
/>
```

Default is `1280`, matching React Native's default perspective.

:::note[iOS]
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`).
:::

## Style handling

If a property appears in both `style` and `animate`, the animated value takes priority and the style value is stripped.
Expand Down
76 changes: 76 additions & 0 deletions example/src/demos/CardFlipDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { EaseView } from 'react-native-ease';

import { Section } from '../components/Section';
import { Button } from '../components/Button';

export function CardFlipDemo() {
const [flipped, setFlipped] = useState(false);
return (
<Section title="Card Flip">
<View style={styles.container} collapsable={false}>
{/* Front face */}
<EaseView
animate={{ rotateY: flipped ? 180 : 0 }}
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
transformPerspective={800}
style={[styles.card, styles.front]}
>
<Text style={styles.emoji}>{' '}</Text>
<Text style={styles.title}>Front</Text>
<Text style={styles.subtitle}>Tap to flip</Text>
</EaseView>
{/* Back face — starts at -180 so backface is hidden, flips to 0 */}
<EaseView
animate={{ rotateY: flipped ? 0 : -180 }}
transition={{ type: 'timing', duration: 600, easing: 'easeInOut' }}
transformPerspective={800}
style={[styles.card, styles.back]}
>
<Text style={styles.emoji}>{' '}</Text>
<Text style={styles.title}>Back</Text>
<Text style={styles.subtitle}>3D perspective flip</Text>
</EaseView>
</View>
<Button label="Flip" onPress={() => setFlipped((v) => !v)} />
</Section>
);
}

const styles = StyleSheet.create({
container: {
width: 200,
height: 260,
},
card: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backfaceVisibility: 'hidden',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
},
front: {
backgroundColor: '#4a90d9',
},
back: {
backgroundColor: '#d94a90',
},
emoji: {
fontSize: 48,
marginBottom: 12,
},
title: {
color: '#fff',
fontSize: 22,
fontWeight: '700',
},
subtitle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 13,
marginTop: 4,
},
});
6 changes: 6 additions & 0 deletions example/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Platform } from 'react-native';
import { BackgroundColorDemo } from './BackgroundColorDemo';
import { BenchmarkDemo } from './BenchmarkDemo';
import { BannerDemo } from './BannerDemo';
import { CardFlipDemo } from './CardFlipDemo';
import { BorderRadiusDemo } from './BorderRadiusDemo';
import { ButtonDemo } from './ButtonDemo';
import { CombinedDemo } from './CombinedDemo';
Expand Down Expand Up @@ -39,6 +40,11 @@ export const demos: Record<string, DemoEntry> = {
'exit': { component: ExitDemo, title: 'Exit', section: 'Basic' },
'rotate': { component: RotateDemo, title: 'Rotate', section: 'Transform' },
'scale': { component: ScaleDemo, title: 'Scale', section: 'Transform' },
'card-flip': {
component: CardFlipDemo,
title: 'Card Flip',
section: 'Transform',
},
'transform-origin': {
component: TransformOriginDemo,
title: 'Transform Origin',
Expand Down
Loading
Loading