From d0fcd43f1a768a64f8b6a45ec5582383340fefcf Mon Sep 17 00:00:00 2001 From: Genki Kondo Date: Fri, 31 Mar 2023 10:16:36 -0700 Subject: [PATCH] Introduce AnimatedObject JS node (#36688) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/36688 AnimatedObject is a more generic version of AnimatedTransform, able to handle animated values within arrays and objects. This is useful for props of native components that may need to be animated per field. I considered flattening the node graph by removing AnimatedStyle and AnimatedTransform. However, this would add significant complexity in AnimatedProps because prop and style values depend on being submitted together on an animation tick (such as transform) using native driver; also, we'll have to special case style anyway. Changelog: [Internal][Added] - Introduce AnimatedObject JS node for handling array and object prop values Reviewed By: rshest Differential Revision: D44279594 fbshipit-source-id: 9504d841dc9196e51d09a0247601de4d4f991a49 --- .../Animated/__tests__/AnimatedObject-test.js | 66 +++++++++ .../Animated/nodes/AnimatedObject.js | 137 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedObject-test.js create mode 100644 packages/react-native/Libraries/Animated/nodes/AnimatedObject.js 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', + ); + } +}