feat: linear gradient android (#45433)

Summary:
- Adds `background` prop that supports CSS's linear gradient. Later this can be extended to support various other gradients and possibly CSS's background image (less motivation as better solutions exists for image)
- Extended `CSSBackgroundDrawable` to draw Linear Gradient shader while preserving the border style support.
- Style supports JS object to specify `LinearGradient`, so it can support Animated libraries.

## Changelog:
[ANDROID] [ADDED] - linear gradient

<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

[ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests

Pull Request resolved: https://github.com/facebook/react-native/pull/45433

Test Plan:
- Check out `processBackground-test.js` for supported syntax testcases.
- Checkout examples added in `LinearGradientExample.js`

Although the PR is tested well but open to any changes/feedback on the approach taken.

iOS PR - https://github.com/facebook/react-native/pull/45433. Separated the PRs to keep it easier to review. Both PRs can be reviewed individually.

Reviewed By: joevilches

Differential Revision: D60493360

Pulled By: NickGerleman

fbshipit-source-id: 762929c4fe16d87cbbd9ebe83ecce96a9e13192c
This commit is contained in:
Nishan
2024-08-01 09:38:51 -07:00
committed by Facebook GitHub Bot
parent 6483a28b3a
commit bd0aedc8c3
22 changed files with 1177 additions and 2 deletions
@@ -11,6 +11,7 @@
import type {AnyAttributeType} from '../../Renderer/shims/ReactNativeTypes';
import processAspectRatio from '../../StyleSheet/processAspectRatio';
import processBackgroundImage from '../../StyleSheet/processBackgroundImage';
import processBoxShadow from '../../StyleSheet/processBoxShadow';
import processColor from '../../StyleSheet/processColor';
import processFilter from '../../StyleSheet/processFilter';
@@ -131,6 +132,11 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
*/
experimental_boxShadow: {process: processBoxShadow},
/**
* Linear Gradient
*/
experimental_backgroundImage: {process: processBackgroundImage},
/**
* View
*/
@@ -102,6 +102,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
overflow: true,
backfaceVisibility: true,
experimental_layoutConformance: true,
experimental_backgroundImage: {
process: require('../../StyleSheet/processBackgroundImage').default,
},
},
}
: {
@@ -14,6 +14,8 @@ import processBoxShadow from '../StyleSheet/processBoxShadow';
const ReactNativeStyleAttributes = require('../Components/View/ReactNativeStyleAttributes');
const resolveAssetSource = require('../Image/resolveAssetSource');
const processBackgroundImage =
require('../StyleSheet/processBackgroundImage').default;
const processColor = require('../StyleSheet/processColor').default;
const processColorArray = require('../StyleSheet/processColorArray');
const processFilter = require('../StyleSheet/processFilter').default;
@@ -193,6 +195,8 @@ function getProcessorForType(typeName: string): ?(nextProp: any) => any {
return processColorArray;
case 'Filter':
return processFilter;
case 'BackgroundImage':
return processBackgroundImage;
case 'ImageSource':
return resolveAssetSource;
case 'BoxShadow':
@@ -273,6 +273,16 @@ export type BlendMode =
| 'color'
| 'luminosity';
export type GradientValue = {
type: 'linearGradient';
// Angle or direction enums
direction: string | undefined;
colorStops: Array<{
color: ColorValue;
position: number | undefined;
}>;
};
/**
* @see https://reactnative.dev/docs/view#style
*/
@@ -709,6 +709,16 @@ export type DropShadowPrimitive = {
color?: ____ColorValue_Internal,
};
export type GradientValue = {
type: 'linearGradient',
// Angle or direction enums
direction?: string,
colorStops: $ReadOnlyArray<{
color: ____ColorValue_Internal,
position?: string,
}>,
};
export type BoxShadowPrimitive = {
offsetX: number | string,
offsetY: number | string,
@@ -781,6 +791,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{
experimental_boxShadow?: $ReadOnlyArray<BoxShadowPrimitive> | string,
experimental_filter?: $ReadOnlyArray<FilterFunction> | string,
experimental_mixBlendMode?: ____BlendMode_Internal,
experimental_backgroundImage?: $ReadOnlyArray<GradientValue> | string,
}>;
export type ____ViewStyle_Internal = $ReadOnly<{
@@ -0,0 +1,381 @@
/**
* 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
*/
'use strict';
import processBackgroundImage from '../processBackgroundImage';
const processColor = require('../processColor').default;
describe('processBackgroundImage', () => {
it('should process a simple linear gradient string', () => {
const input = 'linear-gradient(to right, red, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([
{
type: 'linearGradient',
start: {x: 0, y: 0.5},
end: {x: 1, y: 0.5},
colorStops: [
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
],
},
]);
});
it('should process a diagonal linear gradient', () => {
const input = 'linear-gradient(to bottom right, red, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([
{
type: 'linearGradient',
start: {x: 0, y: 0},
end: {x: 1, y: 1},
colorStops: [
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
],
},
]);
});
it('should return empty array for null values', () => {
let result = processBackgroundImage('');
expect(result).toEqual([]);
result = processBackgroundImage(null);
expect(result).toEqual([]);
result = processBackgroundImage(undefined);
expect(result).toEqual([]);
});
it('should return empty array for invalid values', () => {
let result = processBackgroundImage('linear-');
expect(result).toEqual([]);
});
it('should process a linear gradient with whitespaces in direction', () => {
const input = 'linear-gradient(to bottom right, red, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([
{
type: 'linearGradient',
start: {x: 0, y: 0},
end: {x: 1, y: 1},
colorStops: [
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
],
},
]);
});
it('should process a linear gradient with random whitespaces', () => {
const input =
' linear-gradient(to bottom right, red 30%, blue 80%) ';
const result = processBackgroundImage(input);
expect(result).toEqual([
{
type: 'linearGradient',
start: {x: 0, y: 0},
end: {x: 1, y: 1},
colorStops: [
{color: processColor('red'), position: 0.3},
{color: processColor('blue'), position: 0.8},
],
},
]);
});
it('should process a linear gradient with angle', () => {
const input = 'linear-gradient(45deg, red, blue)';
const result = processBackgroundImage(input);
expect(result[0].type).toBe('linearGradient');
expect(result[0].start.x).toBeCloseTo(0.146447, 5);
expect(result[0].start.y).toBeCloseTo(0.853553, 5);
expect(result[0].end.x).toBeCloseTo(0.853553, 5);
expect(result[0].end.y).toBeCloseTo(0.146447, 5);
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
]);
});
it('should process a linear gradient with case-insensitive angle', () => {
const input = 'linear-gradient(45Deg, red, blue)';
const result = processBackgroundImage(input);
expect(result[0].type).toBe('linearGradient');
expect(result[0].start.x).toBeCloseTo(0.146447, 5);
expect(result[0].start.y).toBeCloseTo(0.853553, 5);
expect(result[0].end.x).toBeCloseTo(0.853553, 5);
expect(result[0].end.y).toBeCloseTo(0.146447, 5);
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
]);
});
it('linear gradient case-insensitive string', () => {
const input = 'LiNeAr-GradieNt(To Bottom, Red, Blue)';
const result = processBackgroundImage(input);
expect(result[0].type).toBe('linearGradient');
expect(result[0].start).toEqual({x: 0.5, y: 0});
expect(result[0].end).toEqual({x: 0.5, y: 1});
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
]);
});
it('should process a linear gradient with case-insensitive direction enum', () => {
const input = 'linear-gradient(tO Right, red, blue)';
const result = processBackgroundImage(input);
expect(result[0].start).toEqual({x: 0, y: 0.5});
expect(result[0].end).toEqual({x: 1, y: 0.5});
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
]);
});
it('should process a linear gradient with case-insensitive colors', () => {
const input =
'linear-gradient(to right, Rgba(0, 0, 0, 0.5), Blue, Hsla(0, 100%, 50%, 0.5))';
const result = processBackgroundImage(input);
expect(result[0].start).toEqual({x: 0, y: 0.5});
expect(result[0].end).toEqual({x: 1, y: 0.5});
expect(result[0].colorStops).toEqual([
{color: processColor('rgba(0, 0, 0, 0.5)'), position: 0},
{color: processColor('blue'), position: 0.5},
{color: processColor('hsla(0, 100%, 50%, 0.5)'), position: 1},
]);
});
it('should process multiple linear gradients', () => {
const input = `
linear-gradient(to right, red, blue),
linear-gradient(to bottom, green, yellow)`;
const result = processBackgroundImage(input);
expect(result).toHaveLength(2);
expect(result[0].type).toEqual('linearGradient');
expect(result[0].start).toEqual({x: 0, y: 0.5});
expect(result[0].end).toEqual({x: 1, y: 0.5});
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
]);
expect(result[1].type).toEqual('linearGradient');
expect(result[1].start).toEqual({x: 0.5, y: 0});
expect(result[1].end).toEqual({x: 0.5, y: 1});
expect(result[1].colorStops).toEqual([
{color: processColor('green'), position: 0},
{color: processColor('yellow'), position: 1},
]);
});
it('should process a linear gradient with multiple color stops', () => {
const input = 'linear-gradient(to right, red 0%, green 50%, blue 100%)';
const result = processBackgroundImage(input);
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('green'), position: 0.5},
{color: processColor('blue'), position: 1},
]);
});
it('should add color stop postion if position is not specified', () => {
const input =
'linear-gradient(to right, red, green, blue 60%, yellow, purple)';
const result = processBackgroundImage(input);
expect(result[0].colorStops).toEqual([
{color: processColor('red'), position: 0},
{color: processColor('green'), position: 0.25},
{color: processColor('blue'), position: 0.6},
{color: processColor('yellow'), position: 0.75},
{color: processColor('purple'), position: 1},
]);
});
it('should process a linear gradient with rgba colors', () => {
const input =
'linear-gradient(to right, rgba(255,0,0,0.5), rgba(0,0,255,0.8))';
const result = processBackgroundImage(input);
expect(result[0].colorStops).toEqual([
{color: processColor('rgba(255,0,0,0.5)'), position: 0},
{color: processColor('rgba(0,0,255,0.8)'), position: 1},
]);
});
it('should process a linear gradient with hsl colors', () => {
const input = `linear-gradient(hsl(330, 100%, 45.1%), hsl(0, 100%, 50%))`;
const result = processBackgroundImage(input);
expect(result[0].colorStops).toEqual([
{color: processColor('hsl(330, 100%, 45.1%)'), position: 0},
{color: processColor('hsl(0, 100%, 50%)'), position: 1},
]);
});
it('should process a linear gradient without direction', () => {
const input = 'linear-gradient(#e66465, #9198e5)';
const result = processBackgroundImage(input);
expect(result[0].colorStops).toEqual([
{color: processColor('#e66465'), position: 0},
{color: processColor('#9198e5'), position: 1},
]);
});
it('should process multiple gradients with spaces', () => {
const input = `linear-gradient(to right ,
rgba(255,0,0,0.5), rgba(0,0,255,0.8)),
linear-gradient(to bottom , rgba(255,0,0,0.9) , rgba(0,0,255,0.2) )`;
const result = processBackgroundImage(input);
expect(result).toHaveLength(2);
expect(result[0].start).toEqual({x: 0, y: 0.5});
expect(result[0].end).toEqual({x: 1, y: 0.5});
expect(result[1].start).toEqual({x: 0.5, y: 0});
expect(result[1].end).toEqual({x: 0.5, y: 1});
expect(result[0].colorStops).toEqual([
{color: processColor('rgba(255,0,0,0.5)'), position: 0},
{color: processColor('rgba(0,0,255,0.8)'), position: 1},
]);
expect(result[1].colorStops).toEqual([
{color: processColor('rgba(255,0,0,0.9)'), position: 0},
{color: processColor('rgba(0,0,255,0.2)'), position: 1},
]);
});
it('should return empty array for invalid color in linear gradient', () => {
const input = 'linear-gradient(45deg, rede, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([]);
});
it('should return empty array for invalid angle in linear gradient', () => {
const input = 'linear-gradient(45 deg, red, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([]);
});
it('should return empty array for invalid direction enum in linear gradient', () => {
const input = 'linear-gradient(to left2, red, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([]);
});
it('should return empty array for invalid color stop unit', () => {
const input = 'linear-gradient(to left, red 5, blue)';
const result = processBackgroundImage(input);
expect(result).toEqual([]);
});
it('should process an array of style objects', () => {
const input = [
{
type: 'linearGradient',
direction: 'to bottom right',
colorStops: [
{color: 'red', position: '0%'},
{color: 'blue', position: '100%'},
],
},
];
const result = processBackgroundImage(input);
expect(result).toEqual([
{
type: 'linearGradient',
start: {x: 0, y: 0},
end: {x: 1, y: 1},
colorStops: [
{color: processColor('red'), position: 0},
{color: processColor('blue'), position: 1},
],
},
]);
});
it('should process an style object with default direction', () => {
const input = [
{
type: 'linearGradient',
colorStops: [{color: 'red'}, {color: 'blue'}],
},
];
const result = processBackgroundImage(input);
expect(result[0].start).toEqual({x: 0.5, y: 0});
expect(result[0].end).toEqual({x: 0.5, y: 1});
});
it('should process style object with direction enum', () => {
const input = [
{
type: 'linearGradient',
direction: 'to right',
colorStops: [{color: 'red'}, {color: 'blue'}],
},
];
const result = processBackgroundImage(input);
expect(result[0].start).toEqual({x: 0, y: 0.5});
expect(result[0].end).toEqual({x: 1, y: 0.5});
});
it('should process style object with direction angle', () => {
const input = [
{
type: 'linearGradient',
direction: '45deg',
colorStops: [{color: 'red'}, {color: 'blue'}],
},
];
const result = processBackgroundImage(input);
expect(result[0].start.x).toBeCloseTo(0.146447, 5);
expect(result[0].start.y).toBeCloseTo(0.853553, 5);
expect(result[0].end.x).toBeCloseTo(0.853553, 5);
expect(result[0].end.y).toBeCloseTo(0.146447, 5);
});
it('should process an style object with mix of default and undefined stop positions', () => {
const input = [
{
type: 'linearGradient',
colorStops: [
{color: 'red'},
{color: 'blue'},
{color: 'green'},
{color: 'purple', position: '80%'},
{color: 'pink'},
],
},
];
const result = processBackgroundImage(input);
expect(result[0].colorStops).toEqual([
{
color: processColor('red'),
position: 0,
},
{
color: processColor('blue'),
position: 0.25,
},
{
color: processColor('green'),
position: 0.5,
},
{
color: processColor('purple'),
position: 0.8,
},
{
color: processColor('pink'),
position: 1,
},
]);
});
});
@@ -0,0 +1,286 @@
/**
* 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 strict-local
*/
'use strict';
import type {ProcessedColorValue} from './processColor';
import type {GradientValue} from './StyleSheetTypes';
const processColor = require('./processColor').default;
const DIRECTION_REGEX =
/^to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?/;
const ANGLE_UNIT_REGEX = /^([+-]?\d*\.?\d+)(deg|grad|rad|turn)$/i;
const TO_BOTTOM_START_END_POINTS = {
start: {x: 0.5, y: 0},
end: {x: 0.5, y: 1},
};
type ParsedGradientValue = {
type: 'linearGradient',
start: {x: number, y: number},
end: {x: number, y: number},
colorStops: $ReadOnlyArray<{
color: ProcessedColorValue,
position: number,
}>,
};
export default function processBackgroundImage(
backgroundImage: ?($ReadOnlyArray<GradientValue> | string),
): $ReadOnlyArray<ParsedGradientValue> {
let result: $ReadOnlyArray<ParsedGradientValue> = [];
if (backgroundImage == null) {
return result;
}
if (typeof backgroundImage === 'string') {
result = parseCSSLinearGradient(backgroundImage);
} else if (Array.isArray(backgroundImage)) {
for (const bgImage of backgroundImage) {
const processedColorStops = [];
for (let index = 0; index < bgImage.colorStops.length; index++) {
const stop = bgImage.colorStops[index];
const processedColor = processColor(stop.color);
let processedPosition: number | null = null;
// Currently we only support percentage and undefined value for color stop position.
if (typeof stop.position === 'undefined') {
processedPosition =
bgImage.colorStops.length === 1
? 1
: index / (bgImage.colorStops.length - 1);
} else if (stop.position.endsWith('%')) {
processedPosition = parseFloat(stop.position) / 100;
} else {
// If a color stop position is invalid, return an empty array and do not apply gradient. Same as web.
return [];
}
if (processedColor != null) {
processedColorStops.push({
color: processedColor,
position: processedPosition,
});
} else {
// If a color is invalid, return an empty array and do not apply gradient. Same as web.
return [];
}
}
let points: {
start: ParsedGradientValue['start'],
end: ParsedGradientValue['end'],
} | null = null;
if (typeof bgImage.direction === 'undefined') {
points = TO_BOTTOM_START_END_POINTS;
} else if (ANGLE_UNIT_REGEX.test(bgImage.direction)) {
const angle = parseAngle(bgImage.direction);
if (angle != null) {
points = calculateStartEndPointsFromAngle(angle);
}
} else if (DIRECTION_REGEX.test(bgImage.direction)) {
const processedPoints = calculateStartEndPointsFromDirection(
bgImage.direction,
);
if (processedPoints != null) {
points = processedPoints;
}
}
if (points != null) {
result = result.concat({
type: 'linearGradient',
start: points.start,
end: points.end,
colorStops: processedColorStops,
});
}
}
}
return result;
}
function parseCSSLinearGradient(
cssString: string,
): $ReadOnlyArray<ParsedGradientValue> {
const gradients = [];
let match;
const linearGradientRegex = /linear-gradient\s*\(((?:\([^)]*\)|[^())])*)\)/gi;
while ((match = linearGradientRegex.exec(cssString))) {
const gradientContent = match[1];
const parts = gradientContent.split(',');
let points = TO_BOTTOM_START_END_POINTS;
const trimmedDirection = parts[0].trim().toLowerCase();
const colorStopRegex =
/\s*((?:(?:rgba?|hsla?)\s*\([^)]+\))|#[0-9a-fA-F]+|[a-zA-Z]+)(?:\s+([0-9.]+%?))?\s*/gi;
if (ANGLE_UNIT_REGEX.test(trimmedDirection)) {
const angle = parseAngle(trimmedDirection);
if (angle != null) {
points = calculateStartEndPointsFromAngle(angle);
parts.shift();
} else {
// If an angle is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
} else if (DIRECTION_REGEX.test(trimmedDirection)) {
const parsedPoints =
calculateStartEndPointsFromDirection(trimmedDirection);
if (parsedPoints != null) {
points = parsedPoints;
parts.shift();
} else {
// If a direction is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
} else if (!colorStopRegex.test(trimmedDirection)) {
// If first part is not an angle/direction or a color stop, return an empty array and do not apply any gradient. Same as web.
return [];
}
colorStopRegex.lastIndex = 0;
const colorStops = [];
const fullColorStopsStr = parts.join(',');
let colorStopMatch;
while ((colorStopMatch = colorStopRegex.exec(fullColorStopsStr))) {
const [, color, position] = colorStopMatch;
const processedColor = processColor(color.trim().toLowerCase());
if (
processedColor != null &&
(typeof position === 'undefined' || position.endsWith('%'))
) {
colorStops.push({
color: processedColor,
position: position ? parseFloat(position) / 100 : null,
});
} else {
// If a color or position is invalid, return an empty array and do not apply any gradient. Same as web.
return [];
}
}
gradients.push({
type: 'linearGradient',
start: points.start,
end: points.end,
colorStops: colorStops.map((stop, index, array) => ({
color: stop.color,
position:
stop.position ??
(array.length === 1 ? 1 : index / (array.length - 1)),
})),
});
}
return gradients;
}
function calculateStartEndPointsFromDirection(direction: string): ?{
start: {x: number, y: number},
end: {x: number, y: number},
} {
// Remove extra whitespace
const normalizedDirection = direction.replace(/\s+/g, ' ');
switch (normalizedDirection) {
case 'to right':
return {
start: {x: 0, y: 0.5},
end: {x: 1, y: 0.5},
};
case 'to left':
return {
start: {x: 1, y: 0.5},
end: {x: 0, y: 0.5},
};
case 'to bottom':
return TO_BOTTOM_START_END_POINTS;
case 'to top':
return {
start: {x: 0.5, y: 1},
end: {x: 0.5, y: 0},
};
case 'to bottom right':
case 'to right bottom':
return {
start: {x: 0, y: 0},
end: {x: 1, y: 1},
};
case 'to top left':
case 'to left top':
return {
start: {x: 1, y: 1},
end: {x: 0, y: 0},
};
case 'to bottom left':
case 'to left bottom':
return {
start: {x: 1, y: 0},
end: {x: 0, y: 1},
};
case 'to top right':
case 'to right top':
return {
start: {x: 0, y: 1},
end: {x: 1, y: 0},
};
default:
return null;
}
}
function calculateStartEndPointsFromAngle(angleRadians: number): {
start: {x: number, y: number},
end: {x: number, y: number},
} {
// Normalize angle to be between 0 and 2π
let angleRadiansNormalized = angleRadians % (2 * Math.PI);
if (angleRadiansNormalized < 0) {
angleRadiansNormalized += 2 * Math.PI;
}
const endX = 0.5 + 0.5 * Math.sin(angleRadiansNormalized);
const endY = 0.5 - 0.5 * Math.cos(angleRadiansNormalized);
const startX = 1 - endX;
const startY = 1 - endY;
return {
start: {x: startX, y: startY},
end: {x: endX, y: endY},
};
}
function parseAngle(angle: string): ?number {
const match = angle.match(ANGLE_UNIT_REGEX);
if (!match) {
return null;
}
const [, value, unit] = match;
const numericValue = parseFloat(value);
switch (unit) {
case 'deg':
return (numericValue * Math.PI) / 180;
case 'grad':
return (numericValue * Math.PI) / 200;
case 'rad':
return numericValue;
case 'turn':
return numericValue * 2 * Math.PI;
default:
return null;
}
}
@@ -8271,6 +8271,14 @@ export type DropShadowPrimitive = {
standardDeviation?: number | string,
color?: ____ColorValue_Internal,
};
export type GradientValue = {
type: \\"linearGradient\\",
direction?: string,
colorStops: $ReadOnlyArray<{
color: ____ColorValue_Internal,
position?: string,
}>,
};
export type BoxShadowPrimitive = {
offsetX: number | string,
offsetY: number | string,
@@ -8341,6 +8349,7 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{
experimental_boxShadow?: $ReadOnlyArray<BoxShadowPrimitive> | string,
experimental_filter?: $ReadOnlyArray<FilterFunction> | string,
experimental_mixBlendMode?: ____BlendMode_Internal,
experimental_backgroundImage?: $ReadOnlyArray<GradientValue> | string,
}>;
export type ____ViewStyle_Internal = $ReadOnly<{
...____ViewStyle_InternalCore,
@@ -8582,6 +8591,22 @@ declare module.exports: processAspectRatio;
"
`;
exports[`public API should not change unintentionally Libraries/StyleSheet/processBackgroundImage.js 1`] = `
"type ParsedGradientValue = {
type: \\"linearGradient\\",
start: { x: number, y: number },
end: { x: number, y: number },
colorStops: $ReadOnlyArray<{
color: ProcessedColorValue,
position: number,
}>,
};
declare export default function processBackgroundImage(
backgroundImage: ?($ReadOnlyArray<GradientValue> | string)
): $ReadOnlyArray<ParsedGradientValue>;
"
`;
exports[`public API should not change unintentionally Libraries/StyleSheet/processBoxShadow.js 1`] = `
"export type ParsedBoxShadow = {
offsetX: number,
@@ -5498,6 +5498,7 @@ public final class com/facebook/react/uimanager/ViewProps {
public static final field ASPECT_RATIO Ljava/lang/String;
public static final field AUTO Ljava/lang/String;
public static final field BACKGROUND_COLOR Ljava/lang/String;
public static final field BACKGROUND_IMAGE Ljava/lang/String;
public static final field BORDER_BLOCK_COLOR Ljava/lang/String;
public static final field BORDER_BLOCK_END_COLOR Ljava/lang/String;
public static final field BORDER_BLOCK_START_COLOR Ljava/lang/String;
@@ -6127,6 +6128,11 @@ public final class com/facebook/react/uimanager/style/ComputedBorderRadiusProp :
public static fun values ()[Lcom/facebook/react/uimanager/style/ComputedBorderRadiusProp;
}
public final class com/facebook/react/uimanager/style/Gradient {
public fun <init> (Lcom/facebook/react/bridge/ReadableMap;)V
public final fun getShader (Landroid/graphics/Rect;)Landroid/graphics/Shader;
}
public abstract class com/facebook/react/uimanager/style/LogicalEdge : java/lang/Enum {
public static final field ALL Lcom/facebook/react/uimanager/style/LogicalEdge;
public static final field BLOCK Lcom/facebook/react/uimanager/style/LogicalEdge;
@@ -8244,6 +8250,7 @@ public class com/facebook/react/views/view/ReactViewManager : com/facebook/react
public fun setBackfaceVisibility (Lcom/facebook/react/views/view/ReactViewGroup;Ljava/lang/String;)V
public synthetic fun setBackgroundColor (Landroid/view/View;I)V
public fun setBackgroundColor (Lcom/facebook/react/views/view/ReactViewGroup;I)V
public fun setBackgroundImage (Lcom/facebook/react/views/view/ReactViewGroup;Lcom/facebook/react/bridge/ReadableArray;)V
public fun setBorderColor (Lcom/facebook/react/views/view/ReactViewGroup;ILjava/lang/Integer;)V
public fun setBorderRadius (Lcom/facebook/react/views/view/ReactViewGroup;IF)V
public fun setBorderRadius (Lcom/facebook/react/views/view/ReactViewGroup;ILcom/facebook/react/bridge/Dynamic;)V
@@ -74,6 +74,7 @@ public object ViewProps {
// Props that affect more than just layout
public const val ENABLED: String = "enabled"
public const val BACKGROUND_COLOR: String = "backgroundColor"
public const val BACKGROUND_IMAGE: String = "experimental_backgroundImage"
public const val FOREGROUND_COLOR: String = "foregroundColor"
public const val COLOR: String = "color"
public const val FONT_SIZE: String = "fontSize"
@@ -12,15 +12,18 @@ import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.ComposeShader;
import android.graphics.DashPathEffect;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathEffect;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import android.view.View;
import androidx.annotation.Nullable;
@@ -38,6 +41,7 @@ import com.facebook.react.uimanager.style.BorderRadiusProp;
import com.facebook.react.uimanager.style.BorderRadiusStyle;
import com.facebook.react.uimanager.style.BorderStyle;
import com.facebook.react.uimanager.style.ComputedBorderRadius;
import com.facebook.react.uimanager.style.Gradient;
import java.util.Locale;
import java.util.Objects;
@@ -109,6 +113,7 @@ public class CSSBackgroundDrawable extends Drawable {
/* Used by all types of background and for drawing borders */
private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private int mColor = Color.TRANSPARENT;
private @Nullable Gradient[] mGradients = null;
private int mAlpha = 255;
// There is a small gap between the edges of adjacent paths
@@ -328,6 +333,11 @@ public class CSSBackgroundDrawable extends Drawable {
invalidateSelf();
}
public void setGradients(Gradient[] gradients) {
mGradients = gradients;
invalidateSelf();
}
@VisibleForTesting
public int getColor() {
return mColor;
@@ -377,12 +387,19 @@ public class CSSBackgroundDrawable extends Drawable {
// Draws the View without its border first (with background color fill)
int useColor = ColorUtils.setAlphaComponent(mColor, getOpacity());
if (Color.alpha(useColor) != 0) { // color is not transparent
if (Color.alpha(useColor) != 0) {
mPaint.setColor(useColor);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawPath(Preconditions.checkNotNull(mBackgroundColorRenderPath), mPaint);
}
if (mGradients != null && mGradients.length > 0) {
mPaint.setShader(getGradientShader());
mPaint.setStyle(Paint.Style.FILL);
canvas.drawPath(Preconditions.checkNotNull(mBackgroundColorRenderPath), mPaint);
mPaint.setShader(null);
}
final RectF borderWidth = getDirectionAwareBorderInsets();
int colorLeft = getBorderColor(Spacing.LEFT);
int colorTop = getBorderColor(Spacing.TOP);
@@ -1105,11 +1122,17 @@ public class CSSBackgroundDrawable extends Drawable {
mPaint.setStyle(Paint.Style.FILL);
int useColor = multiplyColorAlpha(mColor, mAlpha);
if (Color.alpha(useColor) != 0) { // color is not transparent
if (Color.alpha(useColor) != 0) {
mPaint.setColor(useColor);
canvas.drawRect(getBounds(), mPaint);
}
if (mGradients != null && mGradients.length > 0) {
mPaint.setShader(getGradientShader());
canvas.drawRect(getBounds(), mPaint);
mPaint.setShader(null);
}
final RectF borderWidth = getDirectionAwareBorderInsets();
final int borderLeft = Math.round(borderWidth.left);
@@ -1401,6 +1424,27 @@ public class CSSBackgroundDrawable extends Drawable {
return new RectF(borderLeftWidth, borderTopWidth, borderRightWidth, borderBottomWidth);
}
private @Nullable Shader getGradientShader() {
if (mGradients == null) {
return null;
}
Shader compositeShader = null;
for (Gradient gradient : mGradients) {
Shader currentShader = gradient.getShader(getBounds());
if (currentShader == null) {
continue;
}
if (compositeShader == null) {
compositeShader = currentShader;
} else {
compositeShader =
new ComposeShader(currentShader, compositeShader, PorterDuff.Mode.SRC_OVER);
}
}
return compositeShader;
}
/**
* Multiplies the color with the given alpha.
*
@@ -0,0 +1,76 @@
/*
* 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.
*/
package com.facebook.react.uimanager.style
import android.graphics.LinearGradient
import android.graphics.Rect
import android.graphics.Shader
import com.facebook.react.bridge.ReadableMap
public class Gradient(gradient: ReadableMap?) {
private enum class GradientType {
LINEAR_GRADIENT
}
private val type: GradientType
private var startX: Float = 0f
private var startY: Float = 0f
private var endX: Float = 0f
private var endY: Float = 0f
private val colors: IntArray
private val positions: FloatArray
init {
gradient ?: throw IllegalArgumentException("Gradient cannot be null")
val typeString = gradient.getString("type")
type =
when (typeString) {
"linearGradient" -> GradientType.LINEAR_GRADIENT
else -> throw IllegalArgumentException("Unsupported gradient type: $typeString")
}
gradient.getMap("start")?.let { start ->
startX = start.getDouble("x").toFloat()
startY = start.getDouble("y").toFloat()
}
gradient.getMap("end")?.let { end ->
endX = end.getDouble("x").toFloat()
endY = end.getDouble("y").toFloat()
}
val colorStops =
gradient.getArray("colorStops")
?: throw IllegalArgumentException("Invalid colorStops array")
val size = colorStops.size()
colors = IntArray(size)
positions = FloatArray(size)
for (i in 0 until size) {
val colorStop = colorStops.getMap(i)
colors[i] = colorStop.getInt("color")
positions[i] = colorStop.getDouble("position").toFloat()
}
}
public fun getShader(bounds: Rect): Shader? {
return when (type) {
GradientType.LINEAR_GRADIENT ->
LinearGradient(
startX * bounds.width(),
startY * bounds.height(),
endX * bounds.width(),
endY * bounds.height(),
colors,
positions,
Shader.TileMode.CLAMP)
}
}
}
@@ -31,6 +31,7 @@ import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactNoCrashSoftException;
import com.facebook.react.bridge.ReactSoftExceptionLogger;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.common.annotations.UnstableReactNativeAPI;
import com.facebook.react.common.annotations.VisibleForTesting;
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
import com.facebook.react.modules.i18nmanager.I18nUtil;
@@ -59,6 +60,7 @@ import com.facebook.react.uimanager.drawable.CSSBackgroundDrawable;
import com.facebook.react.uimanager.style.BorderRadiusProp;
import com.facebook.react.uimanager.style.BorderStyle;
import com.facebook.react.uimanager.style.ComputedBorderRadius;
import com.facebook.react.uimanager.style.Gradient;
import com.facebook.react.uimanager.style.LogicalEdge;
import com.facebook.react.uimanager.style.Overflow;
@@ -233,6 +235,11 @@ public class ReactViewGroup extends ViewGroup
}
}
@UnstableReactNativeAPI
/*package*/ void setGradients(@Nullable Gradient[] gradient) {
getOrCreateReactViewBackground().setGradients(gradient);
}
@Deprecated(since = "0.66.0", forRemoval = true)
public void setTranslucentBackgroundDrawable(@Nullable Drawable background) {
if (ReactNativeFeatureFlags.enableBackgroundStyleApplicator()) {
@@ -12,6 +12,7 @@ import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.Nullable;
import com.facebook.common.logging.FLog;
import com.facebook.infer.annotation.Nullsafe;
import com.facebook.react.bridge.Dynamic;
import com.facebook.react.bridge.DynamicFromObject;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
@@ -33,14 +34,18 @@ import com.facebook.react.uimanager.UIManagerHelper;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.annotations.ReactPropGroup;
import com.facebook.react.uimanager.common.UIManagerType;
import com.facebook.react.uimanager.common.ViewUtil;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.style.BorderRadiusProp;
import com.facebook.react.uimanager.style.BorderStyle;
import com.facebook.react.uimanager.style.Gradient;
import com.facebook.react.uimanager.style.LogicalEdge;
import java.util.Map;
/** View manager for AndroidViews (plain React Views). */
@ReactModule(name = ReactViewManager.REACT_CLASS)
@Nullsafe(Nullsafe.Mode.LOCAL)
public class ReactViewManager extends ReactClippingViewManager<ReactViewGroup> {
@VisibleForTesting public static final String REACT_CLASS = ViewProps.VIEW_CLASS_NAME;
@@ -92,6 +97,22 @@ public class ReactViewManager extends ReactClippingViewManager<ReactViewGroup> {
}
}
@ReactProp(name = ViewProps.BACKGROUND_IMAGE, customType = "BackgroundImage")
public void setBackgroundImage(ReactViewGroup view, @Nullable ReadableArray backgroundImage) {
if (ViewUtil.getUIManagerType(view) == UIManagerType.FABRIC) {
if (backgroundImage != null && backgroundImage.size() > 0) {
Gradient[] gradients = new Gradient[backgroundImage.size()];
for (int i = 0; i < backgroundImage.size(); i++) {
ReadableMap gradientMap = backgroundImage.getMap(i);
gradients[i] = new Gradient(gradientMap);
}
view.setGradients(gradients);
} else {
view.setGradients(null);
}
}
}
@ReactProp(name = "nextFocusDown", defaultInt = View.NO_ID)
public void nextFocusDown(ReactViewGroup view, int viewId) {
view.setNextFocusDownId(viewId);
@@ -166,6 +166,15 @@ BaseViewProps::BaseViewProps(
"experimental_filter",
sourceProps.filter,
{})),
backgroundImage(
CoreFeatures::enablePropIteratorSetter
? sourceProps.backgroundImage
: convertRawProp(
context,
rawProps,
"experimental_backgroundImage",
sourceProps.backgroundImage,
{})),
mixBlendMode(
CoreFeatures::enablePropIteratorSetter
? sourceProps.mixBlendMode
@@ -311,6 +320,7 @@ void BaseViewProps::setProp(
switch (hash) {
RAW_SET_PROP_SWITCH_CASE_BASIC(opacity);
RAW_SET_PROP_SWITCH_CASE_BASIC(backgroundColor);
RAW_SET_PROP_SWITCH_CASE(backgroundImage, "experimental_backgroundImage");
RAW_SET_PROP_SWITCH_CASE_BASIC(shadowColor);
RAW_SET_PROP_SWITCH_CASE_BASIC(shadowOffset);
RAW_SET_PROP_SWITCH_CASE_BASIC(shadowOpacity);
@@ -13,6 +13,7 @@
#include <react/renderer/core/LayoutMetrics.h>
#include <react/renderer/core/Props.h>
#include <react/renderer/core/PropsParserContext.h>
#include <react/renderer/graphics/BackgroundImage.h>
#include <react/renderer/graphics/BlendMode.h>
#include <react/renderer/graphics/BoxShadow.h>
#include <react/renderer/graphics/Color.h>
@@ -63,6 +64,9 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
// Filter
std::vector<FilterFunction> filter{};
// Gradient
std::vector<GradientValue> backgroundImage{};
// MixBlendMode
BlendMode mixBlendMode;
@@ -68,6 +68,7 @@ void ViewShadowNode::initialize() noexcept {
bool formsView = formsStackingContext ||
isColorMeaningful(viewProps.backgroundColor) || hasBorder() ||
!viewProps.testId.empty() || !viewProps.boxShadow.empty() ||
!viewProps.backgroundImage.empty() ||
HostPlatformViewTraitsInitializer::formsView(viewProps);
if (formsView) {
@@ -15,6 +15,8 @@
#include <react/renderer/core/LayoutMetrics.h>
#include <react/renderer/core/PropsParserContext.h>
#include <react/renderer/core/RawProps.h>
#include <react/renderer/core/graphicsConversions.h>
#include <react/renderer/graphics/BackgroundImage.h>
#include <react/renderer/graphics/BlendMode.h>
#include <react/renderer/graphics/BoxShadow.h>
#include <react/renderer/graphics/Filter.h>
@@ -1091,6 +1093,97 @@ inline void fromRawValue(
result = blendMode.value();
}
inline void fromRawValue(
const PropsParserContext& context,
const RawValue& value,
std::vector<GradientValue>& result) {
react_native_expect(value.hasType<std::vector<RawValue>>());
if (!value.hasType<std::vector<RawValue>>()) {
result = {};
return;
}
std::vector<GradientValue> backgroundImage{};
auto rawBackgroundImage = static_cast<std::vector<RawValue>>(value);
for (const auto& rawGradientValue : rawBackgroundImage) {
bool isMap =
rawGradientValue.hasType<std::unordered_map<std::string, RawValue>>();
react_native_expect(isMap);
if (!isMap) {
result = {};
return;
}
auto rawGradientValueMap =
static_cast<std::unordered_map<std::string, RawValue>>(
rawGradientValue);
GradientValue gradientValue{};
auto typeIt = rawGradientValueMap.find("type");
if (typeIt != rawGradientValueMap.end() &&
typeIt->second.hasType<std::string>()) {
gradientValue.type =
gradientTypeFromString((std::string)(typeIt->second));
}
auto startIt = rawGradientValueMap.find("start");
if (startIt != rawGradientValueMap.end() &&
startIt->second.hasType<std::unordered_map<std::string, RawValue>>()) {
auto startPoints = static_cast<std::unordered_map<std::string, RawValue>>(
startIt->second);
auto xIt = startPoints.find("x");
auto yIt = startPoints.find("y");
if (xIt != startPoints.end() && yIt != startPoints.end() &&
xIt->second.hasType<Float>() && yIt->second.hasType<Float>()) {
gradientValue.startX = (Float)(xIt->second);
gradientValue.startY = (Float)(yIt->second);
}
}
auto endIt = rawGradientValueMap.find("end");
if (endIt != rawGradientValueMap.end() &&
endIt->second.hasType<std::unordered_map<std::string, RawValue>>()) {
auto endPoints =
static_cast<std::unordered_map<std::string, RawValue>>(endIt->second);
auto xIt = endPoints.find("x");
auto yIt = endPoints.find("y");
if (xIt != endPoints.end() && yIt != endPoints.end() &&
xIt->second.hasType<Float>() && yIt->second.hasType<Float>()) {
gradientValue.endX = (Float)(xIt->second);
gradientValue.endY = (Float)(yIt->second);
}
}
auto colorStopsIt = rawGradientValueMap.find("colorStops");
if (colorStopsIt != rawGradientValueMap.end() &&
colorStopsIt->second.hasType<std::vector<RawValue>>()) {
auto rawColorStops =
static_cast<std::vector<RawValue>>(colorStopsIt->second);
for (const auto& stop : rawColorStops) {
if (stop.hasType<std::unordered_map<std::string, RawValue>>()) {
auto stopMap =
static_cast<std::unordered_map<std::string, RawValue>>(stop);
auto positionIt = stopMap.find("position");
auto colorIt = stopMap.find("color");
if (positionIt != stopMap.end() && colorIt != stopMap.end() &&
positionIt->second.hasType<Float>()) {
ColorStop colorStop{};
colorStop.position = (Float)(positionIt->second);
fromRawValue(context, colorIt->second, colorStop.color);
gradientValue.colorStops.push_back(colorStop);
}
}
}
}
backgroundImage.push_back(gradientValue);
}
result = backgroundImage;
}
template <size_t N>
inline std::string toString(const std::array<float, N> vec) {
std::string s;
@@ -0,0 +1,46 @@
/*
* 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.
*/
#pragma once
#include <react/renderer/graphics/Color.h>
#include <react/renderer/graphics/Float.h>
#include <optional>
#include <string>
namespace facebook::react {
struct ColorStop {
bool operator==(const ColorStop& other) const = default;
SharedColor color;
std::optional<Float> position;
};
enum class GradientType {
LinearGradient,
};
struct GradientValue {
bool operator==(const GradientValue& other) const = default;
GradientType type;
Float startX;
Float startY;
Float endX;
Float endY;
std::vector<ColorStop> colorStops;
};
inline GradientType gradientTypeFromString(const std::string& gradientType) {
if (gradientType == "linearGradient") {
return GradientType::LinearGradient;
} else {
throw std::invalid_argument(std::string(gradientType));
}
}
}; // namespace facebook::react
+5
View File
@@ -33,6 +33,7 @@
*/
import {
GradientValue,
BlendMode,
BoxShadowPrimitive,
DimensionValue,
@@ -155,5 +156,9 @@ declare module '.' {
| undefined;
experimental_filter?: ReadonlyArray<FilterFunction> | string | undefined;
experimental_mixBlendMode?: BlendMode | undefined;
experimental_backgroundImage?:
| ReadonlyArray<GradientValue>
| string
| undefined;
}
}
@@ -0,0 +1,129 @@
/**
* 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';
import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
type Props = $ReadOnly<{
style: ViewStyleProp,
testID?: string,
}>;
function GradientBox(props: Props): React.Node {
return (
<View style={[styles.box, props.style]} testID={props.testID}>
<Text style={styles.text}>Linear Gradient</Text>
</View>
);
}
const styles = StyleSheet.create({
box: {
width: 200,
height: 100,
justifyContent: 'center',
alignItems: 'center',
marginVertical: 10,
},
text: {
color: 'white',
fontWeight: 'bold',
},
});
exports.title = 'Linear Gradient';
exports.category = 'UI';
exports.description = 'Examples of linear gradients applied to views.';
exports.examples = [
{
title: 'Basic Linear Gradient',
description: 'Linear gradient from top to bottom',
render(): React.Node {
return (
<GradientBox
style={{
experimental_backgroundImage: 'linear-gradient(#e66465, #9198e5);',
}}
testID="linear-gradient-basic"
/>
);
},
},
{
title: 'Diagonal Gradient',
description: 'Linear gradient from top-left to bottom-right',
render(): React.Node {
return (
<GradientBox
testID="linear-gradient-diagonal"
style={{
experimental_backgroundImage:
'linear-gradient(to bottom right, yellow, green)',
}}
/>
);
},
},
{
title: 'Gradient with angle',
description: 'Linear gradient with angle',
render(): React.Node {
return (
<GradientBox
testID="linear-gradient-angle"
style={{
experimental_backgroundImage:
'linear-gradient(135deg, gray, brown)',
}}
/>
);
},
},
{
title: 'Multiple Color Stops',
render(): React.Node {
return (
<GradientBox
testID="linear-gradient-color-stops"
style={{
experimental_backgroundImage:
'linear-gradient(to right, red 0%, yellow 30%, green 60%, blue 100%)',
}}
/>
);
},
},
{
title: 'Linear gradient with object style syntax',
render(): React.Node {
return (
<GradientBox
testID="linear-gradient-object-style-syntax"
style={{
experimental_backgroundImage: [
{
type: 'linearGradient',
direction: 'to bottom',
colorStops: [
{color: 'purple', position: '0%'},
{color: 'orange', position: '100%'},
],
},
],
}}
/>
);
},
},
];
@@ -311,6 +311,11 @@ const APIs: Array<RNTesterModuleInfo> = ([
category: 'UI',
module: require('../examples/Filter/FilterExample'),
},
{
key: 'LinearGradient',
category: 'UI',
module: require('../examples/LinearGradient/LinearGradientExample'),
},
{
key: 'MixBlendModeExample',
category: 'UI',