From 0dceac9f02c337d9f5d9f15bb4257ad69b1ebc4e Mon Sep 17 00:00:00 2001 From: Joe Vilches Date: Wed, 8 May 2024 13:39:09 -0700 Subject: [PATCH] JS plumbing to get filters into native (#44458) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/44458 This is the JS plumbing to get it so that views can now use filters. The typing looks like `filter: [{brightness: 1.5}, {hueRotate: '90deg'}]` which is different than web which would look like `filter: brightness(1.5) hue-rotate(90deg)`. I feel like the web version is overly complicated and not very *react native-y*. Transform uses the array based approach (albeit they also accept a string). Open to changing this but really feel like the web format is silly and bad since it would just involve parsing some arbitrary string. The diff includes: * Style sheet changes so typing is valid * Process function to turn filter format into {name: string, amount: string} * Test for process function * View config changes on Android, iOS and ReactNativeStyleAttributes Changelog: [Internal] Reviewed By: NickGerleman Differential Revision: D56845572 fbshipit-source-id: 5029b5adac29bb863c89f6c699d5693c58cad711 --- .../View/ReactNativeStyleAttributes.js | 6 + .../NativeComponent/BaseViewConfig.android.js | 3 + .../NativeComponent/BaseViewConfig.ios.js | 3 + .../Libraries/StyleSheet/StyleSheetTypes.js | 6 + .../__tests__/processFilter-test.js | 207 ++++++++++++++++++ .../Libraries/StyleSheet/processFilter.js | 132 +++++++++++ .../__snapshots__/public-api-test.js.snap | 21 ++ 7 files changed, 378 insertions(+) create mode 100644 packages/react-native/Libraries/StyleSheet/__tests__/processFilter-test.js create mode 100644 packages/react-native/Libraries/StyleSheet/processFilter.js diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index e0c82a319f3..0a73eb53c84 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -12,6 +12,7 @@ import type {AnyAttributeType} from '../../Renderer/shims/ReactNativeTypes'; import processAspectRatio from '../../StyleSheet/processAspectRatio'; import processColor from '../../StyleSheet/processColor'; +import processFilter from '../../StyleSheet/processFilter'; import processFontVariant from '../../StyleSheet/processFontVariant'; import processTransform from '../../StyleSheet/processTransform'; import processTransformOrigin from '../../StyleSheet/processTransformOrigin'; @@ -114,6 +115,11 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { transform: {process: processTransform}, transformOrigin: {process: processTransformOrigin}, + /** + * Filter + */ + experimental_filter: {process: processFilter}, + /** * View */ diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js index 6698a49537d..d2947cc9692 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js @@ -166,6 +166,9 @@ const validAttributesForNonEventProps = { backgroundColor: {process: require('../StyleSheet/processColor').default}, transform: true, transformOrigin: true, + experimental_filter: { + process: require('../StyleSheet/processFilter').default, + }, opacity: true, elevation: true, shadowColor: {process: require('../StyleSheet/processColor').default}, diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index 486e94e71bc..b43e14adbac 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -220,6 +220,9 @@ const validAttributesForNonEventProps = { hitSlop: {diff: require('../Utilities/differ/insetsDiffer')}, collapsable: true, collapsableChildren: true, + experimental_filter: { + process: require('../StyleSheet/processFilter').default, + }, borderTopWidth: true, borderTopColor: {process: require('../StyleSheet/processColor').default}, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index a56f83f4dba..de54f8235f5 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -11,6 +11,7 @@ 'use strict'; import type AnimatedNode from '../Animated/nodes/AnimatedNode'; +import type {FilterPrimitive} from '../StyleSheet/processFilter'; import type { ____DangerouslyImpreciseStyle_InternalOverrides, ____ImageStyle_InternalOverrides, @@ -690,10 +691,15 @@ export type ____ShadowStyle_Internal = $ReadOnly<{ ...____ShadowStyle_InternalOverrides, }>; +type ____FilterStyle_Internal = $ReadOnly<{ + experimental_filter?: $ReadOnlyArray, +}>; + export type ____ViewStyle_InternalCore = $ReadOnly<{ ...$Exact<____LayoutStyle_Internal>, ...$Exact<____ShadowStyle_Internal>, ...$Exact<____TransformStyle_Internal>, + ...____FilterStyle_Internal, backfaceVisibility?: 'visible' | 'hidden', backgroundColor?: ____ColorValue_Internal, borderColor?: ____ColorValue_Internal, diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processFilter-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processFilter-test.js new file mode 100644 index 00000000000..7a78db47d8a --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processFilter-test.js @@ -0,0 +1,207 @@ +/** + * 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. + * + * @format + * @oncall react_native + * @flow strict-local + */ + +'use strict'; + +import type {FilterPrimitive} from '../processFilter'; + +const processFilter = require('../processFilter').default; + +// js1 test processFilter +describe('processFilter', () => { + testStandardFilter('brightness'); + testStandardFilter('opacity'); + testStandardFilter('contrast'); + testStandardFilter('saturate'); + testStandardFilter('grayscale'); + testStandardFilter('sepia'); + testStandardFilter('invert'); + + testNumericFilter('blur', 5, [ + { + blur: 5, + }, + ]); + testNumericFilter('blur', -5, []); + testUnitFilter('blur', 5, '%', []); + testUnitFilter('blur', 5, 'px', [ + { + blur: 5, + }, + ]); + + testNumericFilter('hueRotate', 0, [{hueRotate: 0}]); + testUnitFilter('hueRotate', 90, 'deg', [{hueRotate: 90}]); + testUnitFilter('hueRotate', 1.5708, 'rad', [ + {hueRotate: (180 * 1.5708) / Math.PI}, + ]); + testUnitFilter('hueRotate', -90, 'deg', [{hueRotate: -90}]); + testUnitFilter('hueRotate', 1.5, 'grad', []); + testNumericFilter('hueRotate', 90, []); + testUnitFilter('hueRotate', 50, '%', []); + + it('multiple filters', () => { + expect( + processFilter([ + {brightness: 0.5}, + {opacity: 0.5}, + {blur: 5}, + {hueRotate: '90deg'}, + ]), + ).toEqual([{brightness: 0.5}, {opacity: 0.5}, {blur: 5}, {hueRotate: 90}]); + }); + it('multiple filters one invalid', () => { + expect( + processFilter([ + {brightness: 0.5}, + {opacity: 0.5}, + {blur: 5}, + {hueRotate: '90foo'}, + ]), + ).toEqual([]); + }); + it('multiple same filters', () => { + expect( + processFilter([ + {brightness: 0.5}, + {brightness: 0.5}, + {brightness: 0.5}, + {brightness: 0.5}, + ]), + ).toEqual([ + {brightness: 0.5}, + {brightness: 0.5}, + {brightness: 0.5}, + {brightness: 0.5}, + ]); + }); + it('empty', () => { + expect(processFilter([])).toEqual([]); + }); + it('Non filter', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter([{foo: 5}])).toEqual([]); + }); + it('Invalid amount type', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter([{brightness: {}}])).toEqual([]); + }); + it('string multiple filters', () => { + expect( + processFilter('brightness(0.5) opacity(0.5) blur(5) hueRotate(90deg)'), + ).toEqual([{brightness: 0.5}, {opacity: 0.5}, {blur: 5}, {hueRotate: 90}]); + }); + it('string multiple filters one invalid', () => { + expect( + processFilter('brightness(0.5) opacity(0.5) blur(5) hueRotate(90foo)'), + ).toEqual([]); + }); + it('string multiple same filters', () => { + expect( + processFilter( + 'brightness(0.5) brightness(0.5) brightness(0.5) brightness(0.5)', + ), + ).toEqual([ + {brightness: 0.5}, + {brightness: 0.5}, + {brightness: 0.5}, + {brightness: 0.5}, + ]); + }); + it('string empty', () => { + expect(processFilter('')).toEqual([]); + }); + it('string non filter', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('foo: 5')).toEqual([]); + }); + it('string invalid amount type', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('brightness: {}')).toEqual([]); + }); + it('string brightness(.5)', () => { + // $FlowExpectedError[incompatible-call] + expect(processFilter('brightness(.5)')).toEqual([{brightness: 0.5}]); + }); +}); + +function testStandardFilter(filter: string): void { + const value = 0.5; + const expected = createFilterPrimitive(filter, value); + const percentExpected = createFilterPrimitive(filter, value / 100); + + testNumericFilter(filter, value, [expected]); + testNumericFilter(filter, -value, []); + testUnitFilter(filter, value, 'px', [expected]); + testUnitFilter(filter, value, '%', [percentExpected]); +} + +function testNumericFilter( + filter: string, + value: number, + expected: Array, +): void { + const filterObject = createFilterPrimitive(filter, value); + const filterString = filter + '(' + value.toString() + ')'; + + it(filterString, () => { + expect(processFilter([filterObject])).toEqual(expected); + }); + it('string ' + filterString, () => { + expect(processFilter(filterString)).toEqual(expected); + }); +} + +function testUnitFilter( + filter: string, + value: number, + unit: string, + expected: Array, +): void { + const unitAmount = value + unit; + const filterObject = createFilterPrimitive(filter, unitAmount); + const filterString = filter + '(' + unitAmount + ')'; + + it(filterString, () => { + expect(processFilter([filterObject])).toEqual(expected); + }); + it('string ' + filterString, () => { + expect(processFilter(filterString)).toEqual(expected); + }); +} + +function createFilterPrimitive( + filter: string, + value: number | string, +): FilterPrimitive { + switch (filter) { + case 'brightness': + return {brightness: value}; + case 'blur': + return {blur: value}; + case 'contrast': + return {contrast: value}; + case 'grayscale': + return {grayscale: value}; + case 'hueRotate': + return {hueRotate: value}; + case 'invert': + return {invert: value}; + case 'opacity': + return {opacity: value}; + case 'saturate': + return {saturate: value}; + case 'sepia': + return {sepia: value}; + default: + throw new Error('Invalid filter: ' + filter); + } +} diff --git a/packages/react-native/Libraries/StyleSheet/processFilter.js b/packages/react-native/Libraries/StyleSheet/processFilter.js new file mode 100644 index 00000000000..ca129b429e6 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/processFilter.js @@ -0,0 +1,132 @@ +/** + * 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. + * + * @format + * @flow + */ + +'use strict'; + +export type FilterPrimitive = + | {brightness: number | string} + | {blur: number | string} + | {contrast: number | string} + | {grayscale: number | string} + | {hueRotate: number | string} + | {invert: number | string} + | {opacity: number | string} + | {saturate: number | string} + | {sepia: number | string}; + +export default function processFilter( + filter: $ReadOnlyArray | string, +): $ReadOnlyArray { + let result: Array = []; + if (typeof filter === 'string') { + // matches on functions with args like "brightness(1.5)" + const regex = new RegExp(/(\w+)\(([^)]+)\)/g); + let matches; + + while ((matches = regex.exec(filter))) { + const amount = _getFilterAmount(matches[1], matches[2]); + + if (amount != null) { + const filterPrimitive = {}; + // $FlowFixMe The key will be the correct one but flow can't see that. + filterPrimitive[matches[1]] = amount; + // $FlowFixMe The key will be the correct one but flow can't see that. + result.push(filterPrimitive); + } else { + // If any primitive is invalid then apply none of the filters. This is how + // web works and makes it clear that something is wrong becuase no + // graphical effects are happening. + return []; + } + } + } else { + for (const filterPrimitive of filter) { + const [filterName, filterValue] = Object.entries(filterPrimitive)[0]; + const amount = _getFilterAmount(filterName, filterValue); + + if (amount != null) { + const resultObject = {}; + // $FlowFixMe + resultObject[filterName] = amount; + // $FlowFixMe + result.push(resultObject); + } else { + // If any primitive is invalid then apply none of the filters. This is how + // web works and makes it clear that something is wrong becuase no + // graphical effects are happening. + return []; + } + } + } + + return result; +} + +function _getFilterAmount(filterName: string, filterArgs: mixed): ?number { + let filterArgAsNumber: number; + let unit: string; + if (typeof filterArgs === 'string') { + // matches on args with units like "1.5 5% -80deg" + const argsWithUnitsRegex = new RegExp(/([+-]?\d*(\.\d+)?)([a-zA-Z%]+)?/g); + const match = argsWithUnitsRegex.exec(filterArgs); + + if (!match || isNaN(Number(match[1]))) { + return undefined; + } + + filterArgAsNumber = Number(match[1]); + unit = match[3]; + } else if (typeof filterArgs === 'number') { + filterArgAsNumber = filterArgs; + } else { + return undefined; + } + + switch (filterName) { + // Hue rotate takes some angle that can have a unit and can be + // negative. Additionally, 0 with no unit is allowed. + case 'hueRotate': + if (filterArgAsNumber === 0) { + return 0; + } + if (unit !== 'deg' && unit !== 'rad') { + return undefined; + } + return unit === 'rad' + ? (180 * filterArgAsNumber) / Math.PI + : filterArgAsNumber; + // blur takes any positive CSS length that is not a percent. In RN + // we currently only have DIPs, so we are not parsing units here. + case 'blur': + if ((unit && unit !== 'px') || filterArgAsNumber < 0) { + return undefined; + } + return filterArgAsNumber; + // All other filters except take a non negative number or percentage. There + // are no units associated with this value and percentage numbers map 1-to-1 + // to a non-percentage number (e.g. 50% == 0.5). + case 'brightness': + case 'contrast': + case 'grayscale': + case 'invert': + case 'opacity': + case 'saturate': + case 'sepia': + if ((unit && unit !== '%' && unit !== 'px') || filterArgAsNumber < 0) { + return undefined; + } + if (unit === '%') { + filterArgAsNumber /= 100; + } + return filterArgAsNumber; + default: + return undefined; + } +} diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 6e230c20b77..9de191cf068 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -7639,10 +7639,14 @@ export type ____ShadowStyle_Internal = $ReadOnly<{ ...____ShadowStyle_InternalCore, ...____ShadowStyle_InternalOverrides, }>; +type ____FilterStyle_Internal = $ReadOnly<{ + experimental_filter?: $ReadOnlyArray, +}>; export type ____ViewStyle_InternalCore = $ReadOnly<{ ...$Exact<____LayoutStyle_Internal>, ...$Exact<____ShadowStyle_Internal>, ...$Exact<____TransformStyle_Internal>, + ...____FilterStyle_Internal, backfaceVisibility?: \\"visible\\" | \\"hidden\\", backgroundColor?: ____ColorValue_Internal, borderColor?: ____ColorValue_Internal, @@ -7939,6 +7943,23 @@ declare module.exports: processColorArray; " `; +exports[`public API should not change unintentionally Libraries/StyleSheet/processFilter.js 1`] = ` +"export type FilterPrimitive = + | { brightness: number | string } + | { blur: number | string } + | { contrast: number | string } + | { grayscale: number | string } + | { hueRotate: number | string } + | { invert: number | string } + | { opacity: number | string } + | { saturate: number | string } + | { sepia: number | string }; +declare export default function processFilter( + filter: $ReadOnlyArray | string +): $ReadOnlyArray; +" +`; + exports[`public API should not change unintentionally Libraries/StyleSheet/processFontVariant.js 1`] = ` "declare function processFontVariant( fontVariant: ____FontVariantArray_Internal | string