diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index 3cdaa17..e599383 100644 --- a/android/src/main/java/com/ease/EaseView.kt +++ b/android/src/main/java/com/ease/EaseView.kt @@ -15,6 +15,10 @@ import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.LogicalEdge import com.facebook.react.views.view.ReactViewGroup import kotlin.math.sqrt @@ -38,6 +42,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { private var prevBackgroundColor: Int? = null private var prevBorderWidth: Float? = null private var prevBorderColor: Int? = null + private var prevElevation: Float? = null private var currentBackgroundColor: Int = Color.TRANSPARENT private var currentBorderColor: Int = Color.BLACK @@ -64,7 +69,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { return } val configs = mutableMapOf() - val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor", "border") + val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor", "border", "shadow") for (key in keys) { if (map.hasKey(key)) { val configMap = map.getMap(key) ?: continue @@ -98,6 +103,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { "borderRadius" -> "borderRadius" "backgroundColor" -> "backgroundColor" "borderWidth", "borderColor" -> "border" + "elevation" -> "shadow" else -> null } if (categoryKey != null) { @@ -109,7 +115,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { private fun allTransitionsNone(): Boolean { val defaultConfig = transitionConfigs["defaultConfig"] if (defaultConfig == null || defaultConfig.type != "none") return false - val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor", "border") + val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor", "border", "shadow") return categories.all { key -> val config = transitionConfigs[key] config == null || config.type == "none" @@ -130,6 +136,8 @@ class EaseView(context: Context) : ReactViewGroup(context) { const val MASK_BACKGROUND_COLOR = 1 shl 9 const val MASK_BORDER_WIDTH = 1 shl 10 const val MASK_BORDER_COLOR = 1 shl 11 + // Masks 12-15 are shadow properties (iOS only) + const val MASK_ELEVATION = 1 shl 16 } // --- Transform origin (0–1 fractions) --- @@ -160,6 +168,12 @@ class EaseView(context: Context) : ReactViewGroup(context) { clipToOutline = false } invalidateOutline() + // Sync border drawable so borders follow the animated corner radius. + // Value is in pixels; convert back to DIPs for BackgroundStyleApplicator. + val dip = PixelUtil.toDIPFromPixel(value) + BackgroundStyleApplicator.setBorderRadius( + this, BorderRadiusProp.BORDER_RADIUS, + LengthPercentage(dip, LengthPercentageType.POINT)) } } @@ -209,6 +223,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { var initialAnimateBackgroundColor: Int = Color.TRANSPARENT var initialAnimateBorderWidth: Float = 0.0f var initialAnimateBorderColor: Int = Color.BLACK + var initialAnimateElevation: Float = 0.0f // --- Pending animate values (buffered per-view, applied in onAfterUpdateTransaction) --- var pendingOpacity: Float = 1.0f @@ -223,6 +238,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { var pendingBackgroundColor: Int = Color.TRANSPARENT var pendingBorderWidth: Float = 0.0f var pendingBorderColor: Int = Color.BLACK + var pendingElevation: Float = 0.0f // --- Running animations --- private val runningAnimators = mutableMapOf() @@ -246,15 +262,16 @@ class EaseView(context: Context) : ReactViewGroup(context) { cameraDistance = density * density * perspective * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER } + // Custom outline provider used when borderRadius is animated. + // Reads _borderRadius dynamically — invalidated on each frame by setAnimateBorderRadius. + private val animatedOutlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, _borderRadius) + } + } + init { applyCameraDistance(1280f) - - // ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame. - outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, _borderRadius) - } - } } // --- Hardware layer management --- @@ -292,7 +309,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { } fun applyPendingAnimateValues() { - applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor) + applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor, pendingElevation) } private fun applyAnimateValues( @@ -307,7 +324,8 @@ class EaseView(context: Context) : ReactViewGroup(context) { borderRadius: Float, backgroundColor: Int, borderWidth: Float, - borderColor: Int + borderColor: Int, + elevation: Float ) { if (pendingBatchAnimationCount > 0) { onTransitionEnd?.invoke(false) @@ -320,6 +338,16 @@ class EaseView(context: Context) : ReactViewGroup(context) { // Bitmask: which properties are animated. Non-animated = let style handle. val mask = animatedProperties + // Use custom outline provider only when borderRadius is animated. + // Otherwise fall back to BACKGROUND provider so elevation shadows + // respect the style borderRadius from the background drawable. + val needsCustomOutline = mask and MASK_BORDER_RADIUS != 0 + if (needsCustomOutline && outlineProvider !== animatedOutlineProvider) { + outlineProvider = animatedOutlineProvider + } else if (!needsCustomOutline && outlineProvider === animatedOutlineProvider) { + outlineProvider = ViewOutlineProvider.BACKGROUND + } + if (isFirstMount) { isFirstMount = false @@ -335,7 +363,8 @@ class EaseView(context: Context) : ReactViewGroup(context) { (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) || (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) || (mask and MASK_BORDER_WIDTH != 0 && initialAnimateBorderWidth != borderWidth) || - (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) + (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) || + (mask and MASK_ELEVATION != 0 && initialAnimateElevation != elevation) if (hasInitialAnimation) { // Set initial values for animated properties @@ -351,6 +380,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(initialAnimateBackgroundColor) if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(initialAnimateBorderWidth) if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(initialAnimateBorderColor) + if (mask and MASK_ELEVATION != 0) this.elevation = initialAnimateElevation // Animate properties that differ from initial to target if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) { @@ -389,6 +419,9 @@ class EaseView(context: Context) : ReactViewGroup(context) { if (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) { animateBorderColorTransition(initialAnimateBorderColor, borderColor, getTransitionConfig("borderColor"), loop = true) } + if (mask and MASK_ELEVATION != 0 && initialAnimateElevation != elevation) { + animateProperty("elevation", null, initialAnimateElevation, elevation, getTransitionConfig("elevation"), loop = true) + } // If all per-property configs were 'none', no animations were queued. // Fire onTransitionEnd immediately to match the scalar 'none' contract. @@ -409,6 +442,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor) if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(borderWidth) if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(borderColor) + if (mask and MASK_ELEVATION != 0) this.elevation = elevation } // Update backface visibility after setting initial rotation values. @@ -431,6 +465,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor) if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(borderWidth) if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(borderColor) + if (mask and MASK_ELEVATION != 0) this.elevation = elevation onTransitionEnd?.invoke(true) } else { // Subsequent updates: animate changed properties (skip non-animated) @@ -598,6 +633,19 @@ class EaseView(context: Context) : ReactViewGroup(context) { } } + if (prevElevation != null && mask and MASK_ELEVATION != 0 && prevElevation != elevation) { + anyPropertyChanged = true + val config = getTransitionConfig("elevation") + if (config.type == "none") { + runningAnimators["elevation"]?.cancel() + runningAnimators.remove("elevation") + this.elevation = elevation + } else { + val from = getCurrentValue("elevation") + animateProperty("elevation", null, from, elevation, config) + } + } + // If all changed properties resolved to 'none', no animations were queued. // Fire onTransitionEnd immediately. if (anyPropertyChanged && pendingBatchAnimationCount == 0) { @@ -617,6 +665,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { prevBackgroundColor = backgroundColor prevBorderWidth = borderWidth prevBorderColor = borderColor + prevElevation = elevation } private fun getCurrentValue(propertyName: String): Float = when (propertyName) { @@ -630,6 +679,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { "rotationY" -> this.rotationY "animateBorderRadius" -> getAnimateBorderRadius() "animateBorderWidth" -> getAnimateBorderWidth() + "elevation" -> this.elevation else -> 0f } @@ -961,6 +1011,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { prevBackgroundColor = null prevBorderWidth = null prevBorderColor = null + prevElevation = null this.alpha = 1f this.translationX = 0f @@ -974,6 +1025,8 @@ class EaseView(context: Context) : ReactViewGroup(context) { applyBackgroundColor(Color.TRANSPARENT) setAnimateBorderWidth(0f) applyBorderColor(Color.BLACK) + this.elevation = 0f + outlineProvider = ViewOutlineProvider.BACKGROUND transformPerspective = 1280f isFirstMount = true diff --git a/android/src/main/java/com/ease/EaseViewManager.kt b/android/src/main/java/com/ease/EaseViewManager.kt index c1adabe..7d51d74 100644 --- a/android/src/main/java/com/ease/EaseViewManager.kt +++ b/android/src/main/java/com/ease/EaseViewManager.kt @@ -178,6 +178,18 @@ class EaseViewManager : ReactViewManager() { view.initialAnimateBorderColor = value ?: Color.BLACK } + // --- Elevation --- + + @ReactProp(name = "animateElevation", defaultFloat = 0f) + fun setAnimateElevation(view: EaseView, value: Float) { + view.pendingElevation = PixelUtil.toPixelFromDIP(value) + } + + @ReactProp(name = "initialAnimateElevation", defaultFloat = 0f) + fun setInitialAnimateElevation(view: EaseView, value: Float) { + view.initialAnimateElevation = PixelUtil.toPixelFromDIP(value) + } + // --- Hardware layer --- @ReactProp(name = "useHardwareLayer", defaultBoolean = false) diff --git a/example/src/demos/KitchenSinkDemo.tsx b/example/src/demos/KitchenSinkDemo.tsx new file mode 100644 index 0000000..4d8f73c --- /dev/null +++ b/example/src/demos/KitchenSinkDemo.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { View, Text, StyleSheet, Platform } from 'react-native'; +import { EaseView } from 'react-native-ease'; + +import { Section } from '../components/Section'; +import { Button } from '../components/Button'; + +export function KitchenSinkDemo() { + const [active, setActive] = useState(false); + return ( +
+ + + + {active ? 'Wild' : 'Calm'} + + + {Platform.select({ + android: 'all props', + default: 'every prop', + })} + + + +
+ ); +} + +const styles = StyleSheet.create({ + surface: { + backgroundColor: '#e8eaf0', + borderRadius: 12, + padding: 40, + alignItems: 'center', + }, + box: { + width: 110, + height: 110, + alignItems: 'center', + justifyContent: 'center', + }, + text: { + color: '#333', + fontSize: 18, + fontWeight: '800', + }, + textActive: { + color: '#fff', + }, + sub: { + color: '#999', + fontSize: 11, + fontWeight: '600', + marginTop: 2, + }, +}); diff --git a/example/src/demos/ShadowDemo.tsx b/example/src/demos/ShadowDemo.tsx new file mode 100644 index 0000000..7401621 --- /dev/null +++ b/example/src/demos/ShadowDemo.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { View, Text, StyleSheet, Platform } from 'react-native'; +import { EaseView } from 'react-native-ease'; + +import { Section } from '../components/Section'; +import { Button } from '../components/Button'; + +export function ShadowDemo() { + const [active, setActive] = useState(false); + return ( +
+ + + + {active + ? Platform.OS === 'android' + ? 'Elevated' + : 'Shadow' + : 'Flat'} + + + +
+ ); +} + +const styles = StyleSheet.create({ + surface: { + backgroundColor: '#e8eaf0', + borderRadius: 12, + padding: 32, + alignItems: 'center', + }, + box: { + width: 100, + height: 100, + borderRadius: 16, + backgroundColor: '#fff', + shadowColor: '#000', + alignItems: 'center', + justifyContent: 'center', + }, + text: { + color: '#333', + fontSize: 13, + fontWeight: '700', + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index a267515..ab14eb5 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -16,6 +16,7 @@ import { EnterDemo } from './EnterDemo'; import { ExitDemo } from './ExitDemo'; import { FadeDemo } from './FadeDemo'; import { InterruptDemo } from './InterruptDemo'; +import { KitchenSinkDemo } from './KitchenSinkDemo'; import { PulseDemo } from './PulseDemo'; import { RotateDemo } from './RotateDemo'; import { ScaleDemo } from './ScaleDemo'; @@ -24,6 +25,7 @@ import { StyleReRenderDemo } from './StyleReRenderDemo'; import { StyledCardDemo } from './StyledCardDemo'; import { TransformOriginDemo } from './TransformOriginDemo'; import { PerPropertyDemo } from './PerPropertyDemo'; +import { ShadowDemo } from './ShadowDemo'; import { SpinDemo } from './SpinDemo'; import { UniwindDemo } from './uniwind/UniwindDemo'; @@ -78,6 +80,11 @@ export const demos: Record = { title: 'Background Color', section: 'Style', }, + 'shadow': { + component: ShadowDemo, + title: 'Shadow', + section: 'Style', + }, 'style-rerender': { component: StyleReRenderDemo, title: 'Style Re-Render', @@ -106,6 +113,11 @@ export const demos: Record = { title: 'Per-Property', section: 'Advanced', }, + 'kitchen-sink': { + component: KitchenSinkDemo, + title: 'Kitchen Sink', + section: 'Advanced', + }, ...(Platform.OS !== 'web' ? { benchmark: { diff --git a/ios/EaseView.mm b/ios/EaseView.mm index e839734..6385fb9 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -30,6 +30,10 @@ - (void)invalidateLayer; static NSString *const kAnimKeyBackgroundColor = @"ease_backgroundColor"; static NSString *const kAnimKeyBorderWidth = @"ease_borderWidth"; static NSString *const kAnimKeyBorderColor = @"ease_borderColor"; +static NSString *const kAnimKeyShadowOpacity = @"ease_shadowOpacity"; +static NSString *const kAnimKeyShadowRadius = @"ease_shadowRadius"; +static NSString *const kAnimKeyShadowColor = @"ease_shadowColor"; +static NSString *const kAnimKeyShadowOffset = @"ease_shadowOffset"; static inline CGFloat degreesToRadians(CGFloat degrees) { return degrees * M_PI / 180.0; @@ -66,6 +70,11 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY, static const int kMaskBackgroundColor = 1 << 9; static const int kMaskBorderWidth = 1 << 10; static const int kMaskBorderColor = 1 << 11; +static const int kMaskShadowOpacity = 1 << 12; +static const int kMaskShadowRadius = 1 << 13; +static const int kMaskShadowColor = 1 << 14; +static const int kMaskShadowOffset = 1 << 15; +// kMaskElevation = 1 << 16 — Android-only, no-op on iOS static const int kMaskAnyTransform = kMaskTranslateX | kMaskTranslateY | kMaskScaleX | kMaskScaleY | kMaskRotate | kMaskRotateX | kMaskRotateY; @@ -135,6 +144,10 @@ static EaseTransitionConfig transitionConfigFromStruct(const T &src) { } else if ((name == "borderWidth" || name == "borderColor") && hasConfig(t.border)) { return transitionConfigFromStruct(t.border); + } else if ((name == "shadowOpacity" || name == "shadowRadius" || + name == "shadowColor" || name == "shadowOffset") && + hasConfig(t.shadow)) { + return transitionConfigFromStruct(t.shadow); } // Fallback to defaultConfig return transitionConfigFromStruct(t.defaultConfig); @@ -357,6 +370,24 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { (mask & kMaskBorderColor) && viewProps.initialAnimateBorderColor != viewProps.animateBorderColor; + BOOL hasInitialShadowOpacity = + (mask & kMaskShadowOpacity) && + viewProps.initialAnimateShadowOpacity != viewProps.animateShadowOpacity; + + BOOL hasInitialShadowRadius = + (mask & kMaskShadowRadius) && + viewProps.initialAnimateShadowRadius != viewProps.animateShadowRadius; + + BOOL hasInitialShadowColor = + (mask & kMaskShadowColor) && + viewProps.initialAnimateShadowColor != viewProps.animateShadowColor; + + BOOL hasInitialShadowOffset = + (mask & kMaskShadowOffset) && + (viewProps.initialAnimateShadowOffsetX != + viewProps.animateShadowOffsetX || + viewProps.initialAnimateShadowOffsetY != viewProps.animateShadowOffsetY); + BOOL hasInitialTransform = NO; CATransform3D initialT = CATransform3DIdentity; CATransform3D targetT = CATransform3DIdentity; @@ -387,7 +418,9 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { if (hasInitialOpacity || hasInitialTransform || hasInitialBorderRadius || hasInitialBackgroundColor || hasInitialBorderWidth || - hasInitialBorderColor) { + hasInitialBorderColor || hasInitialShadowOpacity || + hasInitialShadowRadius || hasInitialShadowColor || + hasInitialShadowOffset) { // Set initial values after props and layout have settled for this mount. if (mask & kMaskOpacity) self.layer.opacity = viewProps.initialAnimateOpacity; @@ -395,8 +428,6 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { self.layer.transform = [self initialTransformFromProps:viewProps]; if (mask & kMaskBorderRadius) { self.layer.cornerRadius = viewProps.initialAnimateBorderRadius; - self.layer.masksToBounds = viewProps.initialAnimateBorderRadius > 0 || - viewProps.animateBorderRadius > 0; } if (mask & kMaskBackgroundColor) self.layer.backgroundColor = @@ -408,6 +439,18 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { self.layer.borderColor = RCTUIColorFromSharedColor(viewProps.initialAnimateBorderColor) .CGColor; + if (mask & kMaskShadowOpacity) + self.layer.shadowOpacity = viewProps.initialAnimateShadowOpacity; + if (mask & kMaskShadowRadius) + self.layer.shadowRadius = viewProps.initialAnimateShadowRadius; + if (mask & kMaskShadowColor) + self.layer.shadowColor = + RCTUIColorFromSharedColor(viewProps.initialAnimateShadowColor) + .CGColor; + if (mask & kMaskShadowOffset) + self.layer.shadowOffset = + CGSizeMake(viewProps.initialAnimateShadowOffsetX, + viewProps.initialAnimateShadowOffsetY); // Animate from initial to target (skip if config is 'none') if (hasInitialOpacity) { @@ -560,6 +603,68 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { loop:YES]; } } + if (hasInitialShadowOpacity) { + EaseTransitionConfig config = + transitionConfigForProperty("shadowOpacity", viewProps); + self.layer.shadowOpacity = viewProps.animateShadowOpacity; + if (config.type != "none") { + [self applyAnimationForKeyPath:@"shadowOpacity" + animationKey:kAnimKeyShadowOpacity + fromValue:@(viewProps.initialAnimateShadowOpacity) + toValue:@(viewProps.animateShadowOpacity) + config:config + loop:YES]; + } + } + if (hasInitialShadowRadius) { + EaseTransitionConfig config = + transitionConfigForProperty("shadowRadius", viewProps); + self.layer.shadowRadius = viewProps.animateShadowRadius; + if (config.type != "none") { + [self applyAnimationForKeyPath:@"shadowRadius" + animationKey:kAnimKeyShadowRadius + fromValue:@(viewProps.initialAnimateShadowRadius) + toValue:@(viewProps.animateShadowRadius) + config:config + loop:YES]; + } + } + if (hasInitialShadowColor) { + EaseTransitionConfig config = + transitionConfigForProperty("shadowColor", viewProps); + self.layer.shadowColor = + RCTUIColorFromSharedColor(viewProps.animateShadowColor).CGColor; + if (config.type != "none") { + [self applyAnimationForKeyPath:@"shadowColor" + animationKey:kAnimKeyShadowColor + fromValue:(__bridge id)RCTUIColorFromSharedColor( + viewProps.initialAnimateShadowColor) + .CGColor + toValue:(__bridge id)RCTUIColorFromSharedColor( + viewProps.animateShadowColor) + .CGColor + config:config + loop:YES]; + } + } + if (hasInitialShadowOffset) { + EaseTransitionConfig config = + transitionConfigForProperty("shadowOffset", viewProps); + CGSize targetOffset = CGSizeMake(viewProps.animateShadowOffsetX, + viewProps.animateShadowOffsetY); + self.layer.shadowOffset = targetOffset; + if (config.type != "none") { + CGSize initialOffset = + CGSizeMake(viewProps.initialAnimateShadowOffsetX, + viewProps.initialAnimateShadowOffsetY); + [self applyAnimationForKeyPath:@"shadowOffset" + animationKey:kAnimKeyShadowOffset + fromValue:[NSValue valueWithCGSize:initialOffset] + toValue:[NSValue valueWithCGSize:targetOffset] + config:config + loop:YES]; + } + } // If all per-property configs were 'none', no animations were queued. // Fire onTransitionEnd immediately to match the scalar 'none' contract. @@ -576,10 +681,8 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { self.layer.opacity = viewProps.animateOpacity; if (hasTransform) self.layer.transform = [self targetTransformFromProps:viewProps]; - if (mask & kMaskBorderRadius) { + if (mask & kMaskBorderRadius) self.layer.cornerRadius = viewProps.animateBorderRadius; - self.layer.masksToBounds = viewProps.animateBorderRadius > 0; - } if (mask & kMaskBackgroundColor) self.layer.backgroundColor = RCTUIColorFromSharedColor(viewProps.animateBackgroundColor).CGColor; @@ -588,6 +691,16 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { if (mask & kMaskBorderColor) self.layer.borderColor = RCTUIColorFromSharedColor(viewProps.animateBorderColor).CGColor; + if (mask & kMaskShadowOpacity) + self.layer.shadowOpacity = viewProps.animateShadowOpacity; + if (mask & kMaskShadowRadius) + self.layer.shadowRadius = viewProps.animateShadowRadius; + if (mask & kMaskShadowColor) + self.layer.shadowColor = + RCTUIColorFromSharedColor(viewProps.animateShadowColor).CGColor; + if (mask & kMaskShadowOffset) + self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, + viewProps.animateShadowOffsetY); } } @@ -653,7 +766,9 @@ - (void)updateProps:(const Props::Shared &)props (!hasConfig(newViewProps.transitions.backgroundColor) || newViewProps.transitions.backgroundColor.type == "none") && (!hasConfig(newViewProps.transitions.border) || - newViewProps.transitions.border.type == "none")) { + newViewProps.transitions.border.type == "none") && + (!hasConfig(newViewProps.transitions.shadow) || + newViewProps.transitions.shadow.type == "none")) { // All transitions are 'none' — set values immediately [self beginAnimationBatch]; [self.layer removeAllAnimations]; @@ -661,10 +776,8 @@ - (void)updateProps:(const Props::Shared &)props self.layer.opacity = newViewProps.animateOpacity; if (hasTransform) self.layer.transform = [self targetTransformFromProps:newViewProps]; - if (mask & kMaskBorderRadius) { + if (mask & kMaskBorderRadius) self.layer.cornerRadius = newViewProps.animateBorderRadius; - self.layer.masksToBounds = newViewProps.animateBorderRadius > 0; - } if (mask & kMaskBackgroundColor) self.layer.backgroundColor = RCTUIColorFromSharedColor(newViewProps.animateBackgroundColor) @@ -674,6 +787,16 @@ - (void)updateProps:(const Props::Shared &)props if (mask & kMaskBorderColor) self.layer.borderColor = RCTUIColorFromSharedColor(newViewProps.animateBorderColor).CGColor; + if (mask & kMaskShadowOpacity) + self.layer.shadowOpacity = newViewProps.animateShadowOpacity; + if (mask & kMaskShadowRadius) + self.layer.shadowRadius = newViewProps.animateShadowRadius; + if (mask & kMaskShadowColor) + self.layer.shadowColor = + RCTUIColorFromSharedColor(newViewProps.animateShadowColor).CGColor; + if (mask & kMaskShadowOffset) + self.layer.shadowOffset = CGSizeMake(newViewProps.animateShadowOffsetX, + newViewProps.animateShadowOffsetY); if (_eventEmitter) { auto emitter = std::static_pointer_cast(_eventEmitter); @@ -849,7 +972,6 @@ - (void)updateProps:(const Props::Shared &)props EaseTransitionConfig brConfig = transitionConfigForProperty("borderRadius", newViewProps); self.layer.cornerRadius = newViewProps.animateBorderRadius; - self.layer.masksToBounds = newViewProps.animateBorderRadius > 0; if (brConfig.type == "none") { [self.layer removeAnimationForKey:kAnimKeyCornerRadius]; } else { @@ -928,6 +1050,89 @@ - (void)updateProps:(const Props::Shared &)props } } + if ((mask & kMaskShadowOpacity) && oldViewProps.animateShadowOpacity != + newViewProps.animateShadowOpacity) { + anyPropertyChanged = YES; + EaseTransitionConfig config = + transitionConfigForProperty("shadowOpacity", newViewProps); + self.layer.shadowOpacity = newViewProps.animateShadowOpacity; + if (config.type == "none") { + [self.layer removeAnimationForKey:kAnimKeyShadowOpacity]; + } else { + [self applyAnimationForKeyPath:@"shadowOpacity" + animationKey:kAnimKeyShadowOpacity + fromValue:[self presentationValueForKeyPath: + @"shadowOpacity"] + toValue:@(newViewProps.animateShadowOpacity) + config:config + loop:NO]; + } + } + + if ((mask & kMaskShadowRadius) && + oldViewProps.animateShadowRadius != newViewProps.animateShadowRadius) { + anyPropertyChanged = YES; + EaseTransitionConfig config = + transitionConfigForProperty("shadowRadius", newViewProps); + self.layer.shadowRadius = newViewProps.animateShadowRadius; + if (config.type == "none") { + [self.layer removeAnimationForKey:kAnimKeyShadowRadius]; + } else { + [self applyAnimationForKeyPath:@"shadowRadius" + animationKey:kAnimKeyShadowRadius + fromValue:[self presentationValueForKeyPath: + @"shadowRadius"] + toValue:@(newViewProps.animateShadowRadius) + config:config + loop:NO]; + } + } + + if ((mask & kMaskShadowColor) && + oldViewProps.animateShadowColor != newViewProps.animateShadowColor) { + anyPropertyChanged = YES; + EaseTransitionConfig config = + transitionConfigForProperty("shadowColor", newViewProps); + CGColorRef toColor = + RCTUIColorFromSharedColor(newViewProps.animateShadowColor).CGColor; + self.layer.shadowColor = toColor; + if (config.type == "none") { + [self.layer removeAnimationForKey:kAnimKeyShadowColor]; + } else { + CGColorRef fromColor = (__bridge CGColorRef) + [self presentationValueForKeyPath:@"shadowColor"]; + [self applyAnimationForKeyPath:@"shadowColor" + animationKey:kAnimKeyShadowColor + fromValue:(__bridge id)fromColor + toValue:(__bridge id)toColor + config:config + loop:NO]; + } + } + + if ((mask & kMaskShadowOffset) && (oldViewProps.animateShadowOffsetX != + newViewProps.animateShadowOffsetX || + oldViewProps.animateShadowOffsetY != + newViewProps.animateShadowOffsetY)) { + anyPropertyChanged = YES; + EaseTransitionConfig config = + transitionConfigForProperty("shadowOffset", newViewProps); + CGSize targetOffset = CGSizeMake(newViewProps.animateShadowOffsetX, + newViewProps.animateShadowOffsetY); + self.layer.shadowOffset = targetOffset; + if (config.type == "none") { + [self.layer removeAnimationForKey:kAnimKeyShadowOffset]; + } else { + CGSize fromOffset = + [[self presentationValueForKeyPath:@"shadowOffset"] CGSizeValue]; + [self applyAnimationForKeyPath:@"shadowOffset" + animationKey:kAnimKeyShadowOffset + fromValue:[NSValue valueWithCGSize:fromOffset] + toValue:[NSValue valueWithCGSize:targetOffset] + config:config + loop:NO]; + } + } // If all changed properties resolved to 'none', no animations were queued. // Fire onTransitionEnd immediately. if (anyPropertyChanged && _pendingAnimationCount == 0 && _eventEmitter) { @@ -964,7 +1169,8 @@ - (void)invalidateLayer { int mask = viewProps.animatedProperties; if (!(mask & (kMaskOpacity | kMaskBorderRadius | kMaskBackgroundColor | - kMaskBorderWidth | kMaskBorderColor))) { + kMaskBorderWidth | kMaskBorderColor | kMaskShadowOpacity | + kMaskShadowRadius | kMaskShadowColor | kMaskShadowOffset))) { return; } @@ -977,7 +1183,6 @@ - (void)invalidateLayer { if (mask & kMaskBorderRadius) { [self.layer removeAnimationForKey:@"cornerRadius"]; self.layer.cornerRadius = viewProps.animateBorderRadius; - self.layer.masksToBounds = viewProps.animateBorderRadius > 0; } if (mask & kMaskBackgroundColor) { [self.layer removeAnimationForKey:@"backgroundColor"]; @@ -993,6 +1198,24 @@ - (void)invalidateLayer { self.layer.borderColor = RCTUIColorFromSharedColor(viewProps.animateBorderColor).CGColor; } + if (mask & kMaskShadowOpacity) { + [self.layer removeAnimationForKey:kAnimKeyShadowOpacity]; + self.layer.shadowOpacity = viewProps.animateShadowOpacity; + } + if (mask & kMaskShadowRadius) { + [self.layer removeAnimationForKey:kAnimKeyShadowRadius]; + self.layer.shadowRadius = viewProps.animateShadowRadius; + } + if (mask & kMaskShadowColor) { + [self.layer removeAnimationForKey:kAnimKeyShadowColor]; + self.layer.shadowColor = + RCTUIColorFromSharedColor(viewProps.animateShadowColor).CGColor; + } + if (mask & kMaskShadowOffset) { + [self.layer removeAnimationForKey:kAnimKeyShadowOffset]; + self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, + viewProps.animateShadowOffsetY); + } [CATransaction commit]; } @@ -1035,6 +1258,10 @@ - (void)prepareForRecycle { self.layer.backgroundColor = nil; self.layer.borderWidth = 0; self.layer.borderColor = nil; + self.layer.shadowOpacity = 0; + self.layer.shadowRadius = 0; + self.layer.shadowColor = nil; + self.layer.shadowOffset = CGSizeZero; } @end diff --git a/src/EaseView.tsx b/src/EaseView.tsx index 9696bfe..f039261 100644 --- a/src/EaseView.tsx +++ b/src/EaseView.tsx @@ -22,6 +22,11 @@ const IDENTITY = { rotateY: 0, borderRadius: 0, borderWidth: 0, + shadowOpacity: 0, + shadowRadius: 0, + shadowOffsetWidth: 0, + shadowOffsetHeight: 0, + elevation: 0, }; /** Bitmask flags — must match native constants. */ @@ -38,6 +43,11 @@ const MASK_BORDER_RADIUS = 1 << 8; const MASK_BACKGROUND_COLOR = 1 << 9; const MASK_BORDER_WIDTH = 1 << 10; const MASK_BORDER_COLOR = 1 << 11; +const MASK_SHADOW_OPACITY = 1 << 12; +const MASK_SHADOW_RADIUS = 1 << 13; +const MASK_SHADOW_COLOR = 1 << 14; +const MASK_SHADOW_OFFSET = 1 << 15; +const MASK_ELEVATION = 1 << 16; /* eslint-enable no-bitwise */ /** Maps animate prop keys to style keys that conflict. */ @@ -55,6 +65,11 @@ const ANIMATE_TO_STYLE_KEYS: Record = { backgroundColor: 'backgroundColor', borderWidth: 'borderWidth', borderColor: 'borderColor', + shadowOpacity: 'shadowOpacity', + shadowRadius: 'shadowRadius', + shadowColor: 'shadowColor', + shadowOffset: 'shadowOffset', + elevation: 'elevation', }; /** Preset easing curves as cubic bezier control points. */ @@ -143,6 +158,7 @@ const CATEGORY_KEYS = [ 'borderRadius', 'backgroundColor', 'border', + 'shadow', ] as const; /** Resolve the transition prop into a NativeTransitions struct. */ @@ -249,6 +265,11 @@ export function EaseView({ animatedProperties |= MASK_BACKGROUND_COLOR; if (animate?.borderWidth != null) animatedProperties |= MASK_BORDER_WIDTH; if (animate?.borderColor != null) animatedProperties |= MASK_BORDER_COLOR; + if (animate?.shadowOpacity != null) animatedProperties |= MASK_SHADOW_OPACITY; + if (animate?.shadowRadius != null) animatedProperties |= MASK_SHADOW_RADIUS; + if (animate?.shadowColor != null) animatedProperties |= MASK_SHADOW_COLOR; + if (animate?.shadowOffset != null) animatedProperties |= MASK_SHADOW_OFFSET; + if (animate?.elevation != null) animatedProperties |= MASK_ELEVATION; /* eslint-enable no-bitwise */ // Resolve animate values (identity defaults for non-animated — safe values). @@ -259,6 +280,9 @@ export function EaseView({ scaleY: animate?.scaleY ?? animate?.scale ?? IDENTITY.scaleY, rotateX: animate?.rotateX ?? IDENTITY.rotateX, rotateY: animate?.rotateY ?? IDENTITY.rotateY, + // Flatten shadowOffset object into individual values for native + shadowOffsetWidth: animate?.shadowOffset?.width ?? 0, + shadowOffsetHeight: animate?.shadowOffset?.height ?? 0, }; // Resolve initialAnimate: @@ -273,6 +297,8 @@ export function EaseView({ scaleY: initial?.scaleY ?? initial?.scale ?? IDENTITY.scaleY, rotateX: initial?.rotateX ?? IDENTITY.rotateX, rotateY: initial?.rotateY ?? IDENTITY.rotateY, + shadowOffsetWidth: initial?.shadowOffset?.width ?? 0, + shadowOffsetHeight: initial?.shadowOffset?.height ?? 0, }; // Resolve color props — passed as ColorValue directly (codegen handles conversion) @@ -280,6 +306,8 @@ export function EaseView({ const initialBgColor = initialAnimate?.backgroundColor ?? animBgColor; const animBorderColor = animate?.borderColor ?? 'black'; const initialBorderColor = initialAnimate?.borderColor ?? animBorderColor; + const animShadowColor = animate?.shadowColor ?? 'black'; + const initialShadowColor = initialAnimate?.shadowColor ?? animShadowColor; // Strip style keys that conflict with animated properties let cleanStyle: ViewProps['style'] = style; @@ -340,6 +368,12 @@ export function EaseView({ animateBackgroundColor={animBgColor} animateBorderWidth={resolved.borderWidth} animateBorderColor={animBorderColor} + animateShadowOpacity={resolved.shadowOpacity} + animateShadowRadius={resolved.shadowRadius} + animateShadowColor={animShadowColor} + animateShadowOffsetX={resolved.shadowOffsetWidth} + animateShadowOffsetY={resolved.shadowOffsetHeight} + animateElevation={resolved.elevation} initialAnimateOpacity={resolvedInitial.opacity} initialAnimateTranslateX={resolvedInitial.translateX} initialAnimateTranslateY={resolvedInitial.translateY} @@ -352,6 +386,12 @@ export function EaseView({ initialAnimateBackgroundColor={initialBgColor} initialAnimateBorderWidth={resolvedInitial.borderWidth} initialAnimateBorderColor={initialBorderColor} + initialAnimateShadowOpacity={resolvedInitial.shadowOpacity} + initialAnimateShadowRadius={resolvedInitial.shadowRadius} + initialAnimateShadowColor={initialShadowColor} + initialAnimateShadowOffsetX={resolvedInitial.shadowOffsetWidth} + initialAnimateShadowOffsetY={resolvedInitial.shadowOffsetHeight} + initialAnimateElevation={resolvedInitial.elevation} transitions={transitions} useHardwareLayer={useHardwareLayer} transformOriginX={transformOrigin?.x ?? 0.5} diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index 461d95d..f452d27 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -12,8 +12,11 @@ import type { /** Identity values used as defaults for animate/initialAnimate. */ const IDENTITY: Required< - Omit -> = { + Omit< + AnimateProps, + 'scale' | 'backgroundColor' | 'borderColor' | 'shadowColor' | 'shadowOffset' + > +> & { shadowOffset: { width: number; height: number } } = { opacity: 1, translateX: 0, translateY: 0, @@ -24,6 +27,10 @@ const IDENTITY: Required< rotateY: 0, borderRadius: 0, borderWidth: 0, + shadowOpacity: 0, + shadowRadius: 0, + shadowOffset: { width: 0, height: 0 }, + elevation: 0, }; /** Preset easing curves as cubic bezier control points. */ @@ -126,11 +133,12 @@ export type EaseViewProps = { children?: React.ReactNode; }; -function resolveAnimateValues(props: AnimateProps | undefined): Required< - Omit -> & { +function resolveAnimateValues( + props: AnimateProps | undefined, +): typeof IDENTITY & { backgroundColor?: string; borderColor?: string; + shadowColor?: string; } { return { ...IDENTITY, @@ -139,8 +147,13 @@ function resolveAnimateValues(props: AnimateProps | undefined): Required< scaleY: props?.scaleY ?? props?.scale ?? IDENTITY.scaleY, rotateX: props?.rotateX ?? IDENTITY.rotateX, rotateY: props?.rotateY ?? IDENTITY.rotateY, + shadowOffset: { + width: props?.shadowOffset?.width ?? 0, + height: props?.shadowOffset?.height ?? 0, + }, backgroundColor: props?.backgroundColor as string | undefined, borderColor: props?.borderColor as string | undefined, + shadowColor: props?.shadowColor as string | undefined, }; } @@ -203,6 +216,7 @@ const CSS_PROP_MAP = { backgroundColor: 'background-color', borderWidth: 'border-width', borderColor: 'border-color', + boxShadow: 'box-shadow', } as const; type CategoryKey = keyof typeof CSS_PROP_MAP; @@ -220,6 +234,7 @@ function resolvePerCategoryConfigs( backgroundColor: def, borderWidth: def, borderColor: def, + boxShadow: def, }; } if (isSingleTransition(transition)) { @@ -231,6 +246,7 @@ function resolvePerCategoryConfigs( backgroundColor: def, borderWidth: def, borderColor: def, + boxShadow: def, }; } // TransitionMap @@ -238,6 +254,9 @@ function resolvePerCategoryConfigs( const borderConfig = transition.border ? resolveConfigForCss(transition.border) : defaultConfig; + const shadowConfig = transition.shadow + ? resolveConfigForCss(transition.shadow) + : defaultConfig; return { opacity: transition.opacity ? resolveConfigForCss(transition.opacity) @@ -253,6 +272,7 @@ function resolvePerCategoryConfigs( : defaultConfig, borderWidth: borderConfig, borderColor: borderConfig, + boxShadow: shadowConfig, }; } @@ -507,6 +527,14 @@ export function EaseView({ ...(displayValues.borderColor ? { borderColor: displayValues.borderColor } : {}), + ...(displayValues.shadowOpacity > 0 + ? { + shadowColor: displayValues.shadowColor ?? 'black', + shadowOpacity: displayValues.shadowOpacity, + shadowRadius: displayValues.shadowRadius, + shadowOffset: displayValues.shadowOffset, + } + : {}), }; return ( diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts index e87dcbd..4f22c1e 100644 --- a/src/EaseViewNativeComponent.ts +++ b/src/EaseViewNativeComponent.ts @@ -27,6 +27,7 @@ type NativeTransitions = Readonly<{ borderRadius?: NativeTransitionConfig; backgroundColor?: NativeTransitionConfig; border?: NativeTransitionConfig; + shadow?: NativeTransitionConfig; }>; export interface NativeProps extends ViewProps { @@ -46,6 +47,12 @@ export interface NativeProps extends ViewProps { animateBackgroundColor?: ColorValue; animateBorderWidth?: CodegenTypes.WithDefault; animateBorderColor?: ColorValue; + animateShadowOpacity?: CodegenTypes.WithDefault; + animateShadowRadius?: CodegenTypes.WithDefault; + animateShadowColor?: ColorValue; + animateShadowOffsetX?: CodegenTypes.WithDefault; + animateShadowOffsetY?: CodegenTypes.WithDefault; + animateElevation?: CodegenTypes.WithDefault; // Initial values for enter animations initialAnimateOpacity?: CodegenTypes.WithDefault; @@ -63,6 +70,24 @@ export interface NativeProps extends ViewProps { initialAnimateBackgroundColor?: ColorValue; initialAnimateBorderWidth?: CodegenTypes.WithDefault; initialAnimateBorderColor?: ColorValue; + initialAnimateShadowOpacity?: CodegenTypes.WithDefault< + CodegenTypes.Float, + 0.0 + >; + initialAnimateShadowRadius?: CodegenTypes.WithDefault< + CodegenTypes.Float, + 0.0 + >; + initialAnimateShadowColor?: ColorValue; + initialAnimateShadowOffsetX?: CodegenTypes.WithDefault< + CodegenTypes.Float, + 0.0 + >; + initialAnimateShadowOffsetY?: CodegenTypes.WithDefault< + CodegenTypes.Float, + 0.0 + >; + initialAnimateElevation?: CodegenTypes.WithDefault; // Unified transition config — one struct with per-property configs transitions?: NativeTransitions; diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx index ff19608..a53af59 100644 --- a/src/__tests__/EaseView.test.tsx +++ b/src/__tests__/EaseView.test.tsx @@ -719,6 +719,183 @@ describe('EaseView', () => { }); }); + describe('animate shadow properties', () => { + it('passes shadow props to native', () => { + render( + , + ); + const props = getNativeProps(); + expect(props.animateShadowOpacity).toBe(0.5); + expect(props.animateShadowRadius).toBe(10); + expect(props.animateShadowOffsetX).toBe(2); + expect(props.animateShadowOffsetY).toBe(4); + }); + + it('defaults shadow props to 0 when not in animate', () => { + render(); + const props = getNativeProps(); + expect(props.animateShadowOpacity).toBe(0); + expect(props.animateShadowRadius).toBe(0); + expect(props.animateShadowOffsetX).toBe(0); + expect(props.animateShadowOffsetY).toBe(0); + }); + + it('passes shadowColor as ColorValue', () => { + render(); + expect(getNativeProps().animateShadowColor).toBe('red'); + }); + + it('defaults shadowColor to black when not in animate', () => { + render(); + expect(getNativeProps().animateShadowColor).toBe('black'); + }); + + it('sets bitmask for shadow properties', () => { + render( + , + ); + // shadowOpacity = 1<<12 = 4096, shadowRadius = 1<<13 = 8192 → 12288 + expect(getNativeProps().animatedProperties).toBe(12288); + }); + + it('sets bitmask for shadowColor (1 << 14 = 16384)', () => { + render(); + expect(getNativeProps().animatedProperties).toBe(16384); + }); + + it('sets bitmask for shadowOffset (1 << 15 = 32768)', () => { + render( + , + ); + expect(getNativeProps().animatedProperties).toBe(32768); + }); + + it('passes initialAnimate shadow values', () => { + render( + , + ); + const props = getNativeProps(); + expect(props.initialAnimateShadowOpacity).toBe(0); + expect(props.initialAnimateShadowRadius).toBe(0); + expect(props.animateShadowOpacity).toBe(0.5); + expect(props.animateShadowRadius).toBe(10); + }); + + it('passes initialAnimate shadowColor', () => { + render( + , + ); + const props = getNativeProps(); + expect(props.initialAnimateShadowColor).toBe('blue'); + expect(props.animateShadowColor).toBe('red'); + }); + + it('strips style shadowOpacity when animate.shadowOpacity is set', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render( + , + ); + const props = getNativeProps(); + expect(props.style).toEqual( + expect.objectContaining({ backgroundColor: 'red' }), + ); + expect(props.style.shadowOpacity).toBeUndefined(); + spy.mockRestore(); + }); + + it('strips style shadowOffset when animate.shadowOffset is set', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render( + , + ); + const props = getNativeProps(); + expect(props.style).toEqual( + expect.objectContaining({ backgroundColor: 'red' }), + ); + expect(props.style.shadowOffset).toBeUndefined(); + spy.mockRestore(); + }); + }); + + describe('animate elevation', () => { + it('passes elevation to native', () => { + render(); + expect(getNativeProps().animateElevation).toBe(5); + }); + + it('defaults elevation to 0', () => { + render(); + expect(getNativeProps().animateElevation).toBe(0); + }); + + it('sets bitmask for elevation (1 << 16 = 65536)', () => { + render(); + expect(getNativeProps().animatedProperties).toBe(65536); + }); + + it('passes initialAnimate elevation', () => { + render( + , + ); + const props = getNativeProps(); + expect(props.initialAnimateElevation).toBe(0); + expect(props.animateElevation).toBe(10); + }); + + it('strips style elevation when animate.elevation is set', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render( + , + ); + const props = getNativeProps(); + expect(props.style).toEqual( + expect.objectContaining({ backgroundColor: 'red' }), + ); + expect(props.style.elevation).toBeUndefined(); + spy.mockRestore(); + }); + }); + describe('border transition category', () => { it('passes border category in transition map', () => { render( @@ -738,6 +915,25 @@ describe('EaseView', () => { }); }); + describe('shadow transition category', () => { + it('passes shadow category in transition map', () => { + render( + , + ); + const t = getNativeProps().transitions; + expect(t.shadow!.type).toBe('spring'); + expect(t.shadow!.damping).toBe(20); + expect(t.shadow!.stiffness).toBe(200); + expect(t.defaultConfig.type).toBe('timing'); + }); + }); + describe('rotate loop props', () => { it('passes rotate 0→360 with loop repeat to native', () => { render( diff --git a/src/types.ts b/src/types.ts index d70b28c..b6dc605 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,8 @@ export type TransitionMap = { backgroundColor?: SingleTransition; /** Config for border properties (borderWidth, borderColor). */ border?: SingleTransition; + /** Config for shadow properties (shadowOpacity, shadowRadius, shadowColor, shadowOffset) and elevation. */ + shadow?: SingleTransition; }; /** Animation transition configuration — either a single config or a per-property map. */ @@ -120,4 +122,14 @@ export type AnimateProps = { borderWidth?: number; /** Border color. Accepts any React Native color value. @default 'black' */ borderColor?: ColorValue; + /** Shadow opacity (0–1, iOS only). @default 0 */ + shadowOpacity?: number; + /** Shadow blur radius (iOS only). @default 0 */ + shadowRadius?: number; + /** Shadow color (iOS only). Accepts any React Native color value. @default 'black' */ + shadowColor?: ColorValue; + /** Shadow offset (iOS only). @default { width: 0, height: 0 } */ + shadowOffset?: { width?: number; height?: number }; + /** Android elevation for material shadow. @default 0 */ + elevation?: number; };