From 6677def80c5b2386f9d403f733ac383be44e29cd Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 21:06:49 -0400 Subject: [PATCH 01/14] feat: add shadow properties (iOS) and elevation (Android) animation support Adds animatable shadow/elevation properties following the existing per-property pattern: bitmask flags, codegen props, native change detection, and platform-specific animation. iOS: shadowOpacity, shadowRadius, shadowColor, shadowOffset via Core Animation key-path animations. Android: elevation via ObjectAnimator. --- android/src/main/java/com/ease/EaseView.kt | 41 ++- .../src/main/java/com/ease/EaseViewManager.kt | 12 + ios/EaseView.mm | 247 +++++++++++++++++- src/EaseView.tsx | 40 +++ src/EaseView.web.tsx | 11 +- src/EaseViewNativeComponent.ts | 25 ++ src/__tests__/EaseView.test.tsx | 198 ++++++++++++++ src/types.ts | 14 + 8 files changed, 577 insertions(+), 11 deletions(-) diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index 3cdaa17..f49bda1 100644 --- a/android/src/main/java/com/ease/EaseView.kt +++ b/android/src/main/java/com/ease/EaseView.kt @@ -38,6 +38,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 +65,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 +99,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { "borderRadius" -> "borderRadius" "backgroundColor" -> "backgroundColor" "borderWidth", "borderColor" -> "border" + "elevation" -> "shadow" else -> null } if (categoryKey != null) { @@ -109,7 +111,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 +132,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-16 are shadow properties (iOS only) + const val MASK_ELEVATION = 1 shl 17 } // --- Transform origin (0–1 fractions) --- @@ -209,6 +213,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 +228,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() @@ -292,7 +298,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 +313,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) @@ -335,7 +342,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 +359,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 +398,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 +421,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 +444,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 +612,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 +644,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 +658,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { "rotationY" -> this.rotationY "animateBorderRadius" -> getAnimateBorderRadius() "animateBorderWidth" -> getAnimateBorderWidth() + "elevation" -> this.elevation else -> 0f } @@ -961,6 +990,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { prevBackgroundColor = null prevBorderWidth = null prevBorderColor = null + prevElevation = null this.alpha = 1f this.translationX = 0f @@ -974,6 +1004,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { applyBackgroundColor(Color.TRANSPARENT) setAnimateBorderWidth(0f) applyBorderColor(Color.BLACK) + this.elevation = 0f 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/ios/EaseView.mm b/ios/EaseView.mm index e839734..562547a 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -1,4 +1,4 @@ -#import "EaseView.h" +l#import "EaseView.h" #import @@ -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,13 @@ 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 kMaskShadowOffsetX = 1 << 15; +static const int kMaskShadowOffsetY = 1 << 16; +// kMaskElevation = 1 << 17 — Android-only, no-op on iOS +static const int kMaskAnyShadowOffset = kMaskShadowOffsetX | kMaskShadowOffsetY; static const int kMaskAnyTransform = kMaskTranslateX | kMaskTranslateY | kMaskScaleX | kMaskScaleY | kMaskRotate | kMaskRotateX | kMaskRotateY; @@ -135,6 +146,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 +372,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 & kMaskAnyShadowOffset) && + (viewProps.initialAnimateShadowOffsetX != + viewProps.animateShadowOffsetX || + viewProps.initialAnimateShadowOffsetY != viewProps.animateShadowOffsetY); + BOOL hasInitialTransform = NO; CATransform3D initialT = CATransform3DIdentity; CATransform3D targetT = CATransform3DIdentity; @@ -387,7 +420,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; @@ -408,6 +443,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 & kMaskAnyShadowOffset) + self.layer.shadowOffset = + CGSizeMake(viewProps.initialAnimateShadowOffsetX, + viewProps.initialAnimateShadowOffsetY); // Animate from initial to target (skip if config is 'none') if (hasInitialOpacity) { @@ -560,6 +607,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. @@ -588,6 +697,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 & kMaskAnyShadowOffset) + self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, + viewProps.animateShadowOffsetY); } } @@ -653,7 +772,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]; @@ -674,6 +795,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 & kMaskAnyShadowOffset) + self.layer.shadowOffset = CGSizeMake(newViewProps.animateShadowOffsetX, + newViewProps.animateShadowOffsetY); if (_eventEmitter) { auto emitter = std::static_pointer_cast(_eventEmitter); @@ -928,6 +1059,90 @@ - (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 & kMaskAnyShadowOffset) && + (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 +1179,9 @@ - (void)invalidateLayer { int mask = viewProps.animatedProperties; if (!(mask & (kMaskOpacity | kMaskBorderRadius | kMaskBackgroundColor | - kMaskBorderWidth | kMaskBorderColor))) { + kMaskBorderWidth | kMaskBorderColor | kMaskShadowOpacity | + kMaskShadowRadius | kMaskShadowColor | + kMaskAnyShadowOffset))) { return; } @@ -993,6 +1210,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 & kMaskAnyShadowOffset) { + [self.layer removeAnimationForKey:kAnimKeyShadowOffset]; + self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, + viewProps.animateShadowOffsetY); + } [CATransaction commit]; } @@ -1035,6 +1270,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..75a3567 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, + shadowOffsetX: 0, + shadowOffsetY: 0, + elevation: 0, }; /** Bitmask flags — must match native constants. */ @@ -38,6 +43,12 @@ 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_X = 1 << 15; +const MASK_SHADOW_OFFSET_Y = 1 << 16; +const MASK_ELEVATION = 1 << 17; /* eslint-enable no-bitwise */ /** Maps animate prop keys to style keys that conflict. */ @@ -55,6 +66,12 @@ const ANIMATE_TO_STYLE_KEYS: Record = { backgroundColor: 'backgroundColor', borderWidth: 'borderWidth', borderColor: 'borderColor', + shadowOpacity: 'shadowOpacity', + shadowRadius: 'shadowRadius', + shadowColor: 'shadowColor', + shadowOffsetX: 'shadowOffset', + shadowOffsetY: 'shadowOffset', + elevation: 'elevation', }; /** Preset easing curves as cubic bezier control points. */ @@ -143,6 +160,7 @@ const CATEGORY_KEYS = [ 'borderRadius', 'backgroundColor', 'border', + 'shadow', ] as const; /** Resolve the transition prop into a NativeTransitions struct. */ @@ -249,6 +267,14 @@ 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?.shadowOffsetX != null) + animatedProperties |= MASK_SHADOW_OFFSET_X; + if (animate?.shadowOffsetY != null) + animatedProperties |= MASK_SHADOW_OFFSET_Y; + if (animate?.elevation != null) animatedProperties |= MASK_ELEVATION; /* eslint-enable no-bitwise */ // Resolve animate values (identity defaults for non-animated — safe values). @@ -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.shadowOffsetX} + animateShadowOffsetY={resolved.shadowOffsetY} + 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.shadowOffsetX} + initialAnimateShadowOffsetY={resolvedInitial.shadowOffsetY} + 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..2185539 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -12,7 +12,7 @@ import type { /** Identity values used as defaults for animate/initialAnimate. */ const IDENTITY: Required< - Omit + Omit > = { opacity: 1, translateX: 0, @@ -24,6 +24,11 @@ const IDENTITY: Required< rotateY: 0, borderRadius: 0, borderWidth: 0, + shadowOpacity: 0, + shadowRadius: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + elevation: 0, }; /** Preset easing curves as cubic bezier control points. */ @@ -127,10 +132,11 @@ export type EaseViewProps = { }; function resolveAnimateValues(props: AnimateProps | undefined): Required< - Omit + Omit > & { backgroundColor?: string; borderColor?: string; + shadowColor?: string; } { return { ...IDENTITY, @@ -141,6 +147,7 @@ function resolveAnimateValues(props: AnimateProps | undefined): Required< rotateY: props?.rotateY ?? IDENTITY.rotateY, backgroundColor: props?.backgroundColor as string | undefined, borderColor: props?.borderColor as string | undefined, + shadowColor: props?.shadowColor as string | undefined, }; } 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..e6a68a8 100644 --- a/src/__tests__/EaseView.test.tsx +++ b/src/__tests__/EaseView.test.tsx @@ -719,6 +719,185 @@ 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 (X=1<<15, Y=1<<16)', () => { + render( + , + ); + // shadowOffsetX = 1<<15 = 32768, shadowOffsetY = 1<<16 = 65536 → 98304 + expect(getNativeProps().animatedProperties).toBe(98304); + }); + + 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.shadowOffsetX 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 << 17 = 131072)', () => { + render(); + expect(getNativeProps().animatedProperties).toBe(131072); + }); + + 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 +917,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..a6f4b04 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,16 @@ 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 horizontal offset (iOS only). @default 0 */ + shadowOffsetX?: number; + /** Shadow vertical offset (iOS only). @default 0 */ + shadowOffsetY?: number; + /** Android elevation for material shadow. @default 0 */ + elevation?: number; }; From 581405adbd3c4969646e5f1a602e3237de4229b3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 21:30:53 -0400 Subject: [PATCH 02/14] fix(ios): remove stray character at start of EaseView.mm clang-format introduced a stray 'l' at the beginning of the file, causing a compilation error. --- ios/EaseView.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/EaseView.mm b/ios/EaseView.mm index 562547a..c824f98 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -1,4 +1,4 @@ -l#import "EaseView.h" +#import "EaseView.h" #import From 736899c19ab2f2322df1f34276e631d21611d9be Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 21:42:25 -0400 Subject: [PATCH 03/14] feat(web): wire shadow and elevation into CSS transitions and styles --- src/EaseView.web.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index 2185539..0b08f2b 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -210,6 +210,8 @@ const CSS_PROP_MAP = { backgroundColor: 'background-color', borderWidth: 'border-width', borderColor: 'border-color', + boxShadow: 'box-shadow', + elevation: 'elevation', } as const; type CategoryKey = keyof typeof CSS_PROP_MAP; @@ -227,6 +229,8 @@ function resolvePerCategoryConfigs( backgroundColor: def, borderWidth: def, borderColor: def, + boxShadow: def, + elevation: def, }; } if (isSingleTransition(transition)) { @@ -238,6 +242,8 @@ function resolvePerCategoryConfigs( backgroundColor: def, borderWidth: def, borderColor: def, + boxShadow: def, + elevation: def, }; } // TransitionMap @@ -245,6 +251,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) @@ -260,6 +269,8 @@ function resolvePerCategoryConfigs( : defaultConfig, borderWidth: borderConfig, borderColor: borderConfig, + boxShadow: shadowConfig, + elevation: shadowConfig, }; } @@ -514,6 +525,20 @@ export function EaseView({ ...(displayValues.borderColor ? { borderColor: displayValues.borderColor } : {}), + ...(displayValues.shadowOpacity > 0 + ? { + shadowColor: displayValues.shadowColor ?? 'black', + shadowOpacity: displayValues.shadowOpacity, + shadowRadius: displayValues.shadowRadius, + shadowOffset: { + width: displayValues.shadowOffsetX, + height: displayValues.shadowOffsetY, + }, + } + : {}), + ...(displayValues.elevation > 0 + ? { elevation: displayValues.elevation } + : {}), }; return ( From 62469a562388bb7b0159a7d8a0ded1e08982f970 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 21:45:05 -0400 Subject: [PATCH 04/14] feat(example): add shadow/elevation demo --- example/src/demos/ShadowDemo.tsx | 66 ++++++++++++++++++++++++++++++++ example/src/demos/index.ts | 6 +++ 2 files changed, 72 insertions(+) create mode 100644 example/src/demos/ShadowDemo.tsx diff --git a/example/src/demos/ShadowDemo.tsx b/example/src/demos/ShadowDemo.tsx new file mode 100644 index 0000000..3ba20c9 --- /dev/null +++ b/example/src/demos/ShadowDemo.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { 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({ + 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..013cf20 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -24,6 +24,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 +79,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', From 11791cb8966a96bbbfe23b7223232146d4c8c18b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 22:12:00 -0400 Subject: [PATCH 05/14] refactor: change shadowOffset API to object, update bitmask layout, remove web elevation - shadowOffset is now { width, height } matching RN style API (flattened to native props) - Consolidated MASK_SHADOW_OFFSET into single bit (1 << 15) - MASK_ELEVATION shifted to 1 << 16 - Removed elevation from web (no-op in react-native-web) --- android/src/main/java/com/ease/EaseView.kt | 4 +- example/src/demos/ShadowDemo.tsx | 6 +- ios/EaseView.mm | 28 ++--- react-native-animation-performance.md | 128 +++++++++++++++++++++ src/EaseView.tsx | 30 ++--- src/EaseView.web.tsx | 32 +++--- src/__tests__/EaseView.test.tsx | 18 ++- src/types.ts | 6 +- 8 files changed, 183 insertions(+), 69 deletions(-) create mode 100644 react-native-animation-performance.md diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index f49bda1..ffabf9f 100644 --- a/android/src/main/java/com/ease/EaseView.kt +++ b/android/src/main/java/com/ease/EaseView.kt @@ -132,8 +132,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-16 are shadow properties (iOS only) - const val MASK_ELEVATION = 1 shl 17 + // Masks 12-15 are shadow properties (iOS only) + const val MASK_ELEVATION = 1 shl 16 } // --- Transform origin (0–1 fractions) --- diff --git a/example/src/demos/ShadowDemo.tsx b/example/src/demos/ShadowDemo.tsx index 3ba20c9..b7978bf 100644 --- a/example/src/demos/ShadowDemo.tsx +++ b/example/src/demos/ShadowDemo.tsx @@ -15,15 +15,13 @@ export function ShadowDemo() { ? { shadowOpacity: 0.4, shadowRadius: 16, - shadowOffsetX: 0, - shadowOffsetY: 8, + shadowOffset: { width: 0, height: 8 }, elevation: 12, } : { shadowOpacity: 0, shadowRadius: 0, - shadowOffsetX: 0, - shadowOffsetY: 0, + shadowOffset: { width: 0, height: 0 }, elevation: 0, } } diff --git a/ios/EaseView.mm b/ios/EaseView.mm index c824f98..db45c87 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -73,10 +73,8 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY, static const int kMaskShadowOpacity = 1 << 12; static const int kMaskShadowRadius = 1 << 13; static const int kMaskShadowColor = 1 << 14; -static const int kMaskShadowOffsetX = 1 << 15; -static const int kMaskShadowOffsetY = 1 << 16; -// kMaskElevation = 1 << 17 — Android-only, no-op on iOS -static const int kMaskAnyShadowOffset = kMaskShadowOffsetX | kMaskShadowOffsetY; +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; @@ -385,7 +383,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { viewProps.initialAnimateShadowColor != viewProps.animateShadowColor; BOOL hasInitialShadowOffset = - (mask & kMaskAnyShadowOffset) && + (mask & kMaskShadowOffset) && (viewProps.initialAnimateShadowOffsetX != viewProps.animateShadowOffsetX || viewProps.initialAnimateShadowOffsetY != viewProps.animateShadowOffsetY); @@ -451,7 +449,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { self.layer.shadowColor = RCTUIColorFromSharedColor(viewProps.initialAnimateShadowColor) .CGColor; - if (mask & kMaskAnyShadowOffset) + if (mask & kMaskShadowOffset) self.layer.shadowOffset = CGSizeMake(viewProps.initialAnimateShadowOffsetX, viewProps.initialAnimateShadowOffsetY); @@ -704,7 +702,7 @@ - (void)applyFirstMountProps:(const EaseViewProps &)viewProps { if (mask & kMaskShadowColor) self.layer.shadowColor = RCTUIColorFromSharedColor(viewProps.animateShadowColor).CGColor; - if (mask & kMaskAnyShadowOffset) + if (mask & kMaskShadowOffset) self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, viewProps.animateShadowOffsetY); } @@ -802,7 +800,7 @@ - (void)updateProps:(const Props::Shared &)props if (mask & kMaskShadowColor) self.layer.shadowColor = RCTUIColorFromSharedColor(newViewProps.animateShadowColor).CGColor; - if (mask & kMaskAnyShadowOffset) + if (mask & kMaskShadowOffset) self.layer.shadowOffset = CGSizeMake(newViewProps.animateShadowOffsetX, newViewProps.animateShadowOffsetY); if (_eventEmitter) { @@ -1119,11 +1117,10 @@ - (void)updateProps:(const Props::Shared &)props } } - if ((mask & kMaskAnyShadowOffset) && - (oldViewProps.animateShadowOffsetX != - newViewProps.animateShadowOffsetX || - oldViewProps.animateShadowOffsetY != - newViewProps.animateShadowOffsetY)) { + if ((mask & kMaskShadowOffset) && (oldViewProps.animateShadowOffsetX != + newViewProps.animateShadowOffsetX || + oldViewProps.animateShadowOffsetY != + newViewProps.animateShadowOffsetY)) { anyPropertyChanged = YES; EaseTransitionConfig config = transitionConfigForProperty("shadowOffset", newViewProps); @@ -1180,8 +1177,7 @@ - (void)invalidateLayer { if (!(mask & (kMaskOpacity | kMaskBorderRadius | kMaskBackgroundColor | kMaskBorderWidth | kMaskBorderColor | kMaskShadowOpacity | - kMaskShadowRadius | kMaskShadowColor | - kMaskAnyShadowOffset))) { + kMaskShadowRadius | kMaskShadowColor | kMaskShadowOffset))) { return; } @@ -1223,7 +1219,7 @@ - (void)invalidateLayer { self.layer.shadowColor = RCTUIColorFromSharedColor(viewProps.animateShadowColor).CGColor; } - if (mask & kMaskAnyShadowOffset) { + if (mask & kMaskShadowOffset) { [self.layer removeAnimationForKey:kAnimKeyShadowOffset]; self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, viewProps.animateShadowOffsetY); diff --git a/react-native-animation-performance.md b/react-native-animation-performance.md new file mode 100644 index 0000000..7af7b08 --- /dev/null +++ b/react-native-animation-performance.md @@ -0,0 +1,128 @@ +--- +slug: react-native-animation-performance +title: React Native Animation Performance — How Different Libraries Compare +authors: [appandflow] +tags: [react-native, performance, animation, benchmarks] +--- + +At [App & Flow](https://appandflow.com), we build React Native apps and tools for product teams that need fluid, native-feeling UIs. One thing that stuck with us: a slowly translating background image that would occasionally stutter. The culprit is that Reanimated still runs on the UI thread every frame — so if your app does any significant work during that frame (layout, re-renders, other updates), the animation budget shrinks and the background stutters. + +Core Animation, by contrast, hands animations off to the OS render server and never touches your thread again. So we built [react-native-ease](https://github.com/AppAndFlow/react-native-ease) — a declarative animation library that drives everything through platform APIs (Core Animation on iOS, ObjectAnimator on Android) with no JS loop, no worklets, and no shadow tree commits per frame. But that raised a question we needed to answer honestly: **how much does the choice of animation library actually matter?** + +So we measured it. Across four approaches, two platforms, release builds, and both high-end and mid-range devices, we tracked per-frame UI thread overhead. This post shares what we found, and tries to answer the questions that actually matter: how large is the frame penalty? In what kinds of apps does it matter? And what should you prioritize when choosing an animation library? + + + +## The Approaches + +We compared four animation approaches: + +- **Ease** — react-native-ease, using platform APIs directly. Animations are fully described on the JS side via props, then driven natively without any per-frame JS involvement. +- **Reanimated (Shared Values)** — the standard worklet-based approach. Animation values are driven on the UI thread via a C++ worklet runtime, but each frame still updates props through the shadow tree. +- **Reanimated (CSS Animations)** — Reanimated's newer CSS animation API. Declarative like Ease, but still backed by Reanimated's animation engine. +- **RN Animated** — React Native's built-in `Animated` API with `useNativeDriver: true`. Animation values are driven on the native side, but the implementation depends on the platform. + +We also tested Reanimated with its static feature flags enabled — specifically `ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS` and `IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS`, which allow Reanimated to skip the shadow tree commit when only non-layout props (like `transform` and `opacity`) are updated. This is a meaningful optimization worth calling out separately. + +> **Note:** RN 0.85 introduced a new Shared Animation Backend that will eventually make the feature flags unnecessary by handling shadow tree bypass at a lower level. Reanimated's integration is in progress but not yet released, so it isn't included here. See [What's Coming](#whats-coming-rn-085s-new-animation-backend). + +## How We Measured + +We built a benchmark screen into the example app that animates N views simultaneously in a loop (translateX, 2s, linear, repeating). We used a custom Expo native module to measure per-frame overhead: + +- **iOS:** We swizzle `CADisplayLink`'s factory method to intercept all display link callbacks registered by any framework. We measure wall-clock time spent in each callback, aggregated per frame by timestamp. +- **Android:** We use `Window.OnFrameMetricsAvailableListener`, which reports `ANIMATION_DURATION`, `LAYOUT_MEASURE_DURATION`, and `DRAW_DURATION` from the platform's frame metrics system. + +We ran 5-second collection windows in release builds. All benchmarks reported here are from release builds — debug adds significant overhead from Hermes parsing JS instead of precompiled AOT bytecode, and the shadow tree running extra validation. A Reanimated animation that takes 3ms per frame in release can easily take 10ms+ in debug. + +## The Results + +### Android (UI thread time per frame: anim + layout + draw, ms) + +| Views | Approach | Avg | P95 | P99 | +|-------|----------|-----|-----|-----| +| 10 | Ease | 0.21 | 0.33 | 0.48 | +| 10 | Reanimated SV | 1.15 | 1.70 | 1.94 | +| 10 | Reanimated SV (FF) | 0.75 | 1.53 | 2.26 | +| 10 | Reanimated CSS | 0.99 | 1.44 | 1.62 | +| 10 | Reanimated CSS (FF) | 0.45 | 0.80 | 1.35 | +| 10 | RN Animated | 0.36 | 0.62 | 0.98 | +| 100 | Ease | 0.36 | 0.56 | 0.71 | +| 100 | Reanimated SV | 2.71 | 3.09 | 3.20 | +| 100 | Reanimated SV (FF) | 1.81 | 2.29 | 2.63 | +| 100 | Reanimated CSS | 2.19 | 2.67 | 2.97 | +| 100 | Reanimated CSS (FF) | 1.01 | 1.91 | 2.25 | +| 100 | RN Animated | 0.71 | 1.08 | 1.36 | +| 500 | Ease | 0.60 | 0.75 | 0.87 | +| 500 | Reanimated SV | 8.31 | 9.26 | 9.59 | +| 500 | Reanimated SV (FF) | 5.37 | 6.36 | 6.89 | +| 500 | Reanimated CSS | 5.50 | 6.34 | 6.88 | +| 500 | Reanimated CSS (FF) | 2.37 | 2.86 | 3.22 | +| 500 | RN Animated | 1.60 | 1.88 | 3.84 | + +### iOS (Display link callback time per frame, ms) + +| Views | Approach | Avg | P95 | P99 | +|-------|----------|-----|-----|-----| +| 10 | Ease | 0.01 | 0.02 | 0.03 | +| 10 | Reanimated SV | 1.33 | 1.67 | 1.90 | +| 10 | Reanimated SV (FF) | 1.08 | 1.59 | 1.68 | +| 10 | Reanimated CSS | 1.06 | 1.34 | 1.50 | +| 10 | Reanimated CSS (FF) | 0.63 | 1.01 | 1.08 | +| 10 | RN Animated | 0.83 | 1.18 | 1.31 | +| 100 | Ease | 0.01 | 0.01 | 0.02 | +| 100 | Reanimated SV | 3.72 | 5.21 | 5.68 | +| 100 | Reanimated SV (FF) | 3.33 | 4.50 | 4.75 | +| 100 | Reanimated CSS | 2.71 | 3.83 | 4.91 | +| 100 | Reanimated CSS (FF) | 2.48 | 3.39 | 3.79 | +| 100 | RN Animated | 3.32 | 4.28 | 4.55 | +| 500 | Ease | 0.01 | 0.01 | 0.02 | +| 500 | Reanimated SV | 6.84 | 7.69 | 8.10 | +| 500 | Reanimated SV (FF) | 6.54 | 7.32 | 7.45 | +| 500 | Reanimated CSS | 4.16 | 4.59 | 4.71 | +| 500 | Reanimated CSS (FF) | 3.70 | 4.22 | 4.33 | +| 500 | RN Animated | 4.91 | 5.66 | 5.89 | + +## Why the Differences Exist + +### The shadow tree tax + +Every frame, Reanimated's worklet computes new values and applies them by committing a prop update through the shadow tree. That commit traverses the shadow tree — Yoga layout, prop diffing, view mutations. When you animate `transform` or `opacity` (properties that don't affect layout), this work is entirely wasted. + +Reanimated's feature flags (`ANDROID/IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS`) bypass this by updating visual props directly on the UI layer without a shadow tree commit. On Android with 100 views, enabling these flags cuts Reanimated SV overhead from 2.71ms to 1.81ms — about 33% improvement. CSS animations with FF drops from 2.19ms to 1.01ms, more than halving it. The gains are consistent across all view counts. + +### Why Ease shows near-zero on iOS + +Ease registers a `CAAnimation` on each view's layer and walks away. Core Animation runs in a dedicated render server process that Apple runs separately from your app process entirely — it's not even on your main thread. That's why Ease measures ~0.01ms on iOS: there is genuinely almost no main-thread work per frame. + +On Android, ObjectAnimator runs on the UI thread, but the overhead is minimal because there's no shadow tree involvement and no worklet runtime — just direct property updates. + +### RN Animated + +`RN Animated` with `useNativeDriver: true` also avoids the JS thread per frame, but it still goes through a different native animation pipeline that carries some overhead. It performs surprisingly well at low-to-mid view counts on Android, but starts to lag at higher counts. + +## What Does This Mean in Practice? + +At 10 views, all approaches are comfortably within one frame budget (16.67ms at 60 fps). The differences — fractions of a millisecond — are real but not meaningful on their own. + +At 100 views, Reanimated is spending 2–4ms per frame on animation overhead alone, leaving less headroom for the rest of your frame work (layout, rendering, your custom code). On a mid-range device that's already close to budget, this starts to matter. + +At 500 views, Reanimated SV without feature flags approaches the entire frame budget on both platforms. This is an extreme scenario, but it illustrates that the overhead is not constant — it scales linearly with the number of animated views. + +**The practical answer:** it matters most when animations are long-running or slow — like skeleton loaders, background parallax, or ambient UI effects — where a single dropped frame is immediately noticeable and other app work (data fetching, rendering, user interaction) is likely happening at the same time. It also matters for anything in a list, where you can easily have hundreds of animated items simultaneously. And on very low-end devices, where even small per-frame overhead can push a frame over budget, the choice of library becomes the difference between smooth and janky. For short one-shot transitions — a button press feedback, a toast appearing, a modal sliding in — the overhead is negligible and any library works fine. + +It's also worth noting that Ease only covers this specific use case. Gesture-driven animations (scroll-linked, drag, swipe) and animations that affect layout properties (width, height, padding) still require Reanimated or RN Animated. Ease is purpose-built for declarative, trigger-based animations on visual properties. + +## What's Coming: RN 0.85's New Animation Backend + +React Native 0.85 (released April 7, 2025) ships an experimental "Shared Animation Backend" — a unified animation engine built directly into the renderer. Notably, this was a joint project: Software Mansion engineers (the Reanimated team) co-authored much of the RN core implementation alongside Meta, and the feature is explicitly designed to power both Animated and Reanimated going forward. + +The Reanimated integration is [already in progress](https://github.com/software-mansion/react-native-reanimated/pull/8875) — Software Mansion co-built the RN side and has an open PR to adopt it. Once shipped, Reanimated won't need workarounds like `SYNCHRONOUSLY_UPDATE_UI_PROPS` — shadow tree bypass will be built in by default. Expect its performance to land closer to what we measured with all feature flags enabled, or RN Animated. This matters because the feature flags aren't always safe to enable today — they can cause visual bugs depending on your app, so many projects leave them off. + +That said, the architectural difference remains. Ease has no per-frame animation engine at all — the platform drives everything outside the JS layer entirely. Even with a faster backend, Reanimated still computes values and applies prop updates every frame via its worklet runtime. That overhead doesn't disappear, it just gets more efficient. We'll re-run benchmarks once the Reanimated integration ships. + +## Reproduce It Yourself + +The benchmark is built into the example app. Clone the repo, run `yarn example ios` or `yarn example android`, and tap **Benchmark** from the demo screen. The source is in `example/src/demos/BenchmarkDemo.tsx` and the native measurement module is in `example/modules/frame-metrics/`. + +Run release builds (`yarn example ios --configuration Release` / `yarn example android --variant release`) for comparable numbers. diff --git a/src/EaseView.tsx b/src/EaseView.tsx index 75a3567..f039261 100644 --- a/src/EaseView.tsx +++ b/src/EaseView.tsx @@ -24,8 +24,8 @@ const IDENTITY = { borderWidth: 0, shadowOpacity: 0, shadowRadius: 0, - shadowOffsetX: 0, - shadowOffsetY: 0, + shadowOffsetWidth: 0, + shadowOffsetHeight: 0, elevation: 0, }; @@ -46,9 +46,8 @@ 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_X = 1 << 15; -const MASK_SHADOW_OFFSET_Y = 1 << 16; -const MASK_ELEVATION = 1 << 17; +const MASK_SHADOW_OFFSET = 1 << 15; +const MASK_ELEVATION = 1 << 16; /* eslint-enable no-bitwise */ /** Maps animate prop keys to style keys that conflict. */ @@ -69,8 +68,7 @@ const ANIMATE_TO_STYLE_KEYS: Record = { shadowOpacity: 'shadowOpacity', shadowRadius: 'shadowRadius', shadowColor: 'shadowColor', - shadowOffsetX: 'shadowOffset', - shadowOffsetY: 'shadowOffset', + shadowOffset: 'shadowOffset', elevation: 'elevation', }; @@ -270,10 +268,7 @@ export function EaseView({ 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?.shadowOffsetX != null) - animatedProperties |= MASK_SHADOW_OFFSET_X; - if (animate?.shadowOffsetY != null) - animatedProperties |= MASK_SHADOW_OFFSET_Y; + if (animate?.shadowOffset != null) animatedProperties |= MASK_SHADOW_OFFSET; if (animate?.elevation != null) animatedProperties |= MASK_ELEVATION; /* eslint-enable no-bitwise */ @@ -285,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: @@ -299,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) @@ -371,8 +371,8 @@ export function EaseView({ animateShadowOpacity={resolved.shadowOpacity} animateShadowRadius={resolved.shadowRadius} animateShadowColor={animShadowColor} - animateShadowOffsetX={resolved.shadowOffsetX} - animateShadowOffsetY={resolved.shadowOffsetY} + animateShadowOffsetX={resolved.shadowOffsetWidth} + animateShadowOffsetY={resolved.shadowOffsetHeight} animateElevation={resolved.elevation} initialAnimateOpacity={resolvedInitial.opacity} initialAnimateTranslateX={resolvedInitial.translateX} @@ -389,8 +389,8 @@ export function EaseView({ initialAnimateShadowOpacity={resolvedInitial.shadowOpacity} initialAnimateShadowRadius={resolvedInitial.shadowRadius} initialAnimateShadowColor={initialShadowColor} - initialAnimateShadowOffsetX={resolvedInitial.shadowOffsetX} - initialAnimateShadowOffsetY={resolvedInitial.shadowOffsetY} + initialAnimateShadowOffsetX={resolvedInitial.shadowOffsetWidth} + initialAnimateShadowOffsetY={resolvedInitial.shadowOffsetHeight} initialAnimateElevation={resolvedInitial.elevation} transitions={transitions} useHardwareLayer={useHardwareLayer} diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index 0b08f2b..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, @@ -26,8 +29,7 @@ const IDENTITY: Required< borderWidth: 0, shadowOpacity: 0, shadowRadius: 0, - shadowOffsetX: 0, - shadowOffsetY: 0, + shadowOffset: { width: 0, height: 0 }, elevation: 0, }; @@ -131,9 +133,9 @@ 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; @@ -145,6 +147,10 @@ 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, @@ -211,7 +217,6 @@ const CSS_PROP_MAP = { borderWidth: 'border-width', borderColor: 'border-color', boxShadow: 'box-shadow', - elevation: 'elevation', } as const; type CategoryKey = keyof typeof CSS_PROP_MAP; @@ -230,7 +235,6 @@ function resolvePerCategoryConfigs( borderWidth: def, borderColor: def, boxShadow: def, - elevation: def, }; } if (isSingleTransition(transition)) { @@ -243,7 +247,6 @@ function resolvePerCategoryConfigs( borderWidth: def, borderColor: def, boxShadow: def, - elevation: def, }; } // TransitionMap @@ -270,7 +273,6 @@ function resolvePerCategoryConfigs( borderWidth: borderConfig, borderColor: borderConfig, boxShadow: shadowConfig, - elevation: shadowConfig, }; } @@ -530,15 +532,9 @@ export function EaseView({ shadowColor: displayValues.shadowColor ?? 'black', shadowOpacity: displayValues.shadowOpacity, shadowRadius: displayValues.shadowRadius, - shadowOffset: { - width: displayValues.shadowOffsetX, - height: displayValues.shadowOffsetY, - }, + shadowOffset: displayValues.shadowOffset, } : {}), - ...(displayValues.elevation > 0 - ? { elevation: displayValues.elevation } - : {}), }; return ( diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx index e6a68a8..a53af59 100644 --- a/src/__tests__/EaseView.test.tsx +++ b/src/__tests__/EaseView.test.tsx @@ -727,8 +727,7 @@ describe('EaseView', () => { animate={{ shadowOpacity: 0.5, shadowRadius: 10, - shadowOffsetX: 2, - shadowOffsetY: 4, + shadowOffset: { width: 2, height: 4 }, }} />, ); @@ -774,15 +773,14 @@ describe('EaseView', () => { expect(getNativeProps().animatedProperties).toBe(16384); }); - it('sets bitmask for shadowOffset (X=1<<15, Y=1<<16)', () => { + it('sets bitmask for shadowOffset (1 << 15 = 32768)', () => { render( , ); - // shadowOffsetX = 1<<15 = 32768, shadowOffsetY = 1<<16 = 65536 → 98304 - expect(getNativeProps().animatedProperties).toBe(98304); + expect(getNativeProps().animatedProperties).toBe(32768); }); it('passes initialAnimate shadow values', () => { @@ -830,12 +828,12 @@ describe('EaseView', () => { spy.mockRestore(); }); - it('strips style shadowOffset when animate.shadowOffsetX is set', () => { + it('strips style shadowOffset when animate.shadowOffset is set', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); render( { expect(getNativeProps().animateElevation).toBe(0); }); - it('sets bitmask for elevation (1 << 17 = 131072)', () => { + it('sets bitmask for elevation (1 << 16 = 65536)', () => { render(); - expect(getNativeProps().animatedProperties).toBe(131072); + expect(getNativeProps().animatedProperties).toBe(65536); }); it('passes initialAnimate elevation', () => { diff --git a/src/types.ts b/src/types.ts index a6f4b04..b6dc605 100644 --- a/src/types.ts +++ b/src/types.ts @@ -128,10 +128,8 @@ export type AnimateProps = { shadowRadius?: number; /** Shadow color (iOS only). Accepts any React Native color value. @default 'black' */ shadowColor?: ColorValue; - /** Shadow horizontal offset (iOS only). @default 0 */ - shadowOffsetX?: number; - /** Shadow vertical offset (iOS only). @default 0 */ - shadowOffsetY?: number; + /** Shadow offset (iOS only). @default { width: 0, height: 0 } */ + shadowOffset?: { width?: number; height?: number }; /** Android elevation for material shadow. @default 0 */ elevation?: number; }; From 2c01b0bd00ca7839d186a8a2041a80aaac4e8716 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 22:16:24 -0400 Subject: [PATCH 06/14] fix(example): add light surface behind shadow demo for visibility --- example/src/demos/ShadowDemo.tsx | 68 ++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/example/src/demos/ShadowDemo.tsx b/example/src/demos/ShadowDemo.tsx index b7978bf..7401621 100644 --- a/example/src/demos/ShadowDemo.tsx +++ b/example/src/demos/ShadowDemo.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Text, StyleSheet, Platform } from 'react-native'; +import { View, Text, StyleSheet, Platform } from 'react-native'; import { EaseView } from 'react-native-ease'; import { Section } from '../components/Section'; @@ -9,35 +9,37 @@ export function ShadowDemo() { const [active, setActive] = useState(false); return (
- - - {active - ? Platform.OS === 'android' - ? 'Elevated' - : 'Shadow' - : 'Flat'} - - + + + + {active + ? Platform.OS === 'android' + ? 'Elevated' + : 'Shadow' + : 'Flat'} + + +
+ ); +} + +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/index.ts b/example/src/demos/index.ts index 013cf20..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'; @@ -112,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: { From fc0ea4c6b468da1726179a97b35eaaee09b75c7d Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 22:32:14 -0400 Subject: [PATCH 13/14] fix(android): sync animated borderRadius to BorderDrawable When borderRadius is animated via ObjectAnimator, the BorderDrawable was not updated, causing borders to render with square corners. Now setAnimateBorderRadius also calls BackgroundStyleApplicator.setBorderRadius to keep the border drawable in sync with the animated corner radius. --- android/src/main/java/com/ease/EaseView.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index 8285d9b..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 @@ -164,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)) } } From 49e2b6eb5136c38189c9b50232f84ad842d34f56 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 13 Apr 2026 22:42:36 -0400 Subject: [PATCH 14/14] chore: remove stray react-native-animation-performance.md --- react-native-animation-performance.md | 128 -------------------------- 1 file changed, 128 deletions(-) delete mode 100644 react-native-animation-performance.md diff --git a/react-native-animation-performance.md b/react-native-animation-performance.md deleted file mode 100644 index 7af7b08..0000000 --- a/react-native-animation-performance.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -slug: react-native-animation-performance -title: React Native Animation Performance — How Different Libraries Compare -authors: [appandflow] -tags: [react-native, performance, animation, benchmarks] ---- - -At [App & Flow](https://appandflow.com), we build React Native apps and tools for product teams that need fluid, native-feeling UIs. One thing that stuck with us: a slowly translating background image that would occasionally stutter. The culprit is that Reanimated still runs on the UI thread every frame — so if your app does any significant work during that frame (layout, re-renders, other updates), the animation budget shrinks and the background stutters. - -Core Animation, by contrast, hands animations off to the OS render server and never touches your thread again. So we built [react-native-ease](https://github.com/AppAndFlow/react-native-ease) — a declarative animation library that drives everything through platform APIs (Core Animation on iOS, ObjectAnimator on Android) with no JS loop, no worklets, and no shadow tree commits per frame. But that raised a question we needed to answer honestly: **how much does the choice of animation library actually matter?** - -So we measured it. Across four approaches, two platforms, release builds, and both high-end and mid-range devices, we tracked per-frame UI thread overhead. This post shares what we found, and tries to answer the questions that actually matter: how large is the frame penalty? In what kinds of apps does it matter? And what should you prioritize when choosing an animation library? - - - -## The Approaches - -We compared four animation approaches: - -- **Ease** — react-native-ease, using platform APIs directly. Animations are fully described on the JS side via props, then driven natively without any per-frame JS involvement. -- **Reanimated (Shared Values)** — the standard worklet-based approach. Animation values are driven on the UI thread via a C++ worklet runtime, but each frame still updates props through the shadow tree. -- **Reanimated (CSS Animations)** — Reanimated's newer CSS animation API. Declarative like Ease, but still backed by Reanimated's animation engine. -- **RN Animated** — React Native's built-in `Animated` API with `useNativeDriver: true`. Animation values are driven on the native side, but the implementation depends on the platform. - -We also tested Reanimated with its static feature flags enabled — specifically `ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS` and `IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS`, which allow Reanimated to skip the shadow tree commit when only non-layout props (like `transform` and `opacity`) are updated. This is a meaningful optimization worth calling out separately. - -> **Note:** RN 0.85 introduced a new Shared Animation Backend that will eventually make the feature flags unnecessary by handling shadow tree bypass at a lower level. Reanimated's integration is in progress but not yet released, so it isn't included here. See [What's Coming](#whats-coming-rn-085s-new-animation-backend). - -## How We Measured - -We built a benchmark screen into the example app that animates N views simultaneously in a loop (translateX, 2s, linear, repeating). We used a custom Expo native module to measure per-frame overhead: - -- **iOS:** We swizzle `CADisplayLink`'s factory method to intercept all display link callbacks registered by any framework. We measure wall-clock time spent in each callback, aggregated per frame by timestamp. -- **Android:** We use `Window.OnFrameMetricsAvailableListener`, which reports `ANIMATION_DURATION`, `LAYOUT_MEASURE_DURATION`, and `DRAW_DURATION` from the platform's frame metrics system. - -We ran 5-second collection windows in release builds. All benchmarks reported here are from release builds — debug adds significant overhead from Hermes parsing JS instead of precompiled AOT bytecode, and the shadow tree running extra validation. A Reanimated animation that takes 3ms per frame in release can easily take 10ms+ in debug. - -## The Results - -### Android (UI thread time per frame: anim + layout + draw, ms) - -| Views | Approach | Avg | P95 | P99 | -|-------|----------|-----|-----|-----| -| 10 | Ease | 0.21 | 0.33 | 0.48 | -| 10 | Reanimated SV | 1.15 | 1.70 | 1.94 | -| 10 | Reanimated SV (FF) | 0.75 | 1.53 | 2.26 | -| 10 | Reanimated CSS | 0.99 | 1.44 | 1.62 | -| 10 | Reanimated CSS (FF) | 0.45 | 0.80 | 1.35 | -| 10 | RN Animated | 0.36 | 0.62 | 0.98 | -| 100 | Ease | 0.36 | 0.56 | 0.71 | -| 100 | Reanimated SV | 2.71 | 3.09 | 3.20 | -| 100 | Reanimated SV (FF) | 1.81 | 2.29 | 2.63 | -| 100 | Reanimated CSS | 2.19 | 2.67 | 2.97 | -| 100 | Reanimated CSS (FF) | 1.01 | 1.91 | 2.25 | -| 100 | RN Animated | 0.71 | 1.08 | 1.36 | -| 500 | Ease | 0.60 | 0.75 | 0.87 | -| 500 | Reanimated SV | 8.31 | 9.26 | 9.59 | -| 500 | Reanimated SV (FF) | 5.37 | 6.36 | 6.89 | -| 500 | Reanimated CSS | 5.50 | 6.34 | 6.88 | -| 500 | Reanimated CSS (FF) | 2.37 | 2.86 | 3.22 | -| 500 | RN Animated | 1.60 | 1.88 | 3.84 | - -### iOS (Display link callback time per frame, ms) - -| Views | Approach | Avg | P95 | P99 | -|-------|----------|-----|-----|-----| -| 10 | Ease | 0.01 | 0.02 | 0.03 | -| 10 | Reanimated SV | 1.33 | 1.67 | 1.90 | -| 10 | Reanimated SV (FF) | 1.08 | 1.59 | 1.68 | -| 10 | Reanimated CSS | 1.06 | 1.34 | 1.50 | -| 10 | Reanimated CSS (FF) | 0.63 | 1.01 | 1.08 | -| 10 | RN Animated | 0.83 | 1.18 | 1.31 | -| 100 | Ease | 0.01 | 0.01 | 0.02 | -| 100 | Reanimated SV | 3.72 | 5.21 | 5.68 | -| 100 | Reanimated SV (FF) | 3.33 | 4.50 | 4.75 | -| 100 | Reanimated CSS | 2.71 | 3.83 | 4.91 | -| 100 | Reanimated CSS (FF) | 2.48 | 3.39 | 3.79 | -| 100 | RN Animated | 3.32 | 4.28 | 4.55 | -| 500 | Ease | 0.01 | 0.01 | 0.02 | -| 500 | Reanimated SV | 6.84 | 7.69 | 8.10 | -| 500 | Reanimated SV (FF) | 6.54 | 7.32 | 7.45 | -| 500 | Reanimated CSS | 4.16 | 4.59 | 4.71 | -| 500 | Reanimated CSS (FF) | 3.70 | 4.22 | 4.33 | -| 500 | RN Animated | 4.91 | 5.66 | 5.89 | - -## Why the Differences Exist - -### The shadow tree tax - -Every frame, Reanimated's worklet computes new values and applies them by committing a prop update through the shadow tree. That commit traverses the shadow tree — Yoga layout, prop diffing, view mutations. When you animate `transform` or `opacity` (properties that don't affect layout), this work is entirely wasted. - -Reanimated's feature flags (`ANDROID/IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS`) bypass this by updating visual props directly on the UI layer without a shadow tree commit. On Android with 100 views, enabling these flags cuts Reanimated SV overhead from 2.71ms to 1.81ms — about 33% improvement. CSS animations with FF drops from 2.19ms to 1.01ms, more than halving it. The gains are consistent across all view counts. - -### Why Ease shows near-zero on iOS - -Ease registers a `CAAnimation` on each view's layer and walks away. Core Animation runs in a dedicated render server process that Apple runs separately from your app process entirely — it's not even on your main thread. That's why Ease measures ~0.01ms on iOS: there is genuinely almost no main-thread work per frame. - -On Android, ObjectAnimator runs on the UI thread, but the overhead is minimal because there's no shadow tree involvement and no worklet runtime — just direct property updates. - -### RN Animated - -`RN Animated` with `useNativeDriver: true` also avoids the JS thread per frame, but it still goes through a different native animation pipeline that carries some overhead. It performs surprisingly well at low-to-mid view counts on Android, but starts to lag at higher counts. - -## What Does This Mean in Practice? - -At 10 views, all approaches are comfortably within one frame budget (16.67ms at 60 fps). The differences — fractions of a millisecond — are real but not meaningful on their own. - -At 100 views, Reanimated is spending 2–4ms per frame on animation overhead alone, leaving less headroom for the rest of your frame work (layout, rendering, your custom code). On a mid-range device that's already close to budget, this starts to matter. - -At 500 views, Reanimated SV without feature flags approaches the entire frame budget on both platforms. This is an extreme scenario, but it illustrates that the overhead is not constant — it scales linearly with the number of animated views. - -**The practical answer:** it matters most when animations are long-running or slow — like skeleton loaders, background parallax, or ambient UI effects — where a single dropped frame is immediately noticeable and other app work (data fetching, rendering, user interaction) is likely happening at the same time. It also matters for anything in a list, where you can easily have hundreds of animated items simultaneously. And on very low-end devices, where even small per-frame overhead can push a frame over budget, the choice of library becomes the difference between smooth and janky. For short one-shot transitions — a button press feedback, a toast appearing, a modal sliding in — the overhead is negligible and any library works fine. - -It's also worth noting that Ease only covers this specific use case. Gesture-driven animations (scroll-linked, drag, swipe) and animations that affect layout properties (width, height, padding) still require Reanimated or RN Animated. Ease is purpose-built for declarative, trigger-based animations on visual properties. - -## What's Coming: RN 0.85's New Animation Backend - -React Native 0.85 (released April 7, 2025) ships an experimental "Shared Animation Backend" — a unified animation engine built directly into the renderer. Notably, this was a joint project: Software Mansion engineers (the Reanimated team) co-authored much of the RN core implementation alongside Meta, and the feature is explicitly designed to power both Animated and Reanimated going forward. - -The Reanimated integration is [already in progress](https://github.com/software-mansion/react-native-reanimated/pull/8875) — Software Mansion co-built the RN side and has an open PR to adopt it. Once shipped, Reanimated won't need workarounds like `SYNCHRONOUSLY_UPDATE_UI_PROPS` — shadow tree bypass will be built in by default. Expect its performance to land closer to what we measured with all feature flags enabled, or RN Animated. This matters because the feature flags aren't always safe to enable today — they can cause visual bugs depending on your app, so many projects leave them off. - -That said, the architectural difference remains. Ease has no per-frame animation engine at all — the platform drives everything outside the JS layer entirely. Even with a faster backend, Reanimated still computes values and applies prop updates every frame via its worklet runtime. That overhead doesn't disappear, it just gets more efficient. We'll re-run benchmarks once the Reanimated integration ships. - -## Reproduce It Yourself - -The benchmark is built into the example app. Clone the repo, run `yarn example ios` or `yarn example android`, and tap **Benchmark** from the demo screen. The source is in `example/src/demos/BenchmarkDemo.tsx` and the native measurement module is in `example/modules/frame-metrics/`. - -Run release builds (`yarn example ios --configuration Release` / `yarn example android --variant release`) for comparable numbers.