diff --git a/AGENTS.md b/AGENTS.md index 6fda925..ddd5a7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 8613109..856df58 100644 --- a/README.md +++ b/README.md @@ -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 + +``` + +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. @@ -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.) | diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index 5d3c7c5..7544fb3 100644 --- a/android/src/main/java/com/ease/EaseView.kt +++ b/android/src/main/java/com/ease/EaseView.kt @@ -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) --- @@ -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() { @@ -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() @@ -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() @@ -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) { @@ -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) @@ -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() @@ -809,6 +849,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { setAnimateBorderRadius(0f) applyBackgroundColor(Color.TRANSPARENT) + transformPerspective = 1280f isFirstMount = true transitionConfigs = emptyMap() } diff --git a/android/src/main/java/com/ease/EaseViewManager.kt b/android/src/main/java/com/ease/EaseViewManager.kt index 4282310..774f558 100644 --- a/android/src/main/java/com/ease/EaseViewManager.kt +++ b/android/src/main/java/com/ease/EaseViewManager.kt @@ -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) { diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx index 1f8501f..8b9e5a4 100644 --- a/docs/docs/api-reference.mdx +++ b/docs/docs/api-reference.mdx @@ -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 | diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index 3628823..2b1ceda 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -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 + +``` + +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. diff --git a/example/src/demos/CardFlipDemo.tsx b/example/src/demos/CardFlipDemo.tsx new file mode 100644 index 0000000..69ea75a --- /dev/null +++ b/example/src/demos/CardFlipDemo.tsx @@ -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 ( +
+ + {/* Front face */} + + {' '} + Front + Tap to flip + + {/* Back face — starts at -180 so backface is hidden, flips to 0 */} + + {' '} + Back + 3D perspective flip + + +
+ ); +} + +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, + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index 4d518a2..208009d 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -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'; @@ -39,6 +40,11 @@ export const demos: Record = { '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', diff --git a/ios/EaseView.mm b/ios/EaseView.mm index fb93b39..7cb72c7 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -35,13 +35,14 @@ static inline CGFloat degreesToRadians(CGFloat degrees) { // Compose a full CATransform3D from individual animate values. // Order: Scale → RotateY → RotateX → RotateZ → Translate. -// Perspective (m34) is always included — invisible when no 3D rotation. +// Default perspective (1280) matches React Native's default. +// https://github.com/facebook/react-native/blob/a98aa814/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java#L624 static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY, CGFloat translateX, CGFloat translateY, CGFloat rotateZ, CGFloat rotateX, - CGFloat rotateY) { + CGFloat rotateY, CGFloat perspective) { CATransform3D t = CATransform3DIdentity; - t.m34 = -1.0 / 850.0; + t.m34 = -1.0 / perspective; t = CATransform3DTranslate(t, translateX, translateY, 0); t = CATransform3DRotate(t, rotateZ, 0, 0, 1); t = CATransform3DRotate(t, rotateX, 1, 0, 0); @@ -159,6 +160,7 @@ @implementation EaseView { BOOL _anyInterrupted; CGFloat _transformOriginX; CGFloat _transformOriginY; + CGFloat _transformPerspective; } + (ComponentDescriptorProvider)componentDescriptorProvider { @@ -170,6 +172,7 @@ - (instancetype)initWithFrame:(CGRect)frame { static const auto defaultProps = std::make_shared(); _props = defaultProps; _isFirstMount = YES; + _transformPerspective = 1280.0; _hasPendingFirstMountUpdate = NO; _transformOriginX = 0.5; _transformOriginY = 0.5; @@ -294,17 +297,18 @@ - (CATransform3D)targetTransformFromProps:(const EaseViewProps &)p { return composeTransform( p.animateScaleX, p.animateScaleY, p.animateTranslateX, p.animateTranslateY, degreesToRadians(p.animateRotate), - degreesToRadians(p.animateRotateX), degreesToRadians(p.animateRotateY)); + degreesToRadians(p.animateRotateX), degreesToRadians(p.animateRotateY), + _transformPerspective); } /// Compose a CATransform3D from EaseViewProps initial values. - (CATransform3D)initialTransformFromProps:(const EaseViewProps &)p { - return composeTransform(p.initialAnimateScaleX, p.initialAnimateScaleY, - p.initialAnimateTranslateX, - p.initialAnimateTranslateY, - degreesToRadians(p.initialAnimateRotate), - degreesToRadians(p.initialAnimateRotateX), - degreesToRadians(p.initialAnimateRotateY)); + return composeTransform( + p.initialAnimateScaleX, p.initialAnimateScaleY, + p.initialAnimateTranslateX, p.initialAnimateTranslateY, + degreesToRadians(p.initialAnimateRotate), + degreesToRadians(p.initialAnimateRotateX), + degreesToRadians(p.initialAnimateRotateY), _transformPerspective); } - (void)beginAnimationBatch { @@ -372,7 +376,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { if (mask & kMaskOpacity) self.layer.opacity = viewProps.initialAnimateOpacity; if (hasTransform) - self.layer.transform = initialT; + self.layer.transform = [self initialTransformFromProps:viewProps]; if (mask & kMaskBorderRadius) { self.layer.cornerRadius = viewProps.initialAnimateBorderRadius; self.layer.masksToBounds = viewProps.initialAnimateBorderRadius > 0 || @@ -402,7 +406,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { lowestTransformPropertyName(changedInitTransform); EaseTransitionConfig transformConfig = transitionConfigForProperty(transformName, viewProps); - self.layer.transform = targetT; + self.layer.transform = [self targetTransformFromProps:viewProps]; if (transformConfig.type != "none") { // Animate each changed sub-property individually using key paths. // This avoids matrix interpolation which fails for cases like @@ -518,7 +522,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { if (mask & kMaskOpacity) self.layer.opacity = viewProps.animateOpacity; if (hasTransform) - self.layer.transform = targetT; + self.layer.transform = [self targetTransformFromProps:viewProps]; if (mask & kMaskBorderRadius) { self.layer.cornerRadius = viewProps.animateBorderRadius; self.layer.masksToBounds = viewProps.animateBorderRadius > 0; @@ -569,6 +573,10 @@ - (void)updateProps:(const Props::Shared &)props [self updateAnchorPoint]; } + if (_transformPerspective != newViewProps.transformPerspective) { + _transformPerspective = newViewProps.transformPerspective; + } + // Bitmask: which properties are animated. Non-animated = let style handle. int mask = newViewProps.animatedProperties; BOOL hasTransform = (mask & kMaskAnyTransform) != 0; @@ -673,15 +681,99 @@ - (void)updateProps:(const Props::Shared &)props self.layer.transform = [self targetTransformFromProps:newViewProps]; [self.layer removeAnimationForKey:kAnimKeyTransform]; } else { - CATransform3D fromT = [self presentationTransform]; - CATransform3D toT = [self targetTransformFromProps:newViewProps]; - self.layer.transform = toT; - [self applyAnimationForKeyPath:@"transform" - animationKey:kAnimKeyTransform - fromValue:[NSValue valueWithCATransform3D:fromT] - toValue:[NSValue valueWithCATransform3D:toT] - config:transformConfig - loop:NO]; + // Read "from" values from the presentation layer BEFORE setting + // the new model transform. During an active animation, CA tracks + // key-path values correctly. After completion, the model matrix + // with m34 can't be reliably decomposed, so fall back to old props. + BOOL isAnimating = + [self.layer animationForKey:kAnimKeyTransformRotateY] != nil || + [self.layer animationForKey:kAnimKeyTransformRotateX] != nil || + [self.layer animationForKey:kAnimKeyTransformRotateZ] != nil || + [self.layer animationForKey:kAnimKeyTransformTransX] != nil || + [self.layer animationForKey:kAnimKeyTransformTransY] != nil || + [self.layer animationForKey:kAnimKeyTransformScaleX] != nil || + [self.layer animationForKey:kAnimKeyTransformScaleY] != nil; + CGFloat fromTX, fromTY, fromSX, fromSY, fromR, fromRX, fromRY; + if (isAnimating) { + CALayer *pl = self.layer.presentationLayer ?: self.layer; + fromTX = + [[pl valueForKeyPath:@"transform.translation.x"] floatValue]; + fromTY = + [[pl valueForKeyPath:@"transform.translation.y"] floatValue]; + fromSX = [[pl valueForKeyPath:@"transform.scale.x"] floatValue]; + fromSY = [[pl valueForKeyPath:@"transform.scale.y"] floatValue]; + fromR = [[pl valueForKeyPath:@"transform.rotation"] floatValue]; + fromRX = [[pl valueForKeyPath:@"transform.rotation.x"] floatValue]; + fromRY = [[pl valueForKeyPath:@"transform.rotation.y"] floatValue]; + } else { + fromTX = oldViewProps.animateTranslateX; + fromTY = oldViewProps.animateTranslateY; + fromSX = oldViewProps.animateScaleX; + fromSY = oldViewProps.animateScaleY; + fromR = degreesToRadians(oldViewProps.animateRotate); + fromRX = degreesToRadians(oldViewProps.animateRotateX); + fromRY = degreesToRadians(oldViewProps.animateRotateY); + } + self.layer.transform = [self targetTransformFromProps:newViewProps]; + if (changedTransformMask & kMaskTranslateX) { + [self applyAnimationForKeyPath:@"transform.translation.x" + animationKey:kAnimKeyTransformTransX + fromValue:@(fromTX) + toValue:@(newViewProps.animateTranslateX) + config:transformConfig + loop:NO]; + } + if (changedTransformMask & kMaskTranslateY) { + [self applyAnimationForKeyPath:@"transform.translation.y" + animationKey:kAnimKeyTransformTransY + fromValue:@(fromTY) + toValue:@(newViewProps.animateTranslateY) + config:transformConfig + loop:NO]; + } + if (changedTransformMask & kMaskScaleX) { + [self applyAnimationForKeyPath:@"transform.scale.x" + animationKey:kAnimKeyTransformScaleX + fromValue:@(fromSX) + toValue:@(newViewProps.animateScaleX) + config:transformConfig + loop:NO]; + } + if (changedTransformMask & kMaskScaleY) { + [self applyAnimationForKeyPath:@"transform.scale.y" + animationKey:kAnimKeyTransformScaleY + fromValue:@(fromSY) + toValue:@(newViewProps.animateScaleY) + config:transformConfig + loop:NO]; + } + if (changedTransformMask & kMaskRotate) { + [self applyAnimationForKeyPath:@"transform.rotation.z" + animationKey:kAnimKeyTransformRotateZ + fromValue:@(fromR) + toValue:@(degreesToRadians( + newViewProps.animateRotate)) + config:transformConfig + loop:NO]; + } + if (changedTransformMask & kMaskRotateX) { + [self applyAnimationForKeyPath:@"transform.rotation.x" + animationKey:kAnimKeyTransformRotateX + fromValue:@(fromRX) + toValue:@(degreesToRadians( + newViewProps.animateRotateX)) + config:transformConfig + loop:NO]; + } + if (changedTransformMask & kMaskRotateY) { + [self applyAnimationForKeyPath:@"transform.rotation.y" + animationKey:kAnimKeyTransformRotateY + fromValue:@(fromRY) + toValue:@(degreesToRadians( + newViewProps.animateRotateY)) + config:transformConfig + loop:NO]; + } } } } @@ -818,6 +910,7 @@ - (void)prepareForRecycle { _anyInterrupted = NO; _transformOriginX = 0.5; _transformOriginY = 0.5; + _transformPerspective = 1280.0; self.layer.anchorPoint = CGPointMake(0.5, 0.5); self.layer.opacity = 1.0; self.layer.transform = CATransform3DIdentity; diff --git a/src/EaseView.tsx b/src/EaseView.tsx index ab63651..3525f19 100644 --- a/src/EaseView.tsx +++ b/src/EaseView.tsx @@ -7,6 +7,7 @@ import type { Transition, TransitionEndEvent, TransformOrigin, + TransformPerspective, } from './types'; /** Identity values used as defaults for animate/initialAnimate. */ @@ -196,6 +197,18 @@ export type EaseViewProps = ViewProps & { useHardwareLayer?: boolean; /** Pivot point for scale and rotation as 0–1 fractions. @default { x: 0.5, y: 0.5 } (center) */ transformOrigin?: TransformOrigin; + /** + * Distance of the camera from the z=0 plane for 3D transforms (rotateX, + * rotateY). Higher values produce a flatter look; lower values exaggerate + * 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`). + * + * @default 1280 + */ + transformPerspective?: TransformPerspective; /** NativeWind / Uniwind / Tailwind CSS class string. Requires a compatible className interop in your project. */ className?: string; }; @@ -207,6 +220,7 @@ export function EaseView({ onTransitionEnd, useHardwareLayer = false, transformOrigin, + transformPerspective, style, ...rest }: EaseViewProps) { @@ -328,6 +342,7 @@ export function EaseView({ useHardwareLayer={useHardwareLayer} transformOriginX={transformOrigin?.x ?? 0.5} transformOriginY={transformOrigin?.y ?? 0.5} + transformPerspective={transformPerspective ?? 1280} {...rest} /> ); diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index 1592df9..eaaf54e 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -7,6 +7,7 @@ import type { Transition, TransitionEndEvent, TransformOrigin, + TransformPerspective, } from './types'; /** Identity values used as defaults for animate/initialAnimate. */ @@ -112,6 +113,12 @@ export type EaseViewProps = { /** No-op on web. */ useHardwareLayer?: boolean; transformOrigin?: TransformOrigin; + /** + * Distance of the camera from the z=0 plane for 3D transforms (rotateX, rotateY). + * Higher values produce a flatter, more telephoto look; lower values exaggerate + * perspective. @default 1280 + */ + transformPerspective?: TransformPerspective; style?: StyleProp; children?: React.ReactNode; }; @@ -132,8 +139,14 @@ function resolveAnimateValues(props: AnimateProps | undefined): Required< }; } -function buildTransform(vals: ReturnType): string { +function buildTransform( + vals: ReturnType, + perspective: number | false, +): string { const parts: string[] = []; + if (perspective !== false) { + parts.push(`perspective(${perspective}px)`); + } if (vals.translateX !== 0 || vals.translateY !== 0) { parts.push(`translate(${vals.translateX}px, ${vals.translateY}px)`); } @@ -269,10 +282,18 @@ export function EaseView({ onTransitionEnd, useHardwareLayer: _useHardwareLayer, transformOrigin, + transformPerspective = 1280, style, children, }: EaseViewProps) { const resolved = resolveAnimateValues(animate); + + const uses3D = + animate?.rotateX != null || + animate?.rotateY != null || + initialAnimate?.rotateX != null || + initialAnimate?.rotateY != null; + const hasInitial = initialAnimate != null; const [mounted, setMounted] = useState(!hasInitial); // On web, View ref gives us the underlying DOM element. @@ -370,8 +391,14 @@ export function EaseView({ : resolveAnimateValues(undefined); const toValues = resolveAnimateValues(animate); - const fromTransform = buildTransform(fromValues); - const toTransform = buildTransform(toValues); + const fromTransform = buildTransform( + fromValues, + uses3D && transformPerspective, + ); + const toTransform = buildTransform( + toValues, + uses3D && transformPerspective, + ); const name = `ease-loop-${++keyframeCounter}`; animationNameRef.current = name; @@ -421,13 +448,23 @@ export function EaseView({ el.style.animation = ''; animationNameRef.current = null; }; - }, [loopMode, animate, initialAnimate, loopDuration, loopEasing, getElement]); + }, [ + loopMode, + animate, + initialAnimate, + loopDuration, + loopEasing, + getElement, + uses3D, + transformPerspective, + ]); // Build animated style using RN transform array format. // react-native-web converts these to CSS transform strings. const animatedStyle: ViewStyle = { opacity: displayValues.opacity, transform: [ + ...(uses3D ? [{ perspective: transformPerspective }] : []), ...(displayValues.translateX !== 0 ? [{ translateX: displayValues.translateX }] : []), @@ -439,12 +476,8 @@ export function EaseView({ ...(displayValues.rotate !== 0 ? [{ rotate: `${displayValues.rotate}deg` }] : []), - ...(displayValues.rotateX !== 0 - ? [{ rotateX: `${displayValues.rotateX}deg` }] - : []), - ...(displayValues.rotateY !== 0 - ? [{ rotateY: `${displayValues.rotateY}deg` }] - : []), + ...(uses3D ? [{ rotateX: `${displayValues.rotateX}deg` }] : []), + ...(uses3D ? [{ rotateY: `${displayValues.rotateY}deg` }] : []), ], ...(displayValues.borderRadius > 0 ? { borderRadius: displayValues.borderRadius } diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts index 48aad0e..aca7703 100644 --- a/src/EaseViewNativeComponent.ts +++ b/src/EaseViewNativeComponent.ts @@ -66,6 +66,9 @@ export interface NativeProps extends ViewProps { transformOriginX?: CodegenTypes.WithDefault; transformOriginY?: CodegenTypes.WithDefault; + // 3D perspective distance (default 1280, matches RN default) + transformPerspective?: CodegenTypes.WithDefault; + // Events onTransitionEnd?: CodegenTypes.DirectEventHandler< Readonly<{ finished: boolean }> diff --git a/src/index.tsx b/src/index.tsx index 0f260e2..612c5da 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,4 +12,5 @@ export type { EasingType, TransitionEndEvent, TransformOrigin, + TransformPerspective, } from './types'; diff --git a/src/types.ts b/src/types.ts index 312f727..4b9f916 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,6 +79,17 @@ export type TransformOrigin = { y?: number; }; +/** + * Distance of the camera from the z=0 plane for 3D transforms (rotateX, rotateY). + * Higher values produce a flatter, more telephoto look; lower values exaggerate + * perspective. @default 1280 (matches React Native default) + * + * **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`). + */ +export type TransformPerspective = number; + /** Animatable view properties. Unspecified properties default to their identity values. */ export type AnimateProps = { /** View opacity (0–1). @default 1 */