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
This commit is contained in:
Genki Kondo
2022-01-27 13:37:59 -08:00
committed by Facebook GitHub Bot
parent 20b9ed91c0
commit ea90a76efe
6 changed files with 351 additions and 10 deletions
+39 -7
View File
@@ -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.
*
+6 -3
View File
@@ -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,
@@ -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)');
});
});
});
@@ -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,
@@ -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,
+206
View File
@@ -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();
}
}