diff --git a/apps/common-app/src/examples/RuntimeTests/ReJest/matchers/Comparators.ts b/apps/common-app/src/examples/RuntimeTests/ReJest/matchers/Comparators.ts
index 4060525a918..e5fd136d014 100644
--- a/apps/common-app/src/examples/RuntimeTests/ReJest/matchers/Comparators.ts
+++ b/apps/common-app/src/examples/RuntimeTests/ReJest/matchers/Comparators.ts
@@ -113,6 +113,7 @@ export function getComparisonModeForProp(prop: ValidPropNames): ComparisonMode {
top: ComparisonMode.PIXEL,
left: ComparisonMode.PIXEL,
backgroundColor: ComparisonMode.COLOR,
+ boxShadow: ComparisonMode.ARRAY,
};
return propToComparisonModeDict[prop];
}
diff --git a/apps/common-app/src/examples/RuntimeTests/ReJest/types.ts b/apps/common-app/src/examples/RuntimeTests/ReJest/types.ts
index 23600e5fc05..48069b6e27a 100644
--- a/apps/common-app/src/examples/RuntimeTests/ReJest/types.ts
+++ b/apps/common-app/src/examples/RuntimeTests/ReJest/types.ts
@@ -59,11 +59,19 @@ export type TestSuite = {
decorator?: DescribeDecorator | null;
};
-export type ValidPropNames = 'zIndex' | 'opacity' | 'width' | 'height' | 'top' | 'left' | 'backgroundColor';
+export type ValidPropNames =
+ | 'zIndex'
+ | 'opacity'
+ | 'width'
+ | 'height'
+ | 'top'
+ | 'left'
+ | 'backgroundColor'
+ | 'boxShadow';
export function isValidPropName(propName: string): propName is ValidPropNames {
'worklet';
- return ['zIndex', 'opacity', 'width', 'height', 'top', 'left', 'backgroundColor'].includes(propName);
+ return ['zIndex', 'opacity', 'width', 'height', 'top', 'left', 'backgroundColor', 'boxShadow'].includes(propName);
}
export enum ComparisonMode {
diff --git a/apps/common-app/src/examples/RuntimeTests/RuntimeTestsExample.tsx b/apps/common-app/src/examples/RuntimeTests/RuntimeTestsExample.tsx
index 6a460dbf4ef..c18c0cfca7b 100644
--- a/apps/common-app/src/examples/RuntimeTests/RuntimeTestsExample.tsx
+++ b/apps/common-app/src/examples/RuntimeTests/RuntimeTestsExample.tsx
@@ -53,6 +53,12 @@ export default function RuntimeTestsExample() {
require('./tests/core/useSharedValue/animationsCompilerApi.test');
},
},
+ {
+ testSuiteName: 'props',
+ importTest: () => {
+ require('./tests/props/boxShadow.test');
+ },
+ },
{
testSuiteName: 'utilities',
importTest: () => {
diff --git a/apps/common-app/src/examples/RuntimeTests/tests/props/boxShadow.test.tsx b/apps/common-app/src/examples/RuntimeTests/tests/props/boxShadow.test.tsx
new file mode 100644
index 00000000000..3d1c21a7bf3
--- /dev/null
+++ b/apps/common-app/src/examples/RuntimeTests/tests/props/boxShadow.test.tsx
@@ -0,0 +1,107 @@
+// TODO: Enable this test after RuntimeTests are implemented on Fabric
+
+import { useEffect } from 'react';
+import type { BoxShadowValue } from 'react-native';
+import type { AnimatableValue } from 'react-native-reanimated';
+import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes';
+import { ComparisonMode } from '../../ReJest/types';
+import { View, StyleSheet } from 'react-native';
+import Animated, { useSharedValue, withSpring, useAnimatedStyle } from 'react-native-reanimated';
+import { describe, test, expect, render, useTestRef, getTestComponent, wait } from '../../ReJest/RuntimeTestsApi';
+
+describe.skip('animation of BoxShadow', () => {
+ enum Component {
+ ACTIVE = 'ACTIVE',
+ PASSIVE = 'PASSIVE',
+ }
+ function BoxShadowComponent({
+ startBoxShadow,
+ finalBoxShadow,
+ }: {
+ startBoxShadow: BoxShadowValue;
+ finalBoxShadow: BoxShadowValue;
+ }) {
+ const boxShadowActiveSV = useSharedValue(startBoxShadow);
+ const boxShadowPassiveSV = useSharedValue(startBoxShadow);
+
+ const refActive = useTestRef('ACTIVE');
+ const refPassive = useTestRef('PASSIVE');
+
+ const styleActive = useAnimatedStyle(() => {
+ return {
+ boxShadow: [withSpring(boxShadowActiveSV.value as unknown as AnimatableValue, { duration: 700 })],
+ } as DefaultStyle;
+ });
+
+ const stylePassive = useAnimatedStyle(() => {
+ return {
+ boxShadow: [boxShadowPassiveSV.value],
+ } as DefaultStyle;
+ });
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ boxShadowActiveSV.value = finalBoxShadow;
+ boxShadowPassiveSV.value = finalBoxShadow;
+ }, 1000);
+
+ return () => clearTimeout(timeout);
+ }, [finalBoxShadow, boxShadowActiveSV, boxShadowPassiveSV]);
+
+ return (
+
+
+
+
+ );
+ }
+
+ test.each([
+ {
+ startBoxShadow: {
+ offsetX: -10,
+ offsetY: 6,
+ blurRadius: 7,
+ spreadDistance: 10,
+ color: 'rgba(245, 40, 145, 0.8)',
+ },
+
+ finalBoxShadow: {
+ offsetX: -20,
+ offsetY: 4,
+ blurRadius: 10,
+ spreadDistance: 20,
+ color: 'rgba(39, 185, 245, 0.8)',
+ },
+
+ description: 'one boxShadow',
+ },
+ ])(
+ '${description}, from ${startBoxShadow} to ${finalBoxShadow}',
+ async ({ startBoxShadow, finalBoxShadow }: { startBoxShadow: BoxShadowValue; finalBoxShadow: BoxShadowValue }) => {
+ await render();
+
+ const activeComponent = getTestComponent(Component.ACTIVE);
+ const passiveComponent = getTestComponent(Component.PASSIVE);
+
+ await wait(200);
+
+ expect(await activeComponent.getAnimatedStyle('boxShadow')).toBe([finalBoxShadow], ComparisonMode.ARRAY);
+ expect(await passiveComponent.getAnimatedStyle('boxShadow')).toBe([finalBoxShadow], ComparisonMode.ARRAY);
+ },
+ );
+});
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ animatedBox: {
+ backgroundColor: 'palevioletred',
+ width: 100,
+ height: 100,
+ margin: 30,
+ },
+});
diff --git a/packages/react-native-reanimated/__tests__/props.test.tsx b/packages/react-native-reanimated/__tests__/props.test.tsx
new file mode 100644
index 00000000000..e09e9111496
--- /dev/null
+++ b/packages/react-native-reanimated/__tests__/props.test.tsx
@@ -0,0 +1,158 @@
+import { View, Pressable, Text } from 'react-native';
+import type { ViewStyle } from 'react-native';
+import { render, fireEvent } from '@testing-library/react-native';
+import Animated, {
+ interpolate,
+ interpolateColor,
+ useSharedValue,
+ useAnimatedStyle,
+} from '../src';
+import { getAnimatedStyle } from '../src/jestUtils';
+import { processBoxShadow } from '../src/processBoxShadow';
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
+
+const AnimatedComponent = () => {
+ const pressed = useSharedValue(0);
+
+ const animatedBoxShadow = useAnimatedStyle(() => {
+ const blurRadius = interpolate(pressed.value, [0, 1], [10, 0]);
+ const color = interpolateColor(
+ pressed.value,
+ [0, 1],
+ ['rgba(255, 0, 0, 1)', 'rgba(0, 0, 255, 1)']
+ );
+
+ const boxShadow = `0px 4px ${blurRadius}px 0px ${color}`;
+
+ return {
+ boxShadow,
+ };
+ });
+
+ const handlePress = () => {
+ pressed.value = pressed.value === 0 ? 1 : 0;
+ };
+
+ return (
+
+
+ Button
+
+
+ );
+};
+
+const getDefaultStyle = () => ({
+ padding: 16,
+ backgroundColor: 'red',
+ boxShadow: '0px 4px 10px 0px rgba(255, 0, 0, 1)',
+});
+
+const getMultipleBoxShadowStyle = () => ({
+ padding: 16,
+ backgroundColor: 'red',
+ boxShadow:
+ '-10px 6px 8px 10px rgba(255, 0, 0, 1), 10px 0px 15px 6px rgba(0, 0, 255, 1)',
+});
+
+describe('Test of boxShadow prop', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ test('boxShadow prop animation', () => {
+ const style = getDefaultStyle();
+
+ const { getByTestId } = render();
+ const pressable = getByTestId('pressable');
+
+ expect(pressable.props.style.boxShadow).toBe(
+ '0px 4px 10px 0px rgba(255, 0, 0, 1)'
+ );
+ expect(pressable).toHaveAnimatedStyle(style);
+ fireEvent.press(pressable);
+ jest.advanceTimersByTime(600);
+ style.boxShadow = '0px 4px 0px 0px rgba(0, 0, 255, 1)';
+ expect(pressable).toHaveAnimatedStyle(style);
+ });
+
+ test('boxShadow prop animation, get animated style', () => {
+ const { getByTestId } = render();
+ const pressable = getByTestId('pressable');
+
+ fireEvent.press(pressable);
+ jest.advanceTimersByTime(600);
+
+ const style = getAnimatedStyle(pressable);
+ expect((style as ViewStyle).boxShadow).toBe(
+ '0px 4px 0px 0px rgba(0, 0, 255, 1)'
+ );
+ });
+ test('one boxShadow string parsing', () => {
+ const { getByTestId } = render();
+ const pressable = getByTestId('pressable');
+
+ expect(pressable.props.style.boxShadow).toBe(
+ '0px 4px 10px 0px rgba(255, 0, 0, 1)'
+ );
+
+ processBoxShadow(pressable.props.style);
+
+ expect(pressable.props.style.boxShadow).toEqual([
+ {
+ offsetX: 0,
+ offsetY: 4,
+ blurRadius: 10,
+ spreadDistance: 0,
+ color: 'rgba(255, 0, 0, 1)',
+ },
+ ]);
+
+ const style = getAnimatedStyle(pressable);
+ expect((style as ViewStyle).boxShadow).toBe(
+ '0px 4px 10px 0px rgba(255, 0, 0, 1)'
+ );
+ });
+
+ test('two boxShadows string parsing', () => {
+ const multipleBoxShadowStyle = getMultipleBoxShadowStyle();
+
+ processBoxShadow(multipleBoxShadowStyle);
+
+ expect(multipleBoxShadowStyle.boxShadow).toEqual([
+ {
+ offsetX: -10,
+ offsetY: 6,
+ blurRadius: 8,
+ spreadDistance: 10,
+ color: 'rgba(255, 0, 0, 1)',
+ },
+ {
+ offsetX: 10,
+ offsetY: 0,
+ blurRadius: 15,
+ spreadDistance: 6,
+ color: 'rgba(0, 0, 255, 1)',
+ },
+ ]);
+ });
+});
diff --git a/packages/react-native-reanimated/src/Colors.ts b/packages/react-native-reanimated/src/Colors.ts
index 40cd3ecf0ba..0d1fc617141 100644
--- a/packages/react-native-reanimated/src/Colors.ts
+++ b/packages/react-native-reanimated/src/Colors.ts
@@ -349,6 +349,10 @@ export const ColorProperties = makeShareable([
'stroke',
]);
+const NestedColorProperties = makeShareable({
+ boxShadow: 'color',
+});
+
// // ts-prune-ignore-next Exported for the purpose of tests only
export function normalizeColor(color: unknown): number | null {
'worklet';
@@ -675,6 +679,19 @@ export function processColorsInProps(props: StyleProps) {
for (const key in props) {
if (ColorProperties.includes(key)) {
props[key] = processColor(props[key]);
+ } else if (
+ NestedColorProperties[key as keyof typeof NestedColorProperties]
+ ) {
+ const propGroupList = props[key] as StyleProps[];
+ for (const propGroup of propGroupList) {
+ const nestedPropertyName =
+ NestedColorProperties[key as keyof typeof NestedColorProperties];
+ if (propGroup[nestedPropertyName] !== undefined) {
+ propGroup[nestedPropertyName] = processColor(
+ propGroup[nestedPropertyName]
+ );
+ }
+ }
}
}
}
diff --git a/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts b/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts
index 7f4f188f2df..b1a0817dcbd 100644
--- a/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts
+++ b/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts
@@ -34,6 +34,7 @@ import type {
AnimatedStyle,
} from '../commonTypes';
import { isWorkletFunction } from '../commonTypes';
+import { processBoxShadow } from '../processBoxShadow';
import { ReanimatedError } from '../errors';
const SHOULD_BE_USE_WEB = shouldBeUseWeb();
@@ -119,7 +120,8 @@ function runAnimations(
timestamp: Timestamp,
key: number | string,
result: AnimatedStyle,
- animationsActive: SharedValue
+ animationsActive: SharedValue,
+ forceCopyAnimation?: boolean
): boolean {
'worklet';
if (!animationsActive.value) {
@@ -128,9 +130,17 @@ function runAnimations(
if (Array.isArray(animation)) {
result[key] = [];
let allFinished = true;
+ forceCopyAnimation = key === 'boxShadow';
animation.forEach((entry, index) => {
if (
- !runAnimations(entry, timestamp, index, result[key], animationsActive)
+ !runAnimations(
+ entry,
+ timestamp,
+ index,
+ result[key],
+ animationsActive,
+ forceCopyAnimation
+ )
) {
allFinished = false;
}
@@ -150,7 +160,16 @@ function runAnimations(
animation.callback && animation.callback(true /* finished */);
}
}
- result[key] = animation.current;
+ /*
+ * If `animation.current` is a boxShadow object, spread its properties into a new object
+ * to avoid modifying the original reference. This ensures when `newValues` has a nested color prop, it stays unparsed
+ * in rgba format, allowing the animation to run correctly.
+ */
+ if (forceCopyAnimation) {
+ result[key] = { ...animation.current };
+ } else {
+ result[key] = animation.current;
+ }
return finished;
} else if (typeof animation === 'object') {
result[key] = {};
@@ -162,7 +181,8 @@ function runAnimations(
timestamp,
k,
result[key],
- animationsActive
+ animationsActive,
+ forceCopyAnimation
)
) {
allFinished = false;
@@ -191,6 +211,9 @@ function styleUpdater(
let hasAnimations = false;
let frameTimestamp: number | undefined;
let hasNonAnimatedValues = false;
+ if (typeof newValues.boxShadow === 'string') {
+ processBoxShadow(newValues);
+ }
for (const key in newValues) {
const value = newValues[key];
if (isAnimated(value)) {
@@ -226,7 +249,21 @@ function styleUpdater(
animationsActive
);
if (finished) {
- last[propName] = updates[propName];
+ /**
+ * If the animated prop is an array, we need to directly set each
+ * property (manually spread it). This prevents issues where the color
+ * prop might be incorrectly linked with its `toValue` and `current`
+ * states, causing abrupt transitions or 'jumps' in animation states.
+ */
+ if (Array.isArray(updates[propName])) {
+ updates[propName].forEach((obj: StyleProps) => {
+ for (const prop in obj) {
+ last[propName][prop] = obj[prop];
+ }
+ });
+ } else {
+ last[propName] = updates[propName];
+ }
delete animations[propName];
} else {
allFinished = false;
diff --git a/packages/react-native-reanimated/src/processBoxShadow.ts b/packages/react-native-reanimated/src/processBoxShadow.ts
new file mode 100644
index 00000000000..d4df74e606f
--- /dev/null
+++ b/packages/react-native-reanimated/src/processBoxShadow.ts
@@ -0,0 +1,194 @@
+/* based on:
+ * https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/StyleSheet/processBoxShadow.js
+ */
+'use strict';
+
+import type { BoxShadowValue, OpaqueColorValue } from 'react-native';
+import type { StyleProps } from '.';
+
+function parseBoxShadowString(rawBoxShadows: string): Array {
+ 'worklet';
+ const result: Array = [];
+
+ for (const rawBoxShadow of rawBoxShadows
+ .split(/,(?![^()]*\))/) // split by comma that is not in parenthesis
+ .map((bS) => bS.trim())
+ .filter((bS) => bS !== '')) {
+ const boxShadow: BoxShadowValue = {
+ offsetX: 0,
+ offsetY: 0,
+ };
+ let offsetX: number | string | null = null;
+ let offsetY: number | string | null = null;
+ let keywordDetectedAfterLength = false;
+
+ let lengthCount = 0;
+
+ // split rawBoxShadow string by all whitespaces that are not in parenthesis
+ const args = rawBoxShadow.split(/\s+(?![^(]*\))/);
+ for (const arg of args) {
+ if (arg === 'inset') {
+ if (boxShadow.inset != null) {
+ return [];
+ }
+ if (offsetX != null) {
+ keywordDetectedAfterLength = true;
+ }
+ boxShadow.inset = true;
+ continue;
+ }
+
+ switch (lengthCount) {
+ case 0:
+ offsetX = arg;
+ lengthCount++;
+ break;
+ case 1:
+ if (keywordDetectedAfterLength) {
+ return [];
+ }
+ offsetY = arg;
+ lengthCount++;
+ break;
+ case 2:
+ if (keywordDetectedAfterLength) {
+ return [];
+ }
+ boxShadow.blurRadius = arg;
+ lengthCount++;
+ break;
+ case 3:
+ if (keywordDetectedAfterLength) {
+ return [];
+ }
+ boxShadow.spreadDistance = arg;
+ lengthCount++;
+ break;
+ case 4:
+ if (keywordDetectedAfterLength) {
+ return [];
+ }
+ boxShadow.color = arg;
+ lengthCount++;
+ break;
+ default:
+ return [];
+ }
+ }
+
+ if (offsetX === null || offsetY === null) {
+ return [];
+ }
+
+ boxShadow.offsetX = offsetX;
+ boxShadow.offsetY = offsetY;
+
+ result.push(boxShadow);
+ }
+ return result;
+}
+
+function parseLength(length: string): number | null {
+ 'worklet';
+ // matches on args with units like "1.5 5% -80deg"
+ const argsWithUnitsRegex = /([+-]?\d*(\.\d+)?)([\w\W]+)?/g;
+ const match = argsWithUnitsRegex.exec(length);
+
+ if (!match || Number.isNaN(match[1])) {
+ return null;
+ }
+
+ if (match[3] != null && match[3] !== 'px') {
+ return null;
+ }
+
+ return Number(match[1]);
+}
+
+type ParsedBoxShadow = {
+ offsetX: number;
+ offsetY: number;
+ blurRadius?: number | OpaqueColorValue;
+ spreadDistance?: number;
+ inset?: boolean;
+ color?: string;
+};
+
+export function processBoxShadow(props: StyleProps) {
+ 'worklet';
+ const result: Array = [];
+
+ const rawBoxShadows = props.boxShadow;
+
+ if (rawBoxShadows === '') {
+ return result;
+ }
+
+ const boxShadowList = parseBoxShadowString(
+ (rawBoxShadows as string).replace(/\n/g, ' ')
+ );
+
+ for (const rawBoxShadow of boxShadowList) {
+ const parsedBoxShadow: ParsedBoxShadow = {
+ offsetX: 0,
+ offsetY: 0,
+ };
+
+ let value;
+ for (const arg in rawBoxShadow) {
+ switch (arg) {
+ case 'offsetX':
+ value =
+ typeof rawBoxShadow.offsetX === 'string'
+ ? parseLength(rawBoxShadow.offsetX)
+ : rawBoxShadow.offsetX;
+ if (value === null) {
+ return [];
+ }
+
+ parsedBoxShadow.offsetX = value;
+ break;
+ case 'offsetY':
+ value =
+ typeof rawBoxShadow.offsetY === 'string'
+ ? parseLength(rawBoxShadow.offsetY)
+ : rawBoxShadow.offsetY;
+ if (value === null) {
+ return [];
+ }
+
+ parsedBoxShadow.offsetY = value;
+ break;
+ case 'spreadDistance':
+ value =
+ typeof rawBoxShadow.spreadDistance === 'string'
+ ? parseLength(rawBoxShadow.spreadDistance)
+ : rawBoxShadow.spreadDistance;
+ if (value === null) {
+ return [];
+ }
+
+ parsedBoxShadow.spreadDistance = value;
+ break;
+ case 'blurRadius':
+ value =
+ typeof rawBoxShadow.blurRadius === 'string'
+ ? parseLength(rawBoxShadow.blurRadius)
+ : (rawBoxShadow.blurRadius as number);
+ if (value === null || value < 0) {
+ return [];
+ }
+
+ parsedBoxShadow.blurRadius = value;
+ break;
+ case 'color':
+ parsedBoxShadow.color = rawBoxShadow.color;
+ break;
+ case 'inset':
+ parsedBoxShadow.inset = rawBoxShadow.inset;
+ }
+ }
+ result.push(parsedBoxShadow);
+ }
+ props.boxShadow = result;
+}
diff --git a/packages/react-native-reanimated/src/propsAllowlists.ts b/packages/react-native-reanimated/src/propsAllowlists.ts
index 39c77a1920d..9aaeb8decdd 100644
--- a/packages/react-native-reanimated/src/propsAllowlists.ts
+++ b/packages/react-native-reanimated/src/propsAllowlists.ts
@@ -40,6 +40,7 @@ export const PropsAllowlists: AllowlistsHolder = {
borderTopWidth: true,
borderWidth: true,
bottom: true,
+ boxShadow: true,
flex: true,
flexGrow: true,
flexShrink: true,