diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedObject-test.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedObject-test.js new file mode 100644 index 00000000000..8447d8453af --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedObject-test.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import Animated from '../Animated'; +import AnimatedObject, {hasAnimatedNode} from '../nodes/AnimatedObject'; + +describe('AnimatedObject', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should get the proper value', () => { + const anim = new Animated.Value(0); + const translateAnim = anim.interpolate({ + inputRange: [0, 1], + outputRange: [100, 200], + }); + + const node = new AnimatedObject([ + { + translate: [translateAnim, translateAnim], + }, + { + translateX: translateAnim, + }, + {scale: anim}, + ]); + + expect(node.__getValue()).toEqual([ + {translate: [100, 100]}, + {translateX: 100}, + {scale: 0}, + ]); + }); + + describe('hasAnimatedNode', () => { + it('should detect any animated nodes', () => { + expect(hasAnimatedNode(10)).toBe(false); + + const anim = new Animated.Value(0); + expect(hasAnimatedNode(anim)).toBe(true); + + const event = Animated.event([{}], {useNativeDriver: true}); + expect(hasAnimatedNode(event)).toBe(false); + + expect(hasAnimatedNode([10, 10])).toBe(false); + expect(hasAnimatedNode([10, anim])).toBe(true); + + expect(hasAnimatedNode({a: 10, b: 10})).toBe(false); + expect(hasAnimatedNode({a: 10, b: anim})).toBe(true); + + expect(hasAnimatedNode({a: 10, b: {ba: 10, bb: 10}})).toBe(false); + expect(hasAnimatedNode({a: 10, b: {ba: 10, bb: anim}})).toBe(true); + expect(hasAnimatedNode({a: 10, b: [10, 10]})).toBe(false); + expect(hasAnimatedNode({a: 10, b: [10, anim]})).toBe(true); + }); + }); +}); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js b/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js new file mode 100644 index 00000000000..1cd82b8473d --- /dev/null +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +'use strict'; + +import type {PlatformConfig} from '../AnimatedPlatformConfig'; + +import AnimatedNode from './AnimatedNode'; +import AnimatedWithChildren from './AnimatedWithChildren'; + +const MAX_DEPTH = 5; + +function isPlainObject(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + Object.getPrototypeOf(value).isPrototypeOf(Object) + ); +} + +// Recurse through values, executing fn for any AnimatedNodes +function visit(value: any, fn: any => void, depth: number = 0): void { + if (depth >= MAX_DEPTH) { + return; + } + + if (value instanceof AnimatedNode) { + fn(value); + } else if (Array.isArray(value)) { + value.forEach(element => { + visit(element, fn, depth + 1); + }); + } else if (isPlainObject(value)) { + Object.values(value).forEach(element => { + visit(element, fn, depth + 1); + }); + } +} + +// Returns a copy of value with a transformation fn applied to any AnimatedNodes +function mapAnimatedNodes(value: any, fn: any => any, depth: number = 0): any { + if (depth >= MAX_DEPTH) { + return value; + } + + if (value instanceof AnimatedNode) { + return fn(value); + } else if (Array.isArray(value)) { + return value.map(element => mapAnimatedNodes(element, fn, depth + 1)); + } else if (isPlainObject(value)) { + const result: {[string]: any} = {}; + for (const key in value) { + result[key] = mapAnimatedNodes(value[key], fn, depth + 1); + } + return result; + } else { + return value; + } +} + +export function hasAnimatedNode(value: any, depth: number = 0): boolean { + if (depth >= MAX_DEPTH) { + return false; + } + + if (value instanceof AnimatedNode) { + return true; + } else if (Array.isArray(value)) { + for (const element of value) { + if (hasAnimatedNode(element, depth + 1)) { + return true; + } + } + } else if (isPlainObject(value)) { + for (const key in value) { + if (hasAnimatedNode(value[key], depth + 1)) { + return true; + } + } + } + return false; +} + +export default class AnimatedObject extends AnimatedWithChildren { + _value: any; + + constructor(value: any) { + super(); + this._value = value; + } + + __getValue(): any { + return mapAnimatedNodes(this._value, node => { + return node.__getValue(); + }); + } + + __getAnimatedValue(): any { + return mapAnimatedNodes(this._value, node => { + return node.__getAnimatedValue(); + }); + } + + __attach(): void { + super.__attach(); + visit(this._value, node => { + node.__addChild(this); + }); + } + + __detach(): void { + visit(this._value, node => { + node.__removeChild(this); + }); + super.__detach(); + } + + __makeNative(platformConfig: ?PlatformConfig): void { + throw new Error( + 'This JS animated node type cannot be used as native animated node', + ); + } + + __getNativeConfig(): any { + throw new Error( + 'This JS animated node type cannot be used as native animated node', + ); + } +}