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
This commit is contained in:
Joe Vilches
2024-05-08 13:39:09 -07:00
committed by Facebook GitHub Bot
parent c27f2ab747
commit 0dceac9f02
7 changed files with 378 additions and 0 deletions
@@ -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
*/
@@ -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},
@@ -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},
@@ -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<FilterPrimitive>,
}>;
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,
@@ -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<FilterPrimitive>,
): 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<FilterPrimitive>,
): 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);
}
}
@@ -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<FilterPrimitive> | string,
): $ReadOnlyArray<FilterPrimitive> {
let result: Array<FilterPrimitive> = [];
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;
}
}
@@ -7639,10 +7639,14 @@ export type ____ShadowStyle_Internal = $ReadOnly<{
...____ShadowStyle_InternalCore,
...____ShadowStyle_InternalOverrides,
}>;
type ____FilterStyle_Internal = $ReadOnly<{
experimental_filter?: $ReadOnlyArray<FilterPrimitive>,
}>;
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<FilterPrimitive> | string
): $ReadOnlyArray<FilterPrimitive>;
"
`;
exports[`public API should not change unintentionally Libraries/StyleSheet/processFontVariant.js 1`] = `
"declare function processFontVariant(
fontVariant: ____FontVariantArray_Internal | string