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,