From 34db2d4e939cab4c37e5a8e6ec95800cce227c98 Mon Sep 17 00:00:00 2001 From: Gabriel Donadel Dall'Agnol Date: Thu, 22 Sep 2022 07:30:34 -0700 Subject: [PATCH] feat: Add string support to the transform property (#34660) Summary: This updates the `transform` property to support string values as requested on https://github.com/facebook/react-native/issues/34425. This also updates the existing unit tests of the `processTransform` function ensuring the style processing works as expected and updates the TransformExample on RNTester in order to facilitate the manual QA of this. ## Changelog [General] [Added] - Add string support to the transform property Pull Request resolved: https://github.com/facebook/react-native/pull/34660 Test Plan: 1. Open the RNTester app and navigate to the Transforms page 2. Check the transform style through the `Transform using a string` section https://user-images.githubusercontent.com/11707729/189550548-ee3c14dd-11c6-4fd1-bd74-f6b52ecb9eae.mov Reviewed By: lunaleaps Differential Revision: D39423409 Pulled By: cipolleschi fbshipit-source-id: 0d7b79178eb33f34ae55a070ce094360b544361f --- .../processTransform-test.js.snap | 12 +- .../__tests__/processTransform-test.js | 30 +++++ .../StyleSheet/private/_TransformStyle.js | 48 +++---- Libraries/StyleSheet/processTransform.js | 118 +++++++++++++++++- .../js/examples/Transform/TransformExample.js | 22 ++++ 5 files changed, 205 insertions(+), 25 deletions(-) diff --git a/Libraries/StyleSheet/__tests__/__snapshots__/processTransform-test.js.snap b/Libraries/StyleSheet/__tests__/__snapshots__/processTransform-test.js.snap index 9ec8fbc3cd6674..dc8d045189ea5e 100644 --- a/Libraries/StyleSheet/__tests__/__snapshots__/processTransform-test.js.snap +++ b/Libraries/StyleSheet/__tests__/__snapshots__/processTransform-test.js.snap @@ -2,6 +2,8 @@ exports[`processTransform validation should throw on invalid transform property 1`] = `"Invalid transform translateW: {\\"translateW\\":10}"`; +exports[`processTransform validation should throw on invalid transform property 2`] = `"Invalid transform translateW: {\\"translateW\\":10}"`; + exports[`processTransform validation should throw on object with multiple properties 1`] = `"You must specify exactly one property per transform object. Passed properties: {\\"scale\\":0.5,\\"translateY\\":10}"`; exports[`processTransform validation should throw when not passing an array to an array prop 1`] = `"Transform with key of matrix must have an array as the value: {\\"matrix\\":\\"not-a-matrix\\"}"`; @@ -10,17 +12,25 @@ exports[`processTransform validation should throw when not passing an array to a exports[`processTransform validation should throw when passing a matrix of the wrong size 1`] = `"Matrix transform must have a length of 9 (2d) or 16 (3d). Provided matrix has a length of 4: {\\"matrix\\":[1,1,1,1]}"`; +exports[`processTransform validation should throw when passing a matrix of the wrong size 2`] = `"Matrix transform must have a length of 9 (2d) or 16 (3d). Provided matrix has a length of 4: {\\"matrix\\":[1,1,1,1]}"`; + exports[`processTransform validation should throw when passing a perspective of 0 1`] = `"Transform with key of \\"perspective\\" cannot be zero: {\\"perspective\\":0}"`; exports[`processTransform validation should throw when passing a translate of the wrong size 1`] = `"Transform with key translate must be an array of length 2 or 3, found 1: {\\"translate\\":[1]}"`; exports[`processTransform validation should throw when passing a translate of the wrong size 2`] = `"Transform with key translate must be an array of length 2 or 3, found 4: {\\"translate\\":[1,1,1,1]}"`; +exports[`processTransform validation should throw when passing a translate of the wrong size 3`] = `"Transform with key translate must be an string with 1 or 2 parameters, found 4: translate(1px, 1px, 1px, 1px)"`; + exports[`processTransform validation should throw when passing an Animated.Value 1`] = `"You passed an Animated.Value to a normal component. You need to wrap that component in an Animated. For example, replace by ."`; exports[`processTransform validation should throw when passing an invalid angle prop 1`] = `"Transform with key of \\"rotate\\" must be a string: {\\"rotate\\":10}"`; -exports[`processTransform validation should throw when passing an invalid angle prop 2`] = `"Rotate transform must be expressed in degrees (deg) or radians (rad): {\\"skewX\\":\\"10drg\\"}"`; +exports[`processTransform validation should throw when passing an invalid angle prop 2`] = `"Transform with key of \\"rotate\\" must be a string: {\\"rotate\\":10}"`; + +exports[`processTransform validation should throw when passing an invalid angle prop 3`] = `"Rotate transform must be expressed in degrees (deg) or radians (rad): {\\"skewX\\":\\"10drg\\"}"`; + +exports[`processTransform validation should throw when passing an invalid angle prop 4`] = `"Rotate transform must be expressed in degrees (deg) or radians (rad): {\\"skewX\\":\\"10drg\\"}"`; exports[`processTransform validation should throw when passing an invalid value to a number prop 1`] = `"Transform with key of \\"translateY\\" must be a number: {\\"translateY\\":\\"20deg\\"}"`; diff --git a/Libraries/StyleSheet/__tests__/processTransform-test.js b/Libraries/StyleSheet/__tests__/processTransform-test.js index bf6f42db4fe9b4..66ce708c033897 100644 --- a/Libraries/StyleSheet/__tests__/processTransform-test.js +++ b/Libraries/StyleSheet/__tests__/processTransform-test.js @@ -18,6 +18,10 @@ describe('processTransform', () => { processTransform([]); }); + it('should accept an empty string', () => { + processTransform(''); + }); + it('should accept a simple valid transform', () => { processTransform([ {scale: 0.5}, @@ -25,6 +29,9 @@ describe('processTransform', () => { {translateY: 20}, {rotate: '10deg'}, ]); + processTransform( + 'scale(0.5) translateX(10px) translateY(20px) rotate(10deg)', + ); }); it('should throw on object with multiple properties', () => { @@ -37,6 +44,9 @@ describe('processTransform', () => { expect(() => processTransform([{translateW: 10}]), ).toThrowErrorMatchingSnapshot(); + expect(() => + processTransform('translateW(10)'), + ).toThrowErrorMatchingSnapshot(); }); it('should throw when not passing an array to an array prop', () => { @@ -50,19 +60,28 @@ describe('processTransform', () => { it('should accept a valid matrix', () => { processTransform([{matrix: [1, 1, 1, 1, 1, 1, 1, 1, 1]}]); + processTransform('matrix(1, 1, 1, 1, 1, 1, 1, 1, 1)'); processTransform([ {matrix: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}, ]); + processTransform( + 'matrix(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)', + ); }); it('should throw when passing a matrix of the wrong size', () => { expect(() => processTransform([{matrix: [1, 1, 1, 1]}]), ).toThrowErrorMatchingSnapshot(); + expect(() => + processTransform('matrix(1, 1, 1, 1)'), + ).toThrowErrorMatchingSnapshot(); }); it('should accept a valid translate', () => { processTransform([{translate: [1, 1]}]); + processTransform('translate(1px)'); + processTransform('translate(1px, 1px)'); processTransform([{translate: [1, 1, 1]}]); }); @@ -73,6 +92,9 @@ describe('processTransform', () => { expect(() => processTransform([{translate: [1, 1, 1, 1]}]), ).toThrowErrorMatchingSnapshot(); + expect(() => + processTransform('translate(1px, 1px, 1px, 1px)'), + ).toThrowErrorMatchingSnapshot(); }); it('should throw when passing an invalid value to a number prop', () => { @@ -95,16 +117,24 @@ describe('processTransform', () => { it('should accept an angle in degrees or radians', () => { processTransform([{skewY: '10deg'}]); + processTransform('skewY(10deg)'); processTransform([{rotateX: '1.16rad'}]); + processTransform('rotateX(1.16rad)'); }); it('should throw when passing an invalid angle prop', () => { expect(() => processTransform([{rotate: 10}]), ).toThrowErrorMatchingSnapshot(); + expect(() => + processTransform('rotate(10)'), + ).toThrowErrorMatchingSnapshot(); expect(() => processTransform([{skewX: '10drg'}]), ).toThrowErrorMatchingSnapshot(); + expect(() => + processTransform('skewX(10drg)'), + ).toThrowErrorMatchingSnapshot(); }); it('should throw when passing an Animated.Value', () => { diff --git a/Libraries/StyleSheet/private/_TransformStyle.js b/Libraries/StyleSheet/private/_TransformStyle.js index a0d0a9be5a1728..0a264a393e57b5 100644 --- a/Libraries/StyleSheet/private/_TransformStyle.js +++ b/Libraries/StyleSheet/private/_TransformStyle.js @@ -27,27 +27,29 @@ export type ____TransformStyle_Internal = $ReadOnly<{| * * `transform([{ skewX: '45deg' }])` */ - transform?: $ReadOnlyArray< - | {|+perspective: number | AnimatedNode|} - | {|+rotate: string | AnimatedNode|} - | {|+rotateX: string | AnimatedNode|} - | {|+rotateY: string | AnimatedNode|} - | {|+rotateZ: string | AnimatedNode|} - | {|+scale: number | AnimatedNode|} - | {|+scaleX: number | AnimatedNode|} - | {|+scaleY: number | AnimatedNode|} - | {|+translateX: number | AnimatedNode|} - | {|+translateY: number | AnimatedNode|} - | {| - +translate: - | [number | AnimatedNode, number | AnimatedNode] - | AnimatedNode, - |} - | {|+skewX: string|} - | {|+skewY: string|} - // TODO: what is the actual type it expects? - | {| - +matrix: $ReadOnlyArray | AnimatedNode, - |}, - >, + transform?: + | $ReadOnlyArray< + | {|+perspective: number | AnimatedNode|} + | {|+rotate: string | AnimatedNode|} + | {|+rotateX: string | AnimatedNode|} + | {|+rotateY: string | AnimatedNode|} + | {|+rotateZ: string | AnimatedNode|} + | {|+scale: number | AnimatedNode|} + | {|+scaleX: number | AnimatedNode|} + | {|+scaleY: number | AnimatedNode|} + | {|+translateX: number | AnimatedNode|} + | {|+translateY: number | AnimatedNode|} + | {| + +translate: + | [number | AnimatedNode, number | AnimatedNode] + | AnimatedNode, + |} + | {|+skewX: string|} + | {|+skewY: string|} + // TODO: what is the actual type it expects? + | {| + +matrix: $ReadOnlyArray | AnimatedNode, + |}, + > + | string, |}>; diff --git a/Libraries/StyleSheet/processTransform.js b/Libraries/StyleSheet/processTransform.js index 7b065c0403756d..07dfa910b8616b 100644 --- a/Libraries/StyleSheet/processTransform.js +++ b/Libraries/StyleSheet/processTransform.js @@ -22,8 +22,26 @@ const stringifySafe = require('../Utilities/stringifySafe').default; * interface to native code. */ function processTransform( - transform: Array, + transform: Array | string, ): Array | Array { + if (typeof transform === 'string') { + const regex = new RegExp(/(\w+)\(([^)]+)\)/g); + let transformArray: Array = []; + let matches; + + while ((matches = regex.exec(transform))) { + const {key, value} = _getKeyAndValueFromCSSTransform( + matches[1], + matches[2], + ); + + if (value !== undefined) { + transformArray.push({[key]: value}); + } + } + transform = transformArray; + } + if (__DEV__) { _validateTransforms(transform); } @@ -31,6 +49,104 @@ function processTransform( return transform; } +const _getKeyAndValueFromCSSTransform: ( + key: + | string + | $TEMPORARY$string<'matrix'> + | $TEMPORARY$string<'perspective'> + | $TEMPORARY$string<'rotate'> + | $TEMPORARY$string<'rotateX'> + | $TEMPORARY$string<'rotateY'> + | $TEMPORARY$string<'rotateZ'> + | $TEMPORARY$string<'scale'> + | $TEMPORARY$string<'scaleX'> + | $TEMPORARY$string<'scaleY'> + | $TEMPORARY$string<'skewX'> + | $TEMPORARY$string<'skewY'> + | $TEMPORARY$string<'translate'> + | $TEMPORARY$string<'translate3d'> + | $TEMPORARY$string<'translateX'> + | $TEMPORARY$string<'translateY'>, + args: string, +) => {key: string, value?: number[] | number | string} = (key, args) => { + const argsWithUnitsRegex = new RegExp(/([+-]?\d+(\.\d+)?)([a-zA-Z]+)?/g); + + switch (key) { + case 'matrix': + return {key, value: args.match(/[+-]?\d+(\.\d+)?/g)?.map(Number)}; + case 'translate': + case 'translate3d': + const parsedArgs = []; + let missingUnitOfMeasurement = false; + + let matches; + while ((matches = argsWithUnitsRegex.exec(args))) { + const value = Number(matches[1]); + const unitOfMeasurement = matches[3]; + + if (value !== 0 && !unitOfMeasurement) { + missingUnitOfMeasurement = true; + } + + parsedArgs.push(value); + } + + if (__DEV__) { + invariant( + !missingUnitOfMeasurement, + `Transform with key ${key} must have units unless the provided value is 0, found %s`, + `${key}(${args})`, + ); + + if (key === 'translate') { + invariant( + parsedArgs?.length === 1 || parsedArgs?.length === 2, + 'Transform with key translate must be an string with 1 or 2 parameters, found %s: %s', + parsedArgs?.length, + `${key}(${args})`, + ); + } else { + invariant( + parsedArgs?.length === 3, + 'Transform with key translate3d must be an string with 3 parameters, found %s: %s', + parsedArgs?.length, + `${key}(${args})`, + ); + } + } + + if (parsedArgs?.length === 1) { + parsedArgs.push(0); + } + + return {key: 'translate', value: parsedArgs}; + case 'translateX': + case 'translateY': + case 'perspective': + const argMatches = argsWithUnitsRegex.exec(args); + + if (!argMatches?.length) { + return {key, value: undefined}; + } + + const value = Number(argMatches[1]); + const unitOfMeasurement = argMatches[3]; + + if (__DEV__) { + invariant( + value === 0 || unitOfMeasurement, + `Transform with key ${key} must have units unless the provided value is 0, found %s`, + `${key}(${args})`, + ); + } + + return {key, value}; + + default: + return {key, value: !isNaN(args) ? Number(args) : args}; + } +}; + function _validateTransforms(transform: Array): void { transform.forEach(transformation => { const keys = Object.keys(transformation); diff --git a/packages/rn-tester/js/examples/Transform/TransformExample.js b/packages/rn-tester/js/examples/Transform/TransformExample.js index 95ea2a8cab2328..74798f619fc392 100644 --- a/packages/rn-tester/js/examples/Transform/TransformExample.js +++ b/packages/rn-tester/js/examples/Transform/TransformExample.js @@ -199,6 +199,17 @@ const styles = StyleSheet.create({ backgroundColor: 'salmon', alignSelf: 'center', }, + box7: { + backgroundColor: 'lightseagreen', + height: 50, + position: 'absolute', + right: 0, + top: 0, + width: 50, + }, + box7Transform: { + transform: 'translate(-50, 35) rotate(50deg) scale(2)', + }, flipCardContainer: { marginVertical: 40, flex: 1, @@ -324,4 +335,15 @@ exports.examples = [ return ; }, }, + { + title: 'Transform using a string', + description: "transform: 'translate(-50, 35) rotate(50deg) scale(2)'", + render(): Node { + return ( + + + + ); + }, + }, ];