From b589123a3dc0c6190137fbd2e6c18f24b98642f1 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Wed, 30 Nov 2022 10:19:12 -0800 Subject: [PATCH] Rewrite AnimatedInterpolation JS driver support for colors Summary: Restructured the JS implementation of AnimatedInterpolation to make it clearer how colors and other string-based interpolatables are supported. We're then able to use a very similar structure to implement this interpolation on the native driver as well, which simplifies implementation, and improves support for different color types. Changelog: [General][Fixed] Improved support for AnimatedInterpolation of color props. Reviewed By: mdvacca Differential Revision: D40571890 fbshipit-source-id: 7c204a7b736722732dc5f9e0d158ef5af81b4bb1 --- .../Animated/__tests__/Interpolation-test.js | 120 +++++--- .../Animated/nodes/AnimatedInterpolation.js | 261 ++++++++++-------- 2 files changed, 224 insertions(+), 157 deletions(-) diff --git a/Libraries/Animated/__tests__/Interpolation-test.js b/Libraries/Animated/__tests__/Interpolation-test.js index 2893cdd39399ba..0e8e342da760ea 100644 --- a/Libraries/Animated/__tests__/Interpolation-test.js +++ b/Libraries/Animated/__tests__/Interpolation-test.js @@ -13,9 +13,21 @@ import Easing from '../Easing'; import AnimatedInterpolation from '../nodes/AnimatedInterpolation'; +function createInterpolation(config) { + let parentValue = null; + const interpolation = new AnimatedInterpolation( + {__getValue: () => parentValue}, + config, + ); + return input => { + parentValue = input; + return interpolation.__getValue(); + }; +} + describe('Interpolation', () => { it('should work with defaults', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [0, 1], }); @@ -27,7 +39,7 @@ describe('Interpolation', () => { }); it('should work with output range', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [100, 200], }); @@ -39,7 +51,7 @@ describe('Interpolation', () => { }); it('should work with input range', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [100, 200], outputRange: [0, 1], }); @@ -51,23 +63,25 @@ describe('Interpolation', () => { }); it('should throw for non monotonic input ranges', () => { - expect(() => - AnimatedInterpolation.__createInterpolation({ - inputRange: [0, 2, 1], - outputRange: [0, 1, 2], - }), + expect( + () => + new AnimatedInterpolation(null, { + inputRange: [0, 2, 1], + outputRange: [0, 1, 2], + }), ).toThrow(); - expect(() => - AnimatedInterpolation.__createInterpolation({ - inputRange: [0, 1, 2], - outputRange: [0, 3, 1], - }), + expect( + () => + new AnimatedInterpolation(null, { + inputRange: [0, 1, 2], + outputRange: [0, 3, 1], + }), ).not.toThrow(); }); it('should work with empty input range', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 10, 10], outputRange: [1, 2, 3], extrapolate: 'extend', @@ -81,7 +95,7 @@ describe('Interpolation', () => { }); it('should work with empty output range', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [1, 2, 3], outputRange: [0, 10, 10], extrapolate: 'extend', @@ -96,7 +110,7 @@ describe('Interpolation', () => { }); it('should work with easing', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [0, 1], easing: Easing.quad, @@ -109,7 +123,7 @@ describe('Interpolation', () => { }); it('should work with extrapolate', () => { - let interpolation = AnimatedInterpolation.__createInterpolation({ + let interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'extend', @@ -119,7 +133,7 @@ describe('Interpolation', () => { expect(interpolation(-2)).toBe(4); expect(interpolation(2)).toBe(4); - interpolation = AnimatedInterpolation.__createInterpolation({ + interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp', @@ -129,7 +143,7 @@ describe('Interpolation', () => { expect(interpolation(-2)).toBe(0); expect(interpolation(2)).toBe(1); - interpolation = AnimatedInterpolation.__createInterpolation({ + interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'identity', @@ -141,7 +155,7 @@ describe('Interpolation', () => { }); it('should work with keyframes with extrapolate', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 10, 100, 1000], outputRange: [0, 5, 50, 500], extrapolate: true, @@ -159,7 +173,7 @@ describe('Interpolation', () => { }); it('should work with keyframes without extrapolate', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1, 2], outputRange: [0.2, 1, 0.2], extrapolate: 'clamp', @@ -169,23 +183,25 @@ describe('Interpolation', () => { }); it('should throw for an infinite input range', () => { - expect(() => - AnimatedInterpolation.__createInterpolation({ - inputRange: [-Infinity, Infinity], - outputRange: [0, 1], - }), + expect( + () => + new AnimatedInterpolation(null, { + inputRange: [-Infinity, Infinity], + outputRange: [0, 1], + }), ).toThrow(); - expect(() => - AnimatedInterpolation.__createInterpolation({ - inputRange: [-Infinity, 0, Infinity], - outputRange: [1, 2, 3], - }), + expect( + () => + new AnimatedInterpolation(null, { + inputRange: [-Infinity, 0, Infinity], + outputRange: [1, 2, 3], + }), ).not.toThrow(); }); it('should work with negative infinite', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [-Infinity, 0], outputRange: [-Infinity, 0], easing: Easing.quad, @@ -201,7 +217,7 @@ describe('Interpolation', () => { }); it('should work with positive infinite', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [5, Infinity], outputRange: [5, Infinity], easing: Easing.quad, @@ -219,7 +235,7 @@ describe('Interpolation', () => { }); it('should work with output ranges as string', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: ['rgba(0, 100, 200, 0)', 'rgba(50, 150, 250, 0.4)'], }); @@ -230,7 +246,7 @@ describe('Interpolation', () => { }); it('should work with output ranges as short hex string', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: ['#024', '#9BF'], }); @@ -241,7 +257,7 @@ describe('Interpolation', () => { }); it('should work with output ranges as long hex string', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: ['#FF9500', '#87FC70'], }); @@ -252,7 +268,7 @@ describe('Interpolation', () => { }); it('should work with output ranges with mixed hex and rgba strings', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: ['rgba(100, 120, 140, .4)', '#87FC70'], }); @@ -263,7 +279,7 @@ describe('Interpolation', () => { }); it('should work with negative and decimal values in string ranges', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: ['-100.5deg', '100deg'], }); @@ -274,7 +290,7 @@ describe('Interpolation', () => { }); it('should crash when chaining an interpolation that returns a string', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: [0, 1], }); @@ -284,7 +300,7 @@ describe('Interpolation', () => { }); it('should support a mix of color patterns', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1, 2], outputRange: ['rgba(0, 100, 200, 0)', 'rgb(50, 150, 250)', 'red'], }); @@ -297,15 +313,35 @@ describe('Interpolation', () => { it('should crash when defining output range with different pattern', () => { expect(() => - AnimatedInterpolation.__createInterpolation({ + createInterpolation({ inputRange: [0, 1], outputRange: ['20deg', '30rad'], }), ).toThrow(); }); + it('should interpolate values with arbitrary suffixes', () => { + const interpolation = createInterpolation({ + inputRange: [0, 1], + outputRange: ['-10foo', '10foo'], + }); + + expect(interpolation(0)).toBe('-10foo'); + expect(interpolation(0.5)).toBe('0foo'); + }); + + it('should interpolate numeric values of arbitrary format', () => { + const interpolation = createInterpolation({ + inputRange: [0, 1], + outputRange: ['M20,20L20,80L80,80L80,20Z', 'M40,40L33,60L60,60L65,40Z'], + }); + + expect(interpolation(0)).toBe('M20,20L20,80L80,80L80,20Z'); + expect(interpolation(0.5)).toBe('M30,30L26.5,70L70,70L72.5,30Z'); + }); + it('should round the alpha channel of a color to the nearest thousandth', () => { - const interpolation = AnimatedInterpolation.__createInterpolation({ + const interpolation = createInterpolation({ inputRange: [0, 1], outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 1)'], }); diff --git a/Libraries/Animated/nodes/AnimatedInterpolation.js b/Libraries/Animated/nodes/AnimatedInterpolation.js index be85be6e89d742..dbc3433cbc4904 100644 --- a/Libraries/Animated/nodes/AnimatedInterpolation.js +++ b/Libraries/Animated/nodes/AnimatedInterpolation.js @@ -16,6 +16,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type AnimatedNode from './AnimatedNode'; import normalizeColor from '../../StyleSheet/normalizeColor'; +import Easing from '../Easing'; import NativeAnimatedHelper from '../NativeAnimatedHelper'; import AnimatedWithChildren from './AnimatedWithChildren'; import invariant from 'invariant'; @@ -31,39 +32,17 @@ export type InterpolationConfigType = $ReadOnly<{ extrapolateRight?: ExtrapolateType, }>; -const linear = (t: number) => t; - /** * Very handy helper to map input ranges to output ranges with an easing * function and custom behavior outside of the ranges. */ -function createInterpolation( - config: InterpolationConfigType, -): (input: number) => OutputT { - if (config.outputRange && typeof config.outputRange[0] === 'string') { - return (createInterpolationFromStringOutputRange((config: any)): any); - } - +function createNumericInterpolation( + config: InterpolationConfigType, +): (input: number) => number { const outputRange: $ReadOnlyArray = (config.outputRange: any); - const inputRange = config.inputRange; - if (__DEV__) { - checkInfiniteRange('outputRange', outputRange); - checkInfiniteRange('inputRange', inputRange); - checkValidInputRange(inputRange); - - invariant( - inputRange.length === outputRange.length, - 'inputRange (' + - inputRange.length + - ') and outputRange (' + - outputRange.length + - ') must have the same length', - ); - } - - const easing = config.easing || linear; + const easing = config.easing || Easing.linear; let extrapolateLeft: ExtrapolateType = 'extend'; if (config.extrapolateLeft !== undefined) { @@ -167,24 +146,48 @@ function interpolate( return result; } -function colorToRgba(input: string): string { - let normalizedColor = normalizeColor(input); - if (normalizedColor === null || typeof normalizedColor !== 'number') { - return input; - } - - normalizedColor = normalizedColor || 0; +const numericComponentRegex = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g; - const r = (normalizedColor & 0xff000000) >>> 24; - const g = (normalizedColor & 0x00ff0000) >>> 16; - const b = (normalizedColor & 0x0000ff00) >>> 8; - const a = (normalizedColor & 0x000000ff) / 255; +// Maps string inputs an RGBA color or an array of numeric components +function mapStringToNumericComponents( + input: string, +): + | {isColor: true, components: [number, number, number, number]} + | {isColor: false, components: $ReadOnlyArray} { + let normalizedColor = normalizeColor(input); + invariant( + normalizedColor == null || typeof normalizedColor !== 'object', + 'PlatformColors are not supported', + ); - return `rgba(${r}, ${g}, ${b}, ${a})`; + if (typeof normalizedColor === 'number') { + normalizedColor = normalizedColor || 0; + const r = (normalizedColor & 0xff000000) >>> 24; + const g = (normalizedColor & 0x00ff0000) >>> 16; + const b = (normalizedColor & 0x0000ff00) >>> 8; + const a = (normalizedColor & 0x000000ff) / 255; + return {isColor: true, components: [r, g, b, a]}; + } else { + const components: Array = []; + let lastMatchEnd = 0; + for (const match of input.matchAll(numericComponentRegex)) { + if (match.index > lastMatchEnd) { + components.push(input.substring(lastMatchEnd, match.index)); + } + components.push(parseFloat(match[0])); + lastMatchEnd = match.index + match[0].length; + } + invariant( + components.length > 0, + 'outputRange must contain color or value with numeric component', + ); + if (lastMatchEnd < input.length) { + components.push(input.substring(lastMatchEnd, input.length)); + } + return {isColor: false, components}; + } } -const stringShapeRegex = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g; - /** * Supports string shapes by extracting numbers so new values can be computed, * and recombines those values into new strings of the same shape. Supports @@ -193,76 +196,69 @@ const stringShapeRegex = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g; * rgba(123, 42, 99, 0.36) // colors * -45deg // values with units */ -function createInterpolationFromStringOutputRange( +function createStringInterpolation( config: InterpolationConfigType, ): (input: number) => string { - let outputRange: Array = (config.outputRange: any); - invariant(outputRange.length >= 2, 'Bad output range'); - outputRange = outputRange.map(colorToRgba); - checkPattern(outputRange); - - // ['rgba(0, 100, 200, 0)', 'rgba(50, 150, 250, 0.5)'] - // -> - // [ - // [0, 50], - // [100, 150], - // [200, 250], - // [0, 0.5], - // ] - /* $FlowFixMe[incompatible-use] (>=0.18.0): `outputRange[0].match()` can - * return `null`. Need to guard against this possibility. */ - const outputRanges = outputRange[0].match(stringShapeRegex).map(() => []); - outputRange.forEach(value => { - /* $FlowFixMe[incompatible-use] (>=0.18.0): `value.match()` can return - * `null`. Need to guard against this possibility. */ - value.match(stringShapeRegex).forEach((number, i) => { - outputRanges[i].push(+number); - }); - }); - - const interpolations = outputRange[0] - .match(stringShapeRegex) - /* $FlowFixMe[incompatible-use] (>=0.18.0): `outputRange[0].match()` can - * return `null`. Need to guard against this possibility. */ - /* $FlowFixMe[incompatible-call] (>=0.18.0): `outputRange[0].match()` can - * return `null`. Need to guard against this possibility. */ - .map((value, i) => { - return createInterpolation({ - ...config, - outputRange: outputRanges[i], - }); - }); - - // rgba requires that the r,g,b are integers.... so we want to round them, but we *dont* want to - // round the opacity (4th column). - const shouldRound = isRgbOrRgba(outputRange[0]); - - return input => { - let i = 0; - // 'rgba(0, 100, 200, 0)' - // -> - // 'rgba(${interpolations[0](input)}, ${interpolations[1](input)}, ...' - return outputRange[0].replace(stringShapeRegex, () => { - let val = +interpolations[i++](input); - if (shouldRound) { - val = i < 4 ? Math.round(val) : Math.round(val * 1000) / 1000; - } - return String(val); - }); - }; -} - -function isRgbOrRgba(range: string) { - return typeof range === 'string' && range.startsWith('rgb'); -} + invariant(config.outputRange.length >= 2, 'Bad output range'); + const outputRange = config.outputRange.map(mapStringToNumericComponents); -function checkPattern(arr: $ReadOnlyArray) { - const pattern = arr[0].replace(stringShapeRegex, ''); - for (let i = 1; i < arr.length; ++i) { + const isColor = outputRange[0].isColor; + if (__DEV__) { + invariant( + outputRange.every(output => output.isColor === isColor), + 'All elements of output range should either be a color or a string with numeric components', + ); + const firstOutput = outputRange[0].components; invariant( - pattern === arr[i].replace(stringShapeRegex, ''), - 'invalid pattern ' + arr[0] + ' and ' + arr[i], + outputRange.every( + output => output.components.length === firstOutput.length, + ), + 'All elements of output range should have the same number of components', + ); + invariant( + outputRange.every(output => + output.components.every( + (component, i) => + // $FlowIgnoreMe[invalid-compare] + typeof component === 'number' || component === firstOutput[i], + ), + ), + 'All elements of output range should have the same non-numeric components', + ); + } + + const numericComponents: $ReadOnlyArray<$ReadOnlyArray> = + outputRange.map(output => + isColor + ? // $FlowIgnoreMe[incompatible-call] + output.components + : output.components.filter(c => typeof c === 'number'), ); + const interpolations = numericComponents[0].map((_, i) => + createNumericInterpolation({ + ...config, + outputRange: numericComponents.map(components => components[i]), + }), + ); + if (!isColor) { + return input => { + const values = interpolations.map(interpolation => interpolation(input)); + console.log({values}); + let i = 0; + return outputRange[0].components + .map(c => (typeof c === 'number' ? values[i++] : c)) + .join(''); + }; + } else { + return input => { + const result = interpolations.map((interpolation, i) => { + const value = interpolation(input); + // rgba requires that the r,g,b are integers.... so we want to round them, but we *dont* want to + // round the opacity (4th column). + return i < 3 ? Math.round(value) : Math.round(value * 1000) / 1000; + }); + return `rgba(${result[0]}, ${result[1]}, ${result[2]}, ${result[3]})`; + }; } } @@ -276,6 +272,24 @@ function findRange(input: number, inputRange: $ReadOnlyArray) { return i - 1; } +function checkValidRanges( + inputRange: $ReadOnlyArray, + outputRange: $ReadOnlyArray, +) { + checkInfiniteRange('outputRange', outputRange); + checkInfiniteRange('inputRange', inputRange); + checkValidInputRange(inputRange); + + invariant( + inputRange.length === outputRange.length, + 'inputRange (' + + inputRange.length + + ') and outputRange (' + + outputRange.length + + ') must have the same length', + ); +} + function checkValidInputRange(arr: $ReadOnlyArray) { invariant(arr.length >= 2, 'inputRange must have at least 2 elements'); const message = @@ -285,7 +299,10 @@ function checkValidInputRange(arr: $ReadOnlyArray) { } } -function checkInfiniteRange(name: string, arr: $ReadOnlyArray) { +function checkInfiniteRange( + name: string, + arr: $ReadOnlyArray, +) { invariant(arr.length >= 2, name + ' must have at least 2 elements'); invariant( arr.length !== 2 || arr[0] !== -Infinity || arr[1] !== Infinity, @@ -301,20 +318,34 @@ function checkInfiniteRange(name: string, arr: $ReadOnlyArray) { export default class AnimatedInterpolation< OutputT: number | string, > extends AnimatedWithChildren { - // Export for testing. - static __createInterpolation: ( - config: InterpolationConfigType, - ) => (input: number) => OutputT = createInterpolation; - _parent: AnimatedNode; _config: InterpolationConfigType; - _interpolation: (input: number) => OutputT; + _interpolation: ?(input: number) => OutputT; constructor(parent: AnimatedNode, config: InterpolationConfigType) { super(); this._parent = parent; this._config = config; - this._interpolation = createInterpolation(config); + + if (__DEV__) { + checkValidRanges(config.inputRange, config.outputRange); + + // Create interpolation eagerly in dev, so we can signal errors faster + // even when using the native driver + this._getInterpolation(); + } + } + + _getInterpolation(): number => OutputT { + if (!this._interpolation) { + const config = this._config; + if (config.outputRange && typeof config.outputRange[0] === 'string') { + this._interpolation = (createStringInterpolation((config: any)): any); + } else { + this._interpolation = (createNumericInterpolation((config: any)): any); + } + } + return this._interpolation; } __makeNative(platformConfig: ?PlatformConfig) { @@ -322,13 +353,13 @@ export default class AnimatedInterpolation< super.__makeNative(platformConfig); } - __getValue(): number | string { + __getValue(): OutputT { const parentValue: number = this._parent.__getValue(); invariant( typeof parentValue === 'number', 'Cannot interpolate an input which is not a number.', ); - return this._interpolation(parentValue); + return this._getInterpolation()(parentValue); } interpolate(