diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index bbbe3c18998..e739919845e 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -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 */ diff --git a/packages/react-native/Libraries/Components/View/ViewNativeComponent.js b/packages/react-native/Libraries/Components/View/ViewNativeComponent.js index a3cdea8a721..f97a2c823a5 100644 --- a/packages/react-native/Libraries/Components/View/ViewNativeComponent.js +++ b/packages/react-native/Libraries/Components/View/ViewNativeComponent.js @@ -102,6 +102,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = overflow: true, backfaceVisibility: true, experimental_layoutConformance: true, + experimental_backgroundImage: { + process: require('../../StyleSheet/processBackgroundImage').default, + }, }, } : { diff --git a/packages/react-native/Libraries/ReactNative/getNativeComponentAttributes.js b/packages/react-native/Libraries/ReactNative/getNativeComponentAttributes.js index 8e7ec4fea46..232cbc91487 100644 --- a/packages/react-native/Libraries/ReactNative/getNativeComponentAttributes.js +++ b/packages/react-native/Libraries/ReactNative/getNativeComponentAttributes.js @@ -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': diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index 49b96d5453d..ac67b1901b1 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -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 */ diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 2db5431fc56..c92c63016dc 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -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 | string, experimental_filter?: $ReadOnlyArray | string, experimental_mixBlendMode?: ____BlendMode_Internal, + experimental_backgroundImage?: $ReadOnlyArray | string, }>; export type ____ViewStyle_Internal = $ReadOnly<{ diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js new file mode 100644 index 00000000000..6a7c3c47e69 --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js @@ -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, + }, + ]); + }); +}); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js new file mode 100644 index 00000000000..975d132aaaf --- /dev/null +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -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 | string), +): $ReadOnlyArray { + let result: $ReadOnlyArray = []; + 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 { + 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; + } +} 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 e92361bd8cd..480504c75d9 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 @@ -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 | string, experimental_filter?: $ReadOnlyArray | string, experimental_mixBlendMode?: ____BlendMode_Internal, + experimental_backgroundImage?: $ReadOnlyArray | 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 | string) +): $ReadOnlyArray; +" +`; + exports[`public API should not change unintentionally Libraries/StyleSheet/processBoxShadow.js 1`] = ` "export type ParsedBoxShadow = { offsetX: number, diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index f928f8a25ab..5c99afe0589 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -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 (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 diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 97ccbbf0d35..dfcd023945b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -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" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CSSBackgroundDrawable.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CSSBackgroundDrawable.java index 644dc95cb56..05dbfb0ff70 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CSSBackgroundDrawable.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CSSBackgroundDrawable.java @@ -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. * diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt new file mode 100644 index 00000000000..14d02792431 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt @@ -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) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java index c1218f26c70..1bb6209492e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java @@ -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()) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java index 077fec274b1..b910dd8266d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.java @@ -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 { @VisibleForTesting public static final String REACT_CLASS = ViewProps.VIEW_CLASS_NAME; @@ -92,6 +97,22 @@ public class ReactViewManager extends ReactClippingViewManager { } } + @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); diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 76ee628a67d..f23240ec914 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -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); diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index 452e2bd9b53..1e37eec1d69 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +64,9 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { // Filter std::vector filter{}; + // Gradient + std::vector backgroundImage{}; + // MixBlendMode BlendMode mixBlendMode; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index b0d24f24d8f..49099f87ef7 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -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) { diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h index efa00946208..fdb4251b16e 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -1091,6 +1093,97 @@ inline void fromRawValue( result = blendMode.value(); } +inline void fromRawValue( + const PropsParserContext& context, + const RawValue& value, + std::vector& result) { + react_native_expect(value.hasType>()); + if (!value.hasType>()) { + result = {}; + return; + } + + std::vector backgroundImage{}; + auto rawBackgroundImage = static_cast>(value); + for (const auto& rawGradientValue : rawBackgroundImage) { + bool isMap = + rawGradientValue.hasType>(); + react_native_expect(isMap); + if (!isMap) { + result = {}; + return; + } + + auto rawGradientValueMap = + static_cast>( + rawGradientValue); + GradientValue gradientValue{}; + + auto typeIt = rawGradientValueMap.find("type"); + if (typeIt != rawGradientValueMap.end() && + typeIt->second.hasType()) { + gradientValue.type = + gradientTypeFromString((std::string)(typeIt->second)); + } + + auto startIt = rawGradientValueMap.find("start"); + if (startIt != rawGradientValueMap.end() && + startIt->second.hasType>()) { + auto startPoints = static_cast>( + startIt->second); + auto xIt = startPoints.find("x"); + auto yIt = startPoints.find("y"); + if (xIt != startPoints.end() && yIt != startPoints.end() && + xIt->second.hasType() && yIt->second.hasType()) { + gradientValue.startX = (Float)(xIt->second); + gradientValue.startY = (Float)(yIt->second); + } + } + + auto endIt = rawGradientValueMap.find("end"); + if (endIt != rawGradientValueMap.end() && + endIt->second.hasType>()) { + auto endPoints = + static_cast>(endIt->second); + auto xIt = endPoints.find("x"); + auto yIt = endPoints.find("y"); + if (xIt != endPoints.end() && yIt != endPoints.end() && + xIt->second.hasType() && yIt->second.hasType()) { + gradientValue.endX = (Float)(xIt->second); + gradientValue.endY = (Float)(yIt->second); + } + } + + auto colorStopsIt = rawGradientValueMap.find("colorStops"); + if (colorStopsIt != rawGradientValueMap.end() && + colorStopsIt->second.hasType>()) { + auto rawColorStops = + static_cast>(colorStopsIt->second); + + for (const auto& stop : rawColorStops) { + if (stop.hasType>()) { + auto stopMap = + static_cast>(stop); + auto positionIt = stopMap.find("position"); + auto colorIt = stopMap.find("color"); + + if (positionIt != stopMap.end() && colorIt != stopMap.end() && + positionIt->second.hasType()) { + 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 inline std::string toString(const std::array vec) { std::string s; diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h new file mode 100644 index 00000000000..f8b872de4f6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h @@ -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 +#include +#include +#include + +namespace facebook::react { + +struct ColorStop { + bool operator==(const ColorStop& other) const = default; + SharedColor color; + std::optional 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 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 diff --git a/packages/react-native/types/experimental.d.ts b/packages/react-native/types/experimental.d.ts index 859252932a9..870bed86133 100644 --- a/packages/react-native/types/experimental.d.ts +++ b/packages/react-native/types/experimental.d.ts @@ -33,6 +33,7 @@ */ import { + GradientValue, BlendMode, BoxShadowPrimitive, DimensionValue, @@ -155,5 +156,9 @@ declare module '.' { | undefined; experimental_filter?: ReadonlyArray | string | undefined; experimental_mixBlendMode?: BlendMode | undefined; + experimental_backgroundImage?: + | ReadonlyArray + | string + | undefined; } } diff --git a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js new file mode 100644 index 00000000000..b06fc501ae0 --- /dev/null +++ b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js @@ -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 ( + + Linear Gradient + + ); +} + +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 ( + + ); + }, + }, + { + title: 'Diagonal Gradient', + description: 'Linear gradient from top-left to bottom-right', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Gradient with angle', + description: 'Linear gradient with angle', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Multiple Color Stops', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'Linear gradient with object style syntax', + render(): React.Node { + return ( + + ); + }, + }, +]; diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index 7eb90f1f9d9..9e649d6bfc5 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -311,6 +311,11 @@ const APIs: Array = ([ category: 'UI', module: require('../examples/Filter/FilterExample'), }, + { + key: 'LinearGradient', + category: 'UI', + module: require('../examples/LinearGradient/LinearGradientExample'), + }, { key: 'MixBlendModeExample', category: 'UI',