From ea90a76efef60df0f46d29228289f8fc1d26f350 Mon Sep 17 00:00:00 2001 From: Genki Kondo Date: Thu, 27 Jan 2022 13:36:37 -0800 Subject: [PATCH] Initial non-native implementation of AnimatedColor Summary: Creates Animated.Color for animating color props. Implement AnimatedColor, which basically consists of 4 AnimatedValues (along the same vein as ValueXY) which allows us to just use AnimatedValue's interpolation. Provides a string color value of shape 'rgba(r, g, b, a)' AnimationNode DAG looks like: {F696076974} Followup changes will include support for string color values, platform colors, and native driver. Changelog: [General][Added] - New Animated.Color node Reviewed By: mdvacca Differential Revision: D33778456 fbshipit-source-id: 83ddbc955156bf589c864f229a5f83fe6875fd0e --- Libraries/Animated/AnimatedImplementation.js | 46 +++- Libraries/Animated/AnimatedMock.js | 9 +- Libraries/Animated/__tests__/Animated-test.js | 80 +++++++ .../Animated/animations/SpringAnimation.js | 10 + .../Animated/animations/TimingAnimation.js | 10 + Libraries/Animated/nodes/AnimatedColor.js | 206 ++++++++++++++++++ 6 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 Libraries/Animated/nodes/AnimatedColor.js diff --git a/Libraries/Animated/AnimatedImplementation.js b/Libraries/Animated/AnimatedImplementation.js index 148055b81b12f0..e93c0dda4ccfc5 100644 --- a/Libraries/Animated/AnimatedImplementation.js +++ b/Libraries/Animated/AnimatedImplementation.js @@ -38,6 +38,8 @@ import type {DecayAnimationConfig} from './animations/DecayAnimation'; import type {SpringAnimationConfig} from './animations/SpringAnimation'; import type {Mapping, EventConfig} from './AnimatedEvent'; +import AnimatedColor from './nodes/AnimatedColor'; + export type CompositeAnimation = { start: (callback?: ?EndCallback) => void, stop: () => void, @@ -102,7 +104,7 @@ const _combineCallbacks = function ( }; const maybeVectorAnim = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: Object, anim: (value: AnimatedValue, config: Object) => CompositeAnimation, ): ?CompositeAnimation { @@ -121,16 +123,42 @@ const maybeVectorAnim = function ( // We use `stopTogether: false` here because otherwise tracking will break // because the second animation will get stopped before it can update. return parallel([aX, aY], {stopTogether: false}); + } else if (value instanceof AnimatedColor) { + const configR = {...config}; + const configG = {...config}; + const configB = {...config}; + const configA = {...config}; + for (const key in config) { + const {r, g, b, a} = config[key]; + if ( + r !== undefined && + g !== undefined && + b !== undefined && + a !== undefined + ) { + configR[key] = r; + configG[key] = g; + configB[key] = b; + configA[key] = a; + } + } + const aR = anim((value: AnimatedColor).r, configR); + const aG = anim((value: AnimatedColor).g, configG); + const aB = anim((value: AnimatedColor).b, configB); + const aA = anim((value: AnimatedColor).a, configA); + // We use `stopTogether: false` here because otherwise tracking will break + // because the second animation will get stopped before it can update. + return parallel([aR, aG, aB, aA], {stopTogether: false}); } return null; }; const spring = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: SpringAnimationConfig, ): CompositeAnimation { const start = function ( - animatedValue: AnimatedValue | AnimatedValueXY, + animatedValue: AnimatedValue | AnimatedValueXY | AnimatedColor, configuration: SpringAnimationConfig, callback?: ?EndCallback, ): void { @@ -179,11 +207,11 @@ const spring = function ( }; const timing = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: TimingAnimationConfig, ): CompositeAnimation { const start = function ( - animatedValue: AnimatedValue | AnimatedValueXY, + animatedValue: AnimatedValue | AnimatedValueXY | AnimatedColor, configuration: TimingAnimationConfig, callback?: ?EndCallback, ): void { @@ -233,11 +261,11 @@ const timing = function ( }; const decay = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: DecayAnimationConfig, ): CompositeAnimation { const start = function ( - animatedValue: AnimatedValue | AnimatedValueXY, + animatedValue: AnimatedValue | AnimatedValueXY | AnimatedColor, configuration: DecayAnimationConfig, callback?: ?EndCallback, ): void { @@ -547,6 +575,10 @@ module.exports = { * See https://reactnative.dev/docs/animatedvaluexy */ ValueXY: AnimatedValueXY, + /** + * Value class for driving color animations. + */ + Color: AnimatedColor, /** * Exported to use the Interpolation type in flow. * diff --git a/Libraries/Animated/AnimatedMock.js b/Libraries/Animated/AnimatedMock.js index 056620f4912895..f2ce29359c2a66 100644 --- a/Libraries/Animated/AnimatedMock.js +++ b/Libraries/Animated/AnimatedMock.js @@ -24,6 +24,8 @@ import type {TimingAnimationConfig} from './animations/TimingAnimation'; import type {DecayAnimationConfig} from './animations/DecayAnimation'; import type {SpringAnimationConfig} from './animations/SpringAnimation'; +import AnimatedColor from './nodes/AnimatedColor'; + /** * Animations are a source of flakiness in snapshot testing. This mock replaces * animation functions from AnimatedImplementation with empty animations for @@ -89,7 +91,7 @@ const mockCompositeAnimation = ( }); const spring = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: SpringAnimationConfig, ): CompositeAnimation { const anyValue: any = value; @@ -103,7 +105,7 @@ const spring = function ( }; const timing = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: TimingAnimationConfig, ): CompositeAnimation { const anyValue: any = value; @@ -117,7 +119,7 @@ const timing = function ( }; const decay = function ( - value: AnimatedValue | AnimatedValueXY, + value: AnimatedValue | AnimatedValueXY | AnimatedColor, config: DecayAnimationConfig, ): CompositeAnimation { return emptyAnimation; @@ -164,6 +166,7 @@ const loop = function ( module.exports = { Value: AnimatedValue, ValueXY: AnimatedValueXY, + Color: AnimatedColor, Interpolation: AnimatedInterpolation, Node: AnimatedNode, decay, diff --git a/Libraries/Animated/__tests__/Animated-test.js b/Libraries/Animated/__tests__/Animated-test.js index 7a91b85ece58d8..717b1626c10a4f 100644 --- a/Libraries/Animated/__tests__/Animated-test.js +++ b/Libraries/Animated/__tests__/Animated-test.js @@ -972,4 +972,84 @@ describe('Animated tests', () => { } }); }); + + describe('Animated Colors', () => { + it('should animate colors', () => { + const color = new Animated.Color({r: 255, g: 0, b: 0, a: 1.0}); + const callback = jest.fn(); + const node = new AnimatedProps( + { + style: { + backgroundColor: color, + transform: [ + { + scale: color.a.interpolate({ + inputRange: [0, 1], + outputRange: [1, 2], + }), + }, + ], + }, + }, + callback, + ); + + expect(node.__getValue()).toEqual({ + style: { + backgroundColor: 'rgba(255, 0, 0, 1)', + transform: [{scale: 2}], + }, + }); + + node.__attach(); + expect(callback.mock.calls.length).toBe(0); + + color.setValue({r: 11, g: 22, b: 33, a: 0.5}); + expect(callback.mock.calls.length).toBe(4); + expect(node.__getValue()).toEqual({ + style: { + backgroundColor: 'rgba(11, 22, 33, 0.5)', + transform: [{scale: 1.5}], + }, + }); + + node.__detach(); + color.setValue({r: 255, g: 0, b: 0, a: 1.0}); + expect(callback.mock.calls.length).toBe(4); + }); + + it('should track colors', () => { + const color1 = new Animated.Color(); + const color2 = new Animated.Color(); + Animated.timing(color2, { + toValue: color1, + duration: 0, + useNativeDriver: false, + }).start(); + color1.setValue({r: 11, g: 22, b: 33, a: 0.5}); + expect(color2.__getValue()).toEqual('rgba(11, 22, 33, 0.5)'); + + // Make sure tracking keeps working (see stopTogether in ParallelConfig used + // by maybeVectorAnim). + color1.setValue({r: 255, g: 0, b: 0, a: 1.0}); + expect(color2.__getValue()).toEqual('rgba(255, 0, 0, 1)'); + }); + + it('should track with springs', () => { + const color1 = new Animated.Color(); + const color2 = new Animated.Color(); + Animated.spring(color2, { + toValue: color1, + tension: 3000, // faster spring for faster test + friction: 60, + useNativeDriver: false, + }).start(); + color1.setValue({r: 11, g: 22, b: 33, a: 0.5}); + jest.runAllTimers(); + expect(color2.__getValue()).toEqual('rgba(11, 22, 33, 0.5)'); + color1.setValue({r: 44, g: 55, b: 66, a: 0.0}); + jest.runAllTimers(); + expect(color2.__getValue()).toEqual('rgba(44, 55, 66, 0)'); + }); + }); }); diff --git a/Libraries/Animated/animations/SpringAnimation.js b/Libraries/Animated/animations/SpringAnimation.js index a2622e863927bd..471fe50216cd50 100644 --- a/Libraries/Animated/animations/SpringAnimation.js +++ b/Libraries/Animated/animations/SpringAnimation.js @@ -23,6 +23,8 @@ const {shouldUseNativeDriver} = require('../NativeAnimatedHelper'); import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type {AnimationConfig, EndCallback} from './Animation'; +import AnimatedColor from '../nodes/AnimatedColor'; + export type SpringAnimationConfig = { ...AnimationConfig, toValue: @@ -34,6 +36,14 @@ export type SpringAnimationConfig = { ... } | AnimatedValueXY + | { + r: number, + g: number, + b: number, + a: number, + ... + } + | AnimatedColor | AnimatedInterpolation, overshootClamping?: boolean, restDisplacementThreshold?: number, diff --git a/Libraries/Animated/animations/TimingAnimation.js b/Libraries/Animated/animations/TimingAnimation.js index c6dcf71a8186a2..3ed0e0f128825a 100644 --- a/Libraries/Animated/animations/TimingAnimation.js +++ b/Libraries/Animated/animations/TimingAnimation.js @@ -20,6 +20,8 @@ const {shouldUseNativeDriver} = require('../NativeAnimatedHelper'); import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type {AnimationConfig, EndCallback} from './Animation'; +import AnimatedColor from '../nodes/AnimatedColor'; + export type TimingAnimationConfig = { ...AnimationConfig, toValue: @@ -31,6 +33,14 @@ export type TimingAnimationConfig = { ... } | AnimatedValueXY + | { + r: number, + g: number, + b: number, + a: number, + ... + } + | AnimatedColor | AnimatedInterpolation, easing?: (value: number) => number, duration?: number, diff --git a/Libraries/Animated/nodes/AnimatedColor.js b/Libraries/Animated/nodes/AnimatedColor.js new file mode 100644 index 00000000000000..7a320911da3a41 --- /dev/null +++ b/Libraries/Animated/nodes/AnimatedColor.js @@ -0,0 +1,206 @@ +/** + * 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 + */ + +'use strict'; + +import AnimatedValue from './AnimatedValue'; +import AnimatedWithChildren from './AnimatedWithChildren'; +import invariant from 'invariant'; + +type ColorListenerCallback = (value: string) => mixed; + +let _uniqueId = 1; + +export default class AnimatedColor extends AnimatedWithChildren { + r: AnimatedValue; + g: AnimatedValue; + b: AnimatedValue; + a: AnimatedValue; + _listeners: { + [key: string]: { + r: string, + g: string, + b: string, + a: string, + ... + }, + ... + }; + + constructor( + valueIn?: ?{ + +r: number | AnimatedValue, + +g: number | AnimatedValue, + +b: number | AnimatedValue, + +a: number | AnimatedValue, + ... + }, // TODO: support string color and platform color + ) { + super(); + const value: any = valueIn || {r: 0, g: 0, b: 0, a: 1}; // @flowfixme: shouldn't need `: any` + if ( + typeof value.r === 'number' && + typeof value.g === 'number' && + typeof value.b === 'number' && + typeof value.a === 'number' + ) { + this.r = new AnimatedValue(value.r); + this.g = new AnimatedValue(value.g); + this.b = new AnimatedValue(value.b); + this.a = new AnimatedValue(value.a); + } else { + invariant( + value.r instanceof AnimatedValue && + value.g instanceof AnimatedValue && + value.b instanceof AnimatedValue && + value.a instanceof AnimatedValue, + 'AnimatedColor must be initialized with an object of numbers or AnimatedValues.', + ); + this.r = value.r; + this.g = value.g; + this.b = value.b; + this.a = value.a; + } + this._listeners = {}; + } + + /** + * Directly set the value. This will stop any animations running on the value + * and update all the bound properties. + */ + setValue(value: {r: number, g: number, b: number, a: number, ...}) { + this.r.setValue(value.r); + this.g.setValue(value.g); + this.b.setValue(value.b); + this.a.setValue(value.a); + } + + /** + * Sets an offset that is applied on top of whatever value is set, whether + * via `setValue`, an animation, or `Animated.event`. Useful for compensating + * things like the start of a pan gesture. + */ + setOffset(offset: {r: number, g: number, b: number, a: number, ...}) { + this.r.setOffset(offset.r); + this.g.setOffset(offset.g); + this.b.setOffset(offset.b); + this.a.setOffset(offset.a); + } + + /** + * Merges the offset value into the base value and resets the offset to zero. + * The final output of the value is unchanged. + */ + flattenOffset(): void { + this.r.flattenOffset(); + this.g.flattenOffset(); + this.b.flattenOffset(); + this.a.flattenOffset(); + } + + /** + * Sets the offset value to the base value, and resets the base value to + * zero. The final output of the value is unchanged. + */ + extractOffset(): void { + this.r.extractOffset(); + this.g.extractOffset(); + this.b.extractOffset(); + this.a.extractOffset(); + } + + /** + * Adds an asynchronous listener to the value so you can observe updates from + * animations. This is useful because there is no way to synchronously read + * the value because it might be driven natively. + * + * Returns a string that serves as an identifier for the listener. + */ + addListener(callback: ColorListenerCallback): string { + const id = String(_uniqueId++); + const jointCallback = ({value: number}) => { + callback(this.__getValue()); + }; + this._listeners[id] = { + r: this.r.addListener(jointCallback), + g: this.g.addListener(jointCallback), + b: this.b.addListener(jointCallback), + a: this.a.addListener(jointCallback), + }; + return id; + } + + /** + * Unregister a listener. The `id` param shall match the identifier + * previously returned by `addListener()`. + */ + removeListener(id: string): void { + this.r.removeListener(this._listeners[id].r); + this.g.removeListener(this._listeners[id].g); + this.b.removeListener(this._listeners[id].b); + this.a.removeListener(this._listeners[id].a); + delete this._listeners[id]; + } + + /** + * Remove all registered listeners. + */ + removeAllListeners(): void { + this.r.removeAllListeners(); + this.g.removeAllListeners(); + this.b.removeAllListeners(); + this.a.removeAllListeners(); + this._listeners = {}; + } + + /** + * Stops any running animation or tracking. `callback` is invoked with the + * final value after stopping the animation, which is useful for updating + * state to match the animation position with layout. + */ + stopAnimation(callback?: (value: string) => void): void { + this.r.stopAnimation(); + this.g.stopAnimation(); + this.b.stopAnimation(); + this.a.stopAnimation(); + callback && callback(this.__getValue()); + } + + /** + * Stops any animation and resets the value to its original. + */ + resetAnimation(callback?: (value: string) => void): void { + this.r.resetAnimation(); + this.g.resetAnimation(); + this.b.resetAnimation(); + this.a.resetAnimation(); + callback && callback(this.__getValue()); + } + + __getValue(): string { + return `rgba(${this.r.__getValue()}, ${this.g.__getValue()}, ${this.b.__getValue()}, ${this.a.__getValue()})`; + } + + __attach(): void { + this.r.__addChild(this); + this.g.__addChild(this); + this.b.__addChild(this); + this.a.__addChild(this); + super.__attach(); + } + + __detach(): void { + this.r.__removeChild(this); + this.g.__removeChild(this); + this.b.__removeChild(this); + this.a.__removeChild(this); + super.__detach(); + } +}