From b22a3acdb9bac7b523758fe3035f755f1ed79a2b Mon Sep 17 00:00:00 2001 From: Muhammed Ibrahim Date: Tue, 25 Aug 2026 15:01:58 +0400 Subject: [PATCH 1/3] feat(styles): add conic gradient parsing --- .../Components/View/__tests__/View-itest.js | 37 +- .../__tests__/View-nativeCSSParsing-itest.js | 23 ++ .../Libraries/StyleSheet/StyleSheetTypes.js | 13 +- .../__tests__/processBackgroundImage-itest.js | 91 +++++ .../processBackgroundPosition-itest.js | 6 + .../StyleSheet/processBackgroundImage.js | 228 +++++++++--- .../StyleSheet/processBackgroundPosition.js | 32 +- .../view/BackgroundImagePropsConversions.cpp | 158 ++++++++- .../react/renderer/css/CSSBackgroundImage.h | 332 +++++++++++++++++- .../react/renderer/css/CSSKeyword.h | 3 + .../css/tests/CSSBackgroundImageTest.cpp | 39 ++ .../renderer/graphics/BackgroundImage.cpp | 4 +- .../react/renderer/graphics/BackgroundImage.h | 7 +- .../react/renderer/graphics/ConicGradient.cpp | 55 +++ .../react/renderer/graphics/ConicGradient.h | 39 ++ 15 files changed, 1006 insertions(+), 61 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.h diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js index 3c46277f3a84..3a663075da5c 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js @@ -352,24 +352,55 @@ describe('', () => { ], }} /> + + , ); }); - const expectedProps = { + const expectedRadialProps = { backgroundImage: '[radial-gradient(ellipse farthest-corner at 50% 50% , rgba(230, 100, 101, 1), rgba(145, 152, 229, 1))]', }; + const expectedConicProps = { + backgroundImage: + '[conic-gradient(from 45deg at 50% 50% , rgba(230, 100, 101, 1), rgba(145, 152, 229, 1))]', + }; expect(root.getRenderedOutput().toJSON()).toEqual([ { children: [], - props: expectedProps, + props: expectedRadialProps, + type: 'View', + }, + { + children: [], + props: expectedRadialProps, + type: 'View', + }, + { + children: [], + props: expectedConicProps, type: 'View', }, { children: [], - props: expectedProps, + props: expectedConicProps, type: 'View', }, ]); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js index 8aefac7b4248..033ee60229ea 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js @@ -71,5 +71,28 @@ describe(' native CSS parsing', () => { expect(backgroundImage).toContain('linear-gradient'); expect(backgroundImage).toContain('rgba(230, 100, 101, 1)'); }); + + it('parses a conic-gradient()', () => { + const backgroundImage = mountedProp( + { + backgroundImage: 'conic-gradient(from 45deg, #e66465, #9198e5)', + }, + 'backgroundImage', + ); + expect(backgroundImage).toBe( + '[conic-gradient(from 45deg at 50% 50% , rgba(230, 100, 101, 1), rgba(145, 152, 229, 1))]', + ); + }); + + it('parses a four-value conic-gradient position', () => { + expect( + mountedProp( + { + backgroundImage: 'conic-gradient(at top 20px left 10px, red, blue)', + }, + 'backgroundImage', + ), + ).toContain('at 10px 20px'); + }); }); }); diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 5a1951a1e2c6..f7f332c4b921 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -769,7 +769,18 @@ type RadialGradientValue = { }>, }; -export type BackgroundImageValue = LinearGradientValue | RadialGradientValue; +type ConicGradientValue = { + type: 'conic-gradient', + from?: string, + position?: RadialGradientPosition, + colorStops: ReadonlyArray<{ + color: ____ColorValue_Internal, + positions?: ReadonlyArray, + }>, +}; + +export type BackgroundImageValue = + LinearGradientValue | RadialGradientValue | ConicGradientValue; export type BackgroundSizeValue = { x: string | number, diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 5dc05646b27e..df98bfcb9b36 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js @@ -42,6 +42,97 @@ describe('processBackgroundImage', () => { ]); }); + it('should process a conic gradient string', () => { + const result = processBackgroundImage( + 'conic-gradient(from 45deg at 25% 75%, red 0deg, blue .5turn, red 100%)', + ); + + expect(result).toEqual([ + { + type: 'conic-gradient', + from: 45, + position: {left: '25%', top: '75%'}, + colorStops: [ + {color: processColor('red'), position: '0%'}, + {color: processColor('blue'), position: '50%'}, + {color: processColor('red'), position: '100%'}, + ], + }, + ]); + }); + + it('should process a conic gradient with four-value position', () => { + expect( + processBackgroundImage( + 'conic-gradient(at top 20px left 10px, red, blue)', + ), + ).toEqual([ + { + type: 'conic-gradient', + from: 0, + position: {top: 20, left: 10}, + colorStops: [ + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, + ], + }, + ]); + }); + + it('should process conic gradient object syntax', () => { + const input: ReadonlyArray = [ + { + type: 'conic-gradient', + from: '0.25turn', + position: {top: '50%', left: '50%'}, + colorStops: [ + {color: 'red', positions: ['0deg', '90deg']}, + {color: 'blue', positions: ['90deg', '360deg']}, + ], + }, + ]; + + expect(processBackgroundImage(input)).toEqual([ + { + type: 'conic-gradient', + from: 90, + position: {top: '50%', left: '50%'}, + colorStops: [ + {color: processColor('red'), position: '0%'}, + {color: processColor('red'), position: '25%'}, + {color: processColor('blue'), position: '25%'}, + {color: processColor('blue'), position: '100%'}, + ], + }, + ]); + }); + + it('should reject invalid conic gradient object stops', () => { + expect( + processBackgroundImage([ + { + type: 'conic-gradient', + colorStops: [{color: 'red'}], + }, + ]), + ).toEqual([]); + + const numericStop = [ + { + type: 'conic-gradient', + colorStops: [{color: 'red', positions: [50]}, {color: 'blue'}], + }, + ]; + // $FlowFixMe[incompatible-type] - verifies runtime validation. + expect(processBackgroundImage(numericStop)).toEqual([]); + }); + + it('should reject conic gradient length color stops', () => { + expect(processBackgroundImage('conic-gradient(red 10px, blue)')).toEqual( + [], + ); + }); + it('should process a diagonal linear gradient', () => { const input = 'linear-gradient(to bottom right, red, blue)'; const result = processBackgroundImage(input); diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundPosition-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundPosition-itest.js index c8d7a6049b33..376808b71131 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundPosition-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundPosition-itest.js @@ -287,6 +287,12 @@ describe('processBackgroundPosition', () => { ]); }); + it('should parse vertical position before horizontal position', () => { + expect(processBackgroundPosition('top 75% left 25%')).toEqual([ + {top: '75%', left: '25%'}, + ]); + }); + // Test multiple background positions (comma-separated) it('should parse multiple background positions', () => { expect(processBackgroundPosition('left top, right bottom')).toEqual([ diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 23c0b20cc525..ac1360153ef1 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -18,11 +18,14 @@ import type { RadialGradientSize, } from './StyleSheetTypes'; +import processBackgroundPosition from './processBackgroundPosition'; + const processColor = require('./processColor').default; // Pre-compiled regex patterns for performance - avoids regex compilation on each call const NEWLINE_REGEX = /\n/g; -const GRADIENT_REGEX = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/; +const GRADIENT_REGEX = + /^(linear|radial|conic)-gradient\(((?:\([^)]*\)|[^()])*)\)/; const COMMA_SPLIT_REGEX = /,(?![^(]*\))/; const WHITESPACE_SPLIT_REGEX = /\s+/; const COLOR_STOP_PARTS_REGEX = /\S+\([^)]*\)|\S+/g; @@ -70,13 +73,32 @@ type RadialGradientBackgroundImage = { }>, }; +// Conic Gradient +const DEFAULT_CONIC_FROM = 0; +const DEFAULT_CONIC_POSITION: RadialGradientPosition = { + top: '50%', + left: '50%', +}; + +type ConicGradientBackgroundImage = { + type: 'conic-gradient', + from: number, + position: RadialGradientPosition, + colorStops: ReadonlyArray<{ + color: ColorStopColor, + position: ColorStopPosition, + }>, +}; + // null color indicate that the transition hint syntax is used. e.g. red, 20%, blue type ColorStopColor = ProcessedColorValue | null; // percentage or pixel value type ColorStopPosition = number | string | null; type ParsedBackgroundImageValue = - LinearGradientBackgroundImage | RadialGradientBackgroundImage; + | LinearGradientBackgroundImage + | RadialGradientBackgroundImage + | ConicGradientBackgroundImage; export default function processBackgroundImage( backgroundImage: ?(ReadonlyArray | string), @@ -92,7 +114,10 @@ export default function processBackgroundImage( ); } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { - const processedColorStops = processColorStops(bgImage); + const processedColorStops = processColorStops( + bgImage, + bgImage.type === 'conic-gradient', + ); if (processedColorStops == null) { // If a color stop is invalid, return an empty array and do not apply any gradient. Same as web. return []; @@ -184,6 +209,29 @@ export default function processBackgroundImage( position, colorStops: processedColorStops, }); + } else if (bgImage.type === 'conic-gradient') { + let from = DEFAULT_CONIC_FROM; + if (bgImage.from != null) { + const parsedFrom = getAngleInDegrees(bgImage.from, true); + if (parsedFrom == null) { + return []; + } + from = parsedFrom; + } + + if ( + processedColorStops.filter(colorStop => colorStop.color != null) + .length < 2 + ) { + return []; + } + + result = result.concat({ + type: 'conic-gradient', + from, + position: bgImage.position ?? {...DEFAULT_CONIC_POSITION}, + colorStops: processedColorStops, + }); } } } @@ -191,7 +239,10 @@ export default function processBackgroundImage( return result; } -function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ +function processColorStops( + bgImage: BackgroundImageValue, + allowAngularPositions: boolean = false, +): ReadonlyArray<{ color: ColorStopColor, position: ColorStopPosition, }> | null { @@ -209,19 +260,17 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ Array.isArray(positions) && positions.length === 1 ) { - const position = positions[0]; - if ( - typeof position === 'number' || - (typeof position === 'string' && position.endsWith('%')) - ) { - processedColorStops.push({ - color: null, - position, - }); - } else { - // If a position is invalid, return null and do not apply gradient. Same as web. + const position = parseColorStopPosition( + positions[0], + allowAngularPositions, + ); + if (position == null) { return null; } + processedColorStops.push({ + color: null, + position, + }); } else { const processedColor = processColor(colorStop.color); if (processedColor == null) { @@ -229,19 +278,18 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ return null; } if (positions != null && positions.length > 0) { - for (const position of positions) { - if ( - typeof position === 'number' || - (typeof position === 'string' && position.endsWith('%')) - ) { - processedColorStops.push({ - color: processedColor, - position, - }); - } else { - // If a position is invalid, return null and do not apply gradient. Same as web. + for (const rawPosition of positions) { + const position = parseColorStopPosition( + rawPosition, + allowAngularPositions, + ); + if (position == null) { return null; } + processedColorStops.push({ + color: processedColor, + position, + }); } } else { processedColorStops.push({ @@ -266,10 +314,12 @@ function parseBackgroundImageCSSString( const match = GRADIENT_REGEX.exec(bgImage); if (match) { const [, type, gradientContent] = match; - const isRadial = type.toLowerCase() === 'radial'; - const gradient = isRadial - ? parseRadialGradientCSSString(gradientContent) - : parseLinearGradientCSSString(gradientContent); + const gradient = + type === 'radial' + ? parseRadialGradientCSSString(gradientContent) + : type === 'conic' + ? parseConicGradientCSSString(gradientContent) + : parseLinearGradientCSSString(gradientContent); if (gradient != null) { gradients.push(gradient); @@ -593,6 +643,64 @@ function parseRadialGradientCSSString( }; } +function parseConicGradientCSSString( + gradientContent: string, +): ConicGradientBackgroundImage | null { + const parts = gradientContent.split(COMMA_SPLIT_REGEX); + const firstPart = parts[0]?.trim() ?? ''; + let from = DEFAULT_CONIC_FROM; + let position: RadialGradientPosition = {...DEFAULT_CONIC_POSITION}; + let hasPrelude = false; + + if (firstPart.startsWith('from ')) { + const match = /^from\s+(\S+)(?:\s+at\s+(.+))?$/.exec(firstPart); + if (match == null) { + return null; + } + + const parsedFrom = getAngleInDegrees(match[1], true); + if (parsedFrom == null) { + return null; + } + from = parsedFrom; + hasPrelude = true; + + if (match[2] != null) { + const parsedPositions = processBackgroundPosition(match[2]); + if (parsedPositions.length !== 1) { + return null; + } + position = parsedPositions[0]; + } + } else if (firstPart.startsWith('at ')) { + const parsedPositions = processBackgroundPosition(firstPart.slice(3)); + if (parsedPositions.length !== 1) { + return null; + } + position = parsedPositions[0]; + hasPrelude = true; + } + + if (hasPrelude) { + parts.shift(); + } + + const colorStops = parseColorStopsCSSString(parts, true); + if ( + colorStops == null || + colorStops.filter(colorStop => colorStop.color != null).length < 2 + ) { + return null; + } + + return { + type: 'conic-gradient', + from, + position, + colorStops, + }; +} + function parseLinearGradientCSSString( gradientContent: string, ): LinearGradientBackgroundImage | null { @@ -636,7 +744,10 @@ function parseLinearGradientCSSString( }; } -function parseColorStopsCSSString(parts: Array): Array<{ +function parseColorStopsCSSString( + parts: Array, + allowAngularPositions: boolean = false, +): Array<{ color: ColorStopColor, position: ColorStopPosition, }> | null { @@ -660,8 +771,14 @@ function parseColorStopsCSSString(parts: Array): Array<{ // Case 1: [color, position, position] if (colorStopParts.length === 3) { const color = colorStopParts[0]; - const position1 = getPositionFromCSSValue(colorStopParts[1]); - const position2 = getPositionFromCSSValue(colorStopParts[2]); + const position1 = parseColorStopPosition( + colorStopParts[1], + allowAngularPositions, + ); + const position2 = parseColorStopPosition( + colorStopParts[2], + allowAngularPositions, + ); const processedColor = processColor(color); if (processedColor == null) { // If a color is invalid, return null and do not apply any gradient. Same as web. @@ -685,7 +802,10 @@ function parseColorStopsCSSString(parts: Array): Array<{ // Case 2: [color, position] else if (colorStopParts.length === 2) { const color = colorStopParts[0]; - const position = getPositionFromCSSValue(colorStopParts[1]); + const position = parseColorStopPosition( + colorStopParts[1], + allowAngularPositions, + ); const processedColor = processColor(color); if (processedColor == null) { // If a color is invalid, return null and do not apply any gradient. Same as web. @@ -703,13 +823,17 @@ function parseColorStopsCSSString(parts: Array): Array<{ // Case 3: [color] // Case 4: [position] => transition hint syntax else if (colorStopParts.length === 1) { - const position = getPositionFromCSSValue(colorStopParts[0]); + const position = parseColorStopPosition( + colorStopParts[0], + allowAngularPositions, + ); if (position != null) { // handle invalid transition hint syntax. transition hint syntax must have color before and after the position. e.g. red, 20%, blue if ( (prevStop != null && prevStop.length === 1 && - getPositionFromCSSValue(prevStop[0]) != null) || + parseColorStopPosition(prevStop[0], allowAngularPositions) != + null) || i === stops.length - 1 || i === 0 ) { @@ -776,32 +900,54 @@ function getDirectionForKeyword(direction?: string): ?LinearGradientDirection { } } -function getAngleInDegrees(angle?: string): ?number { +function getAngleInDegrees( + angle?: string, + allowUnitlessZero: boolean = false, +): ?number { if (angle == null) { return null; } + if (allowUnitlessZero && angle.trim() === '0') { + return 0; + } const match = angle.match(LINEAR_GRADIENT_ANGLE_UNIT_REGEX); if (!match) { return null; } const [, value, unit] = match; - const numericValue = parseFloat(value); - switch (unit) { + switch (unit.toLowerCase()) { case 'deg': return numericValue; case 'grad': - return numericValue * 0.9; // 1 grad = 0.9 degrees + return numericValue * 0.9; case 'rad': return (numericValue * 180) / Math.PI; case 'turn': - return numericValue * 360; // 1 turn = 360 degrees + return numericValue * 360; default: return null; } } +function parseColorStopPosition( + position: string | number, + allowAngularPositions: boolean, +): ColorStopPosition { + if (typeof position === 'number') { + return allowAngularPositions ? null : position; + } + if (position.endsWith('%')) { + return position; + } + if (allowAngularPositions) { + const angle = getAngleInDegrees(position, true); + return angle == null ? null : `${angle / 3.6}%`; + } + return getPositionFromCSSValue(position) ?? null; +} + function getPositionFromCSSValue(position: string) { if (position.endsWith('px')) { return parseFloat(position); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundPosition.js b/packages/react-native/Libraries/StyleSheet/processBackgroundPosition.js index 0b3800c4966e..2aa514a62438 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundPosition.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundPosition.js @@ -219,16 +219,28 @@ const parseBackgroundPositionCSSString = ( if (value1 == null || value2 == null) { return []; } - if (keyword1 === 'left') { - left = value1; - } else if (keyword1 === 'right') { - right = value1; - } - - if (keyword2 === 'top') { - top = value2; - } else if (keyword2 === 'bottom') { - bottom = value2; + const assignPosition = ( + keyword: string, + value: string | number, + ): boolean => { + if (keyword === 'left') { + left = value; + } else if (keyword === 'right') { + right = value; + } else if (keyword === 'top') { + top = value; + } else if (keyword === 'bottom') { + bottom = value; + } else { + return false; + } + return true; + }; + if ( + !assignPosition(keyword1, value1) || + !assignPosition(keyword2, value2) + ) { + return []; } } diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp index 7759acebe2a6..a7a3a5d2f545 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BackgroundImagePropsConversions.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,46 @@ inline GradientKeyword parseGradientKeyword(const std::string& keyword) { } } +inline ValueUnit toConicColorStopValueUnit(const RawValue& value) { + if (!value.hasType()) { + return {}; + } + + auto position = (std::string)value; + if (position == "0") { + return {0.0f, UnitType::Percent}; + } + + auto angle = parseCSSProperty(position); + if (std::holds_alternative(angle)) { + return {std::get(angle).degrees / 3.6f, UnitType::Percent}; + } + + auto percentage = toValueUnit(value); + return percentage.unit == UnitType::Percent ? percentage : ValueUnit{}; +} + +inline void parseGradientPosition( + const RawValueMap& positionMap, + RadialGradientPosition& position) { + auto topIt = positionMap.find("top"); + auto bottomIt = positionMap.find("bottom"); + auto leftIt = positionMap.find("left"); + auto rightIt = positionMap.find("right"); + + if (topIt != positionMap.end()) { + position.top = toValueUnit(topIt->second); + } else if (bottomIt != positionMap.end()) { + position.bottom = toValueUnit(bottomIt->second); + } + + if (leftIt != positionMap.end()) { + position.left = toValueUnit(leftIt->second); + } else if (rightIt != positionMap.end()) { + position.right = toValueUnit(rightIt->second); + } +} + void parseProcessedBackgroundImage( const PropsParserContext& context, const RawValue& value, @@ -208,6 +249,25 @@ void parseProcessedBackgroundImage( } backgroundImage.emplace_back(std::move(radialGradient)); + } else if (type == "conic-gradient") { + ConicGradient conicGradient; + conicGradient.position.top = {50.0f, UnitType::Percent}; + conicGradient.position.left = {50.0f, UnitType::Percent}; + auto fromIt = rawBackgroundImageMap.find("from"); + if (fromIt != rawBackgroundImageMap.end() && + fromIt->second.hasType()) { + conicGradient.from = (Float)fromIt->second; + } + + auto positionIt = rawBackgroundImageMap.find("position"); + if (positionIt != rawBackgroundImageMap.end() && + positionIt->second.hasType()) { + parseGradientPosition( + static_cast(positionIt->second), + conicGradient.position); + } + conicGradient.colorStops = colorStops; + backgroundImage.emplace_back(std::move(conicGradient)); } } @@ -269,7 +329,9 @@ void parseUnprocessedBackgroundImageList( positionsIt->second.hasType()) { auto positions = static_cast(positionsIt->second); for (const auto& position : positions) { - auto positionValue = toValueUnit(position); + auto positionValue = type == "conic-gradient" + ? toConicColorStopValueUnit(position) + : toValueUnit(position); if (!positionValue) { // invalid position result = {}; @@ -409,6 +471,35 @@ void parseUnprocessedBackgroundImageList( } backgroundImage.emplace_back(std::move(radialGradient)); + } else if (type == "conic-gradient") { + ConicGradient conicGradient; + conicGradient.position.top = {50.0f, UnitType::Percent}; + conicGradient.position.left = {50.0f, UnitType::Percent}; + auto fromIt = rawBackgroundImageMap.find("from"); + if (fromIt != rawBackgroundImageMap.end() && + fromIt->second.hasType()) { + auto from = (std::string)fromIt->second; + if (from == "0") { + conicGradient.from = 0.0f; + } else { + auto angle = parseCSSProperty(from); + if (!std::holds_alternative(angle)) { + result = {}; + return; + } + conicGradient.from = std::get(angle).degrees; + } + } + + auto positionIt = rawBackgroundImageMap.find("position"); + if (positionIt != rawBackgroundImageMap.end() && + positionIt->second.hasType()) { + parseGradientPosition( + static_cast(positionIt->second), + conicGradient.position); + } + conicGradient.colorStops = colorStops; + backgroundImage.emplace_back(std::move(conicGradient)); } } @@ -470,9 +561,72 @@ void fromCSSColorStop( } } +ValueUnit convertAngularPositionToValueUnit(const CSSAngularPosition& value) { + if (std::holds_alternative(value)) { + return {std::get(value).degrees / 3.6f, UnitType::Percent}; + } + return {std::get(value).value, UnitType::Percent}; +} + +void fromCSSConicColorStop( + const std::variant& item, + std::vector& colorStops) { + if (std::holds_alternative(item)) { + ColorStop hint; + hint.position = convertAngularPositionToValueUnit( + std::get(item).position); + colorStops.push_back(hint); + return; + } + + const auto& colorStop = std::get(item); + ColorStop start; + start.color = fromCSSColor(colorStop.color); + if (colorStop.startPosition.has_value()) { + start.position = + convertAngularPositionToValueUnit(*colorStop.startPosition); + } + colorStops.push_back(start); + + if (colorStop.endPosition.has_value()) { + ColorStop end; + end.color = fromCSSColor(colorStop.color); + end.position = convertAngularPositionToValueUnit(*colorStop.endPosition); + colorStops.push_back(end); + } +} + std::optional fromCSSBackgroundImage( const CSSBackgroundImage& cssBackgroundImage) { - if (std::holds_alternative(cssBackgroundImage)) { + if (std::holds_alternative(cssBackgroundImage)) { + const auto& gradient = + std::get(cssBackgroundImage); + ConicGradient conicGradient; + conicGradient.from = gradient.from.degrees; + + const auto& position = gradient.position; + if (position.top.has_value()) { + conicGradient.position.top = + convertLengthPercentageToValueUnit(*position.top); + } + if (position.bottom.has_value()) { + conicGradient.position.bottom = + convertLengthPercentageToValueUnit(*position.bottom); + } + if (position.left.has_value()) { + conicGradient.position.left = + convertLengthPercentageToValueUnit(*position.left); + } + if (position.right.has_value()) { + conicGradient.position.right = + convertLengthPercentageToValueUnit(*position.right); + } + for (const auto& item : gradient.items) { + fromCSSConicColorStop(item, conicGradient.colorStops); + } + return BackgroundImage{conicGradient}; + } else if (std::holds_alternative( + cssBackgroundImage)) { const auto& gradient = std::get(cssBackgroundImage); LinearGradient linearGradient; diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h index 75daa6853d85..82c78139053b 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSBackgroundImage.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,12 @@ enum class CSSGradientDirectionKeyword : std::underlying_type_t { static_assert(CSSDataType); +enum class CSSGradientFromKeyword : std::underlying_type_t { + From = to_underlying(CSSKeyword::From), +}; + +static_assert(CSSDataType); + enum class CSSGradientAtKeyword : std::underlying_type_t { At = to_underlying(CSSKeyword::At), }; @@ -271,6 +278,82 @@ struct CSSDataTypeParser { static_assert(CSSDataType); +using CSSAngularPosition = std::variant; + +struct CSSConicColorHint { + CSSAngularPosition position{}; + + bool operator==(const CSSConicColorHint &rhs) const = default; +}; + +template <> +struct CSSDataTypeParser { + static constexpr auto consume(CSSValueParser &parser) -> std::optional + { + auto angle = parser.parseNextValue(); + if (std::holds_alternative(angle)) { + return CSSConicColorHint{std::get(angle)}; + } + auto zero = parser.parseNextValue(); + if (std::holds_alternative(zero)) { + return CSSConicColorHint{CSSAngle{0.0f}}; + } + auto percentage = parser.parseNextValue(); + if (std::holds_alternative(percentage)) { + return CSSConicColorHint{std::get(percentage)}; + } + return {}; + } +}; + +static_assert(CSSDataType); + +struct CSSConicColorStop { + CSSColor color{}; + std::optional startPosition{}; + std::optional endPosition{}; + + bool operator==(const CSSConicColorStop &rhs) const = default; +}; + +template <> +struct CSSDataTypeParser { + static constexpr auto consume(CSSValueParser &parser) -> std::optional + { + auto color = parser.parseNextValue(); + if (!std::holds_alternative(color)) { + return {}; + } + + CSSConicColorStop colorStop{.color = std::get(color)}; + colorStop.startPosition = parsePosition(parser, CSSDelimiter::Whitespace); + if (colorStop.startPosition.has_value()) { + colorStop.endPosition = parsePosition(parser, CSSDelimiter::Whitespace); + } + return colorStop; + } + + private: + static constexpr std::optional parsePosition(CSSValueParser &parser, CSSDelimiter delimiter) + { + auto angle = parser.parseNextValue(delimiter); + if (std::holds_alternative(angle)) { + return std::get(angle); + } + auto zero = parser.parseNextValue(delimiter); + if (std::holds_alternative(zero)) { + return CSSAngle{0.0f}; + } + auto percentage = parser.parseNextValue(delimiter); + if (std::holds_alternative(percentage)) { + return std::get(percentage); + } + return {}; + } +}; + +static_assert(CSSDataType); + struct CSSLinearGradientFunction { std::optional direction{}; std::vector> items{}; // Color stops and color hints @@ -788,6 +871,252 @@ struct CSSDataTypeParser { static_assert(CSSDataType); +struct CSSConicGradientFunction { + CSSAngle from{}; + CSSRadialGradientPosition position{}; + std::vector> items{}; + + bool operator==(const CSSConicGradientFunction &rhs) const = default; +}; + +template <> +struct CSSDataTypeParser { + static auto consumeFunctionBlock(const CSSFunctionBlock &func, CSSValueParser &parser) + -> std::optional + { + if (!iequals(func.name, "conic-gradient")) { + return {}; + } + + CSSConicGradientFunction gradient; + gradient.position.top = CSSPercentage{50.0f}; + gradient.position.left = CSSPercentage{50.0f}; + + auto fromKeyword = parser.parseNextValue(); + if (std::holds_alternative(fromKeyword)) { + parser.syntaxParser().consumeWhitespace(); + auto angle = parser.parseNextValue(); + if (std::holds_alternative(angle)) { + gradient.from = std::get(angle); + } else { + auto zero = parser.parseNextValue(); + if (!std::holds_alternative(zero)) { + return {}; + } + gradient.from = CSSAngle{0.0f}; + } + parser.syntaxParser().consumeWhitespace(); + } + + auto atKeyword = parser.parseNextValue(); + if (std::holds_alternative(atKeyword)) { + parser.syntaxParser().consumeWhitespace(); + auto first = parsePositionComponent(parser); + if (!first.has_value()) { + return {}; + } + parser.syntaxParser().consumeWhitespace(); + auto second = parsePositionComponent(parser); + std::optional third; + std::optional fourth; + if (second.has_value()) { + parser.syntaxParser().consumeWhitespace(); + third = parsePositionComponent(parser); + } + if (third.has_value()) { + parser.syntaxParser().consumeWhitespace(); + fourth = parsePositionComponent(parser); + if (!fourth.has_value()) { + return {}; + } + } + auto position = resolvePosition(*first, second, third, fourth); + if (!position.has_value()) { + return {}; + } + gradient.position = *position; + } + + parser.syntaxParser().consumeDelimiter(CSSDelimiter::Comma); + int colorStopCount = 0; + std::optional previousColorStop; + do { + auto colorStop = parser.parseNextValue(); + if (std::holds_alternative(colorStop)) { + auto parsedColorStop = std::get(colorStop); + gradient.items.emplace_back(parsedColorStop); + previousColorStop = parsedColorStop; + colorStopCount++; + } else { + auto colorHint = parser.parseNextValue(); + if (!std::holds_alternative(colorHint) || !previousColorStop.has_value()) { + break; + } + auto nextColorStop = parser.peekNextValue(CSSDelimiter::Comma); + if (!std::holds_alternative(nextColorStop)) { + return {}; + } + gradient.items.emplace_back(std::get(colorHint)); + } + } while (parser.syntaxParser().consumeDelimiter(CSSDelimiter::Comma)); + + if (colorStopCount < 2) { + return {}; + } + return gradient; + } + + private: + using PositionComponent = std::variant; + + static std::optional parsePositionComponent(CSSValueParser &parser) + { + auto keyword = parser.parseNextValue(); + if (std::holds_alternative(keyword)) { + return std::get(keyword); + } + auto value = parser.parseNextValue(); + if (std::holds_alternative(value)) { + return std::get(value); + } + if (std::holds_alternative(value)) { + return std::get(value); + } + return {}; + } + + static std::variant positionValue(const PositionComponent &component) + { + if (std::holds_alternative(component)) { + return std::get(component); + } + return std::get(component); + } + + static std::optional resolvePosition( + const PositionComponent &first, + const std::optional &second, + const std::optional &third, + const std::optional &fourth) + { + CSSRadialGradientPosition position; + if (third.has_value()) { + if (!second.has_value() || !fourth.has_value() || !std::holds_alternative(first) || + !std::holds_alternative(*third) || + std::holds_alternative(*second) || + std::holds_alternative(*fourth)) { + return {}; + } + + bool hasHorizontal = false; + bool hasVertical = false; + auto setOffset = [&](CSSGradientPositionKeyword keyword, const PositionComponent &value) { + if (keyword == CSSGradientPositionKeyword::Left && !hasHorizontal) { + position.left = positionValue(value); + hasHorizontal = true; + } else if (keyword == CSSGradientPositionKeyword::Right && !hasHorizontal) { + position.right = positionValue(value); + hasHorizontal = true; + } else if (keyword == CSSGradientPositionKeyword::Top && !hasVertical) { + position.top = positionValue(value); + hasVertical = true; + } else if (keyword == CSSGradientPositionKeyword::Bottom && !hasVertical) { + position.bottom = positionValue(value); + hasVertical = true; + } else { + return false; + } + return true; + }; + + if (!setOffset(std::get(first), *second) || + !setOffset(std::get(*third), *fourth)) { + return {}; + } + return position; + } + if (!second.has_value()) { + if (std::holds_alternative(first)) { + switch (std::get(first)) { + case CSSGradientPositionKeyword::Left: + position.left = CSSPercentage{0.0f}; + position.top = CSSPercentage{50.0f}; + break; + case CSSGradientPositionKeyword::Right: + position.right = CSSPercentage{0.0f}; + position.top = CSSPercentage{50.0f}; + break; + case CSSGradientPositionKeyword::Top: + position.left = CSSPercentage{50.0f}; + position.top = CSSPercentage{0.0f}; + break; + case CSSGradientPositionKeyword::Bottom: + position.left = CSSPercentage{50.0f}; + position.bottom = CSSPercentage{0.0f}; + break; + case CSSGradientPositionKeyword::Center: + position.left = CSSPercentage{50.0f}; + position.top = CSSPercentage{50.0f}; + break; + } + } else { + position.left = positionValue(first); + position.top = CSSPercentage{50.0f}; + } + return position; + } + + auto setHorizontal = [&position](const PositionComponent &component) { + if (std::holds_alternative(component)) { + auto keyword = std::get(component); + if (keyword == CSSGradientPositionKeyword::Left) { + position.left = CSSPercentage{0.0f}; + } else if (keyword == CSSGradientPositionKeyword::Right) { + position.right = CSSPercentage{0.0f}; + } else if (keyword == CSSGradientPositionKeyword::Center) { + position.left = CSSPercentage{50.0f}; + } else { + return false; + } + } else { + position.left = positionValue(component); + } + return true; + }; + auto setVertical = [&position](const PositionComponent &component) { + if (std::holds_alternative(component)) { + auto keyword = std::get(component); + if (keyword == CSSGradientPositionKeyword::Top) { + position.top = CSSPercentage{0.0f}; + } else if (keyword == CSSGradientPositionKeyword::Bottom) { + position.bottom = CSSPercentage{0.0f}; + } else if (keyword == CSSGradientPositionKeyword::Center) { + position.top = CSSPercentage{50.0f}; + } else { + return false; + } + } else { + position.top = positionValue(component); + } + return true; + }; + + bool firstIsVertical = std::holds_alternative(first) && + (std::get(first) == CSSGradientPositionKeyword::Top || + std::get(first) == CSSGradientPositionKeyword::Bottom); + if (firstIsVertical) { + if (!setVertical(first) || !setHorizontal(*second)) { + return {}; + } + } else if (!setHorizontal(first) || !setVertical(*second)) { + return {}; + } + return position; + } +}; + +static_assert(CSSDataType); + template <> struct CSSDataTypeParser { static auto consumeFunctionBlock(const CSSFunctionBlock &func, CSSValueParser &parser) @@ -826,7 +1155,8 @@ static_assert(CSSDataType); * Representation of * https://www.w3.org/TR/css-backgrounds-3/#background-image */ -using CSSBackgroundImage = CSSCompoundDataType; +using CSSBackgroundImage = + CSSCompoundDataType; /** * Representation of diff --git a/packages/react-native/ReactCommon/react/renderer/css/CSSKeyword.h b/packages/react-native/ReactCommon/react/renderer/css/CSSKeyword.h index f75901853c86..c93564993f71 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/CSSKeyword.h +++ b/packages/react-native/ReactCommon/react/renderer/css/CSSKeyword.h @@ -45,6 +45,7 @@ enum class CSSKeyword : uint8_t { Flex, FlexEnd, FlexStart, + From, Grid, Groove, Hidden, @@ -167,6 +168,7 @@ CSS_DEFINE_KEYWORD(Fixed, "fixed") CSS_DEFINE_KEYWORD(Flex, "flex") CSS_DEFINE_KEYWORD(FlexEnd, "flex-end") CSS_DEFINE_KEYWORD(FlexStart, "flex-start") +CSS_DEFINE_KEYWORD(From, "from") CSS_DEFINE_KEYWORD(Grid, "grid") CSS_DEFINE_KEYWORD(Groove, "groove") CSS_DEFINE_KEYWORD(Hidden, "hidden") @@ -280,6 +282,7 @@ constexpr std::optional parseCSSKeyword(std::string_view ident) CSS_HANDLE_KEYWORD(Flex) CSS_HANDLE_KEYWORD(FlexEnd) CSS_HANDLE_KEYWORD(FlexStart) + CSS_HANDLE_KEYWORD(From) CSS_HANDLE_KEYWORD(Grid) CSS_HANDLE_KEYWORD(Groove) CSS_HANDLE_KEYWORD(Hidden) diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp index 9851cc87277f..1472d4065992 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSBackgroundImageTest.cpp @@ -539,6 +539,45 @@ TEST_F(CSSBackgroundImageTest, RadialGradientMultipleColorStops) { ASSERT_EQ(result, expected); } +TEST_F(CSSBackgroundImageTest, ConicGradientWithAngleAndPosition) { + auto result = parseCSSProperty( + "conic-gradient(from 45deg at 25% 75%, red 0deg, blue 180deg, red 100%)"); + decltype(result) expected = CSSConicGradientFunction{ + .from = CSSAngle{.degrees = 45.0f}, + .position = + CSSRadialGradientPosition{ + .top = CSSPercentage{.value = 75.0f}, + .left = CSSPercentage{.value = 25.0f}}, + .items = { + CSSConicColorStop{ + .color = CSSColor{.r = 255, .g = 0, .b = 0, .a = 255}, + .startPosition = CSSAngle{.degrees = 0.0f}}, + CSSConicColorStop{ + .color = CSSColor{.r = 0, .g = 0, .b = 255, .a = 255}, + .startPosition = CSSAngle{.degrees = 180.0f}}, + CSSConicColorStop{ + .color = CSSColor{.r = 255, .g = 0, .b = 0, .a = 255}, + .startPosition = CSSPercentage{.value = 100.0f}}}}; + ASSERT_EQ(result, expected); +} + +TEST_F(CSSBackgroundImageTest, ConicGradientWithFourValuePosition) { + auto result = parseCSSProperty( + "conic-gradient(at top 20px left 10px, red, blue)"); + decltype(result) expected = CSSConicGradientFunction{ + .from = CSSAngle{.degrees = 0.0f}, + .position = + CSSRadialGradientPosition{ + .top = CSSLength{.value = 20.0f, .unit = CSSLengthUnit::Px}, + .left = CSSLength{.value = 10.0f, .unit = CSSLengthUnit::Px}}, + .items = { + CSSConicColorStop{ + .color = CSSColor{.r = 255, .g = 0, .b = 0, .a = 255}}, + CSSConicColorStop{ + .color = CSSColor{.r = 0, .g = 0, .b = 255, .a = 255}}}}; + ASSERT_EQ(result, expected); +} + TEST_F(CSSBackgroundImageTest, InvalidGradientFunctionName) { const std::string input = "aoeusntial-gradient(red 0%, yellow 30%, green 60%, blue 100%)"; diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp index 9ee16ac87392..2d9f171e2a7f 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.cpp @@ -11,7 +11,9 @@ namespace facebook::react { #ifdef RN_SERIALIZABLE_STATE folly::dynamic toDynamic(const BackgroundImage& backgroundImage) { - if (std::holds_alternative(backgroundImage)) { + if (std::holds_alternative(backgroundImage)) { + return std::get(backgroundImage).toDynamic(); + } else if (std::holds_alternative(backgroundImage)) { return std::get(backgroundImage).toDynamic(); } else if (std::holds_alternative(backgroundImage)) { return std::get(backgroundImage).toDynamic(); diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h index d156126f251c..bab8cc08a558 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/BackgroundImage.h @@ -7,12 +7,13 @@ #pragma once +#include #include #include namespace facebook::react { -using BackgroundImage = std::variant; +using BackgroundImage = std::variant; #ifdef RN_SERIALIZABLE_STATE folly::dynamic toDynamic(const BackgroundImage &backgroundImage); @@ -30,7 +31,9 @@ inline std::string toString(std::vector &value) } const auto &backgroundImage = value[i]; - if (std::holds_alternative(backgroundImage)) { + if (std::holds_alternative(backgroundImage)) { + std::get(backgroundImage).toString(ss); + } else if (std::holds_alternative(backgroundImage)) { std::get(backgroundImage).toString(ss); } else if (std::holds_alternative(backgroundImage)) { std::get(backgroundImage).toString(ss); diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.cpp b/packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.cpp new file mode 100644 index 000000000000..d5212aaf176b --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.cpp @@ -0,0 +1,55 @@ +/* + * 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. + */ + +#include "ConicGradient.h" + +namespace facebook::react { + +#ifdef RN_SERIALIZABLE_STATE +folly::dynamic ConicGradient::toDynamic() const { + folly::dynamic result = folly::dynamic::object(); + result["type"] = "conic-gradient"; + result["from"] = from; + result["position"] = position.toDynamic(); + + folly::dynamic colorStopsArray = folly::dynamic::array(); + for (const auto& colorStop : colorStops) { + colorStopsArray.push_back(colorStop.toDynamic()); + } + result["colorStops"] = colorStopsArray; + + return result; +} +#endif + +#if RN_DEBUG_STRING_CONVERTIBLE +void ConicGradient::toString(std::stringstream& ss) const { + ss << "conic-gradient(from " << from << "deg at "; + + if (position.left.has_value()) { + ss << position.left->toString() << " "; + } + if (position.top.has_value()) { + ss << position.top->toString() << " "; + } + if (position.right.has_value()) { + ss << position.right->toString() << " "; + } + if (position.bottom.has_value()) { + ss << position.bottom->toString() << " "; + } + + for (const auto& colorStop : colorStops) { + ss << ", "; + colorStop.toString(ss); + } + + ss << ")"; +} +#endif + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.h b/packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.h new file mode 100644 index 000000000000..a2143726bce2 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/graphics/ConicGradient.h @@ -0,0 +1,39 @@ +/* + * 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 + +#if RN_DEBUG_STRING_CONVERTIBLE +#include +#endif + +#include + +namespace facebook::react { + +struct ConicGradient { + Float from{}; + RadialGradientPosition position; + std::vector colorStops; + + bool operator==(const ConicGradient &other) const = default; + +#ifdef RN_SERIALIZABLE_STATE + folly::dynamic toDynamic() const; +#endif + +#if RN_DEBUG_STRING_CONVERTIBLE + void toString(std::stringstream &ss) const; +#endif +}; + +} // namespace facebook::react From 5aaee6410f9687a795e8d83c9fd8ace43fe7731d Mon Sep 17 00:00:00 2001 From: Muhammed Ibrahim Date: Tue, 25 Aug 2026 15:02:17 +0400 Subject: [PATCH 2/3] feat(view): render conic gradients natively --- .../View/RCTViewComponentView.mm | 8 +- .../React/Fabric/Utils/RCTConicGradient.h | 19 ++++ .../React/Fabric/Utils/RCTConicGradient.mm | 51 ++++++++++ .../uimanager/style/BackgroundImageLayer.kt | 12 ++- .../react/uimanager/style/ConicGradient.kt | 99 +++++++++++++++++++ .../BackgroundImage/BackgroundImageExample.js | 34 +++++++ 6 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 packages/react-native/React/Fabric/Utils/RCTConicGradient.h create mode 100644 packages/react-native/React/Fabric/Utils/RCTConicGradient.mm create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ConicGradient.kt diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 0912c50fec13..18d56bdd6087 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -20,6 +20,7 @@ #import #import #import +#import #import #import #import @@ -1315,9 +1316,12 @@ - (void)invalidateLayer backgroundSize:backgroundSize backgroundRepeat:backgroundRepeat]; - CALayer *gradientLayer; + CALayer *gradientLayer = nil; - if (std::holds_alternative(backgroundImage)) { + if (std::holds_alternative(backgroundImage)) { + const auto &conicGradient = std::get(backgroundImage); + gradientLayer = [RCTConicGradient gradientLayerWithSize:backgroundImageSize gradient:conicGradient]; + } else if (std::holds_alternative(backgroundImage)) { const auto &linearGradient = std::get(backgroundImage); gradientLayer = [RCTLinearGradient gradientLayerWithSize:backgroundImageSize gradient:linearGradient]; } else if (std::holds_alternative(backgroundImage)) { diff --git a/packages/react-native/React/Fabric/Utils/RCTConicGradient.h b/packages/react-native/React/Fabric/Utils/RCTConicGradient.h new file mode 100644 index 000000000000..05a570776dda --- /dev/null +++ b/packages/react-native/React/Fabric/Utils/RCTConicGradient.h @@ -0,0 +1,19 @@ +/* + * 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. + */ + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTConicGradient : NSObject + ++ (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const facebook::react::ConicGradient &)gradient; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Fabric/Utils/RCTConicGradient.mm b/packages/react-native/React/Fabric/Utils/RCTConicGradient.mm new file mode 100644 index 000000000000..60771ec764ad --- /dev/null +++ b/packages/react-native/React/Fabric/Utils/RCTConicGradient.mm @@ -0,0 +1,51 @@ +/* + * 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. + */ + +#import "RCTConicGradient.h" + +#import "RCTGradientUtils.h" + +#import + +using namespace facebook::react; + +@implementation RCTConicGradient + ++ (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const ConicGradient &)gradient +{ + CAGradientLayer *gradientLayer = [CAGradientLayer layer]; + gradientLayer.type = kCAGradientLayerConic; + + CGPoint centerPoint = CGPointMake(size.width / 2.0, size.height / 2.0); + if (gradient.position.top.has_value()) { + centerPoint.y = gradient.position.top->resolve(static_cast(size.height)); + } else if (gradient.position.bottom.has_value()) { + centerPoint.y = size.height - gradient.position.bottom->resolve(static_cast(size.height)); + } + if (gradient.position.left.has_value()) { + centerPoint.x = gradient.position.left->resolve(static_cast(size.width)); + } else if (gradient.position.right.has_value()) { + centerPoint.x = size.width - gradient.position.right->resolve(static_cast(size.width)); + } + + CGPoint normalizedCenter = CGPointMake(centerPoint.x / size.width, centerPoint.y / size.height); + CGFloat radians = gradient.from * M_PI / 180.0; + gradientLayer.startPoint = normalizedCenter; + gradientLayer.endPoint = CGPointMake(normalizedCenter.x + std::sin(radians), normalizedCenter.y - std::cos(radians)); + + const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops gradientLineLength:1.0]; + NSMutableArray *colors = [NSMutableArray array]; + NSMutableArray *locations = [NSMutableArray array]; + [RCTGradientUtils getColors:colors andLocations:locations fromColorStops:colorStops]; + + gradientLayer.frame = CGRectMake(0.0f, 0.0f, size.width, size.height); + gradientLayer.colors = colors; + gradientLayer.locations = locations; + return gradientLayer; +} + +@end diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt index 8e5cc7d8c2a4..bbe43c54c493 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt @@ -15,10 +15,11 @@ import com.facebook.react.bridge.ReadableType /** * Represents a single layer of a background image, typically containing a gradient. * - * This class encapsulates gradient definitions (linear or radial) that can be applied as background - * layers to React Native views. It provides parsing from React Native bridge data and shader - * generation for rendering. + * This class encapsulates gradient definitions that can be applied as background layers to React + * Native views. It provides parsing from React Native bridge data and shader generation for + * rendering. * + * @see ConicGradient * @see LinearGradient * @see RadialGradient */ @@ -33,8 +34,8 @@ public class BackgroundImageLayer() { /** * Parses a ReadableMap into a BackgroundImageLayer. * - * The map should contain gradient configuration including a "type" key specifying either - * "linear-gradient" or "radial-gradient". + * The map should contain gradient configuration including a "type" key specifying a supported + * gradient type. * * @param gradientMap The map containing gradient configuration * @param context Android context for resource resolution @@ -54,6 +55,7 @@ public class BackgroundImageLayer() { } return when (gradientMap.getString("type")) { + "conic-gradient" -> ConicGradient.parse(gradientMap, context) "linear-gradient" -> LinearGradient.parse(gradientMap, context) "radial-gradient" -> RadialGradient.parse(gradientMap, context) else -> null diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ConicGradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ConicGradient.kt new file mode 100644 index 000000000000..4a0a4e30adc0 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ConicGradient.kt @@ -0,0 +1,99 @@ +/* + * 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.content.Context +import android.graphics.Matrix +import android.graphics.Shader +import android.graphics.SweepGradient +import com.facebook.react.bridge.ColorPropConverter +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil.dpToPx + +internal class ConicGradient( + val from: Float, + val position: RadialGradient.Position, + val colorStops: List, +) : Gradient { + companion object { + fun parse(gradientMap: ReadableMap, context: Context): Gradient? { + if (!gradientMap.hasKey("from") || !gradientMap.hasKey("position")) { + return null + } + val from = gradientMap.getDouble("from").toFloat() + val positionMap = gradientMap.getMap("position") ?: return null + var top: LengthPercentage? = null + var left: LengthPercentage? = null + var right: LengthPercentage? = null + var bottom: LengthPercentage? = null + + if (positionMap.hasKey("top")) { + top = LengthPercentage.setFromDynamic(positionMap.getDynamic("top")) + } else if (positionMap.hasKey("bottom")) { + bottom = LengthPercentage.setFromDynamic(positionMap.getDynamic("bottom")) + } + if (positionMap.hasKey("left")) { + left = LengthPercentage.setFromDynamic(positionMap.getDynamic("left")) + } else if (positionMap.hasKey("right")) { + right = LengthPercentage.setFromDynamic(positionMap.getDynamic("right")) + } + + val colorStopsArray = gradientMap.getArray("colorStops") ?: return null + val colorStops = ArrayList(colorStopsArray.size()) + for (i in 0 until colorStopsArray.size()) { + val colorStop = colorStopsArray.getMap(i) ?: continue + val color: Int? = + when { + !colorStop.hasKey("color") || colorStop.isNull("color") -> null + colorStop.getType("color") == ReadableType.Map -> + ColorPropConverter.getColor(colorStop.getMap("color"), context) + else -> colorStop.getInt("color") + } + val stopPosition = LengthPercentage.setFromDynamic(colorStop.getDynamic("position")) + colorStops.add(ColorStop(color, stopPosition)) + } + + if (colorStops.size < 2) { + return null + } + return ConicGradient(from, RadialGradient.Position(top, left, right, bottom), colorStops) + } + } + + override fun getShader(width: Float, height: Float): Shader { + var centerX = width / 2f + var centerY = height / 2f + position.top?.let { + centerY = if (it.type == LengthPercentageType.PERCENT) it.resolve(height) else it.resolve(height).dpToPx() + } + position.bottom?.let { + centerY = if (it.type == LengthPercentageType.PERCENT) height - it.resolve(height) else height - it.resolve(height).dpToPx() + } + position.left?.let { + centerX = if (it.type == LengthPercentageType.PERCENT) it.resolve(width) else it.resolve(width).dpToPx() + } + position.right?.let { + centerX = if (it.type == LengthPercentageType.PERCENT) width - it.resolve(width) else width - it.resolve(width).dpToPx() + } + + val finalStops = ColorStopUtils.getFixedColorStops(colorStops, 1f) + val colors = IntArray(finalStops.size) + val positions = FloatArray(finalStops.size) + finalStops.forEachIndexed { index, colorStop -> + colors[index] = colorStop.color ?: 0 + positions[index] = colorStop.position ?: 0f + } + + val shader = SweepGradient(centerX, centerY, colors, positions) + shader.setLocalMatrix(Matrix().apply { setRotate(from - 90f, centerX, centerY) }) + return shader + } +} diff --git a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js index e1ca7d92ef48..ff56687fb2c9 100644 --- a/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js +++ b/packages/rn-tester/js/examples/BackgroundImage/BackgroundImageExample.js @@ -180,6 +180,40 @@ exports.examples = [ ); }, }, + { + title: 'Conic Gradient', + description: + 'Conic gradients rotate color stops clockwise around a configurable center.', + name: 'conic', + render(): React.Node { + return ( + + + + Quadrants + + + + Rotated and off-center + + + + + ); + }, + }, { title: 'Gradient with Background Repeat', name: 'repeat', From 866b15376293fadc1fd1d583bd2d4a764132c854 Mon Sep 17 00:00:00 2001 From: Muhammed Ibrahim Date: Tue, 25 Aug 2026 15:02:33 +0400 Subject: [PATCH 3/3] chore(types): update React Native API --- packages/react-native/ReactNativeApi.d.ts | 142 ++++++++++++---------- 1 file changed, 76 insertions(+), 66 deletions(-) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c1ac9628efe7..47d8e3398194 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2199d280ef42e2a63ec4d6c405af893a>> + * @generated SignedSource<<3dba34aa9f9b26815e076a32ac3f7637>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1589,7 +1589,8 @@ declare type AttributeType = readonly process?: (arg1: V) => T } declare type AutoCapitalize = "characters" | "none" | "sentences" | "words" -declare type BackgroundImageValue = LinearGradientValue | RadialGradientValue +declare type BackgroundImageValue = + ConicGradientValue | LinearGradientValue | RadialGradientValue declare type BackgroundPositionValue = | { bottom: number | string @@ -1778,6 +1779,15 @@ declare function configureNext( onAnimationDidEnd?: OnAnimationDidEndCallback, onAnimationDidFail?: OnAnimationDidFailCallback, ): void +declare type ConicGradientValue = { + colorStops: ReadonlyArray<{ + color: ____ColorValue_Internal + positions?: ReadonlyArray + }> + from?: string + position?: RadialGradientPosition + type: "conic-gradient" +} declare type ContentAvailable = 1 | null | void declare type Context = { readonly cellKey: string | undefined @@ -5752,18 +5762,18 @@ export { AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // 085c0bdd + ActivityIndicator, // 5c425112 ActivityIndicatorInstance, // a82dd4e7 - ActivityIndicatorProps, // 7597a087 + ActivityIndicatorProps, // d7516cd9 Alert, // a398a509 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 8ed2ff0a + Animated, // 68343bb2 AppConfig, // 35c0ca70 - AppRegistry, // 5bc2bced + AppRegistry, // 19ccbeca AppState, // 12012be5 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -5775,8 +5785,8 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // 4ba4f941 BoxShadowValue, // b679703f - Button, // af384e38 - ButtonInstance, // 3f6e29ea + Button, // d37d614e + ButtonInstance, // 342c271e ButtonProps, // 21c5780c Clipboard, // 41addb89 CodegenTypes, // ab4986cc @@ -5795,9 +5805,9 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 787ba7e2 + DrawerLayoutAndroid, // 82bb4e6a DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // 658000f3 + DrawerLayoutAndroidProps, // a6bcbc90 DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5813,9 +5823,9 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // 901c50da - FlatListInstance, // c2dd86eb - FlatListProps, // 3bd11d32 + FlatList, // 3d8bde04 + FlatListInstance, // 543847e3 + FlatListProps, // fcc3b7bc FocusEvent, // 850f1517 FontVariant, // 7c7558bb GestureResponderEvent, // 14d3e77a @@ -5826,17 +5836,17 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // 23ec0708 - ImageBackground, // 4922fef8 - ImageBackgroundInstance, // a693a792 - ImageBackgroundProps, // 9a257b18 + Image, // 047e6a3c + ImageBackground, // 29325eb1 + ImageBackgroundInstance, // d6c605e8 + ImageBackgroundProps, // aa3e835c ImageErrorEvent, // 978933f4 ImageInstance, // 9a100753 ImageLoadEvent, // 77f0b718 ImageProgressEventIOS, // 445331a4 - ImageProps, // 9acb1a99 + ImageProps, // 14e2f9c5 ImagePropsAndroid, // ee00e1d5 - ImagePropsBase, // ceee1a0e + ImagePropsBase, // 035153a6 ImagePropsIOS, // 9e19c85d ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 @@ -5844,19 +5854,19 @@ export { ImageSize, // 1c47cf88 ImageSource, // ea31cf4a ImageSourcePropType, // f522e093 - ImageStyle, // c972b651 + ImageStyle, // d7ad9013 ImageURISource, // 443d047c - InputAccessoryView, // 69da97d8 - InputAccessoryViewProps, // 6c4ba417 + InputAccessoryView, // c53c9e38 + InputAccessoryViewProps, // 44e0a667 InputModeOptions, // 4e8581b9 Insets, // e7fe432a KeyDownEvent, // 5b147614 KeyEvent, // 20fa4267 KeyUpEvent, // 57f832c5 Keyboard, // 49414c97 - KeyboardAvoidingView, // 206bbaad - KeyboardAvoidingViewInstance, // 9fc99a9a - KeyboardAvoidingViewProps, // 1661b30c + KeyboardAvoidingView, // 6e66c9e2 + KeyboardAvoidingViewInstance, // 24079016 + KeyboardAvoidingViewProps, // 8d254545 KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -5881,10 +5891,10 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // 8e222ce5 + Modal, // d63c125c ModalBaseProps, // c294cc46 ModalInstance, // d466ce77 - ModalProps, // dc3dfe04 + ModalProps, // 487f9665 ModalPropsAndroid, // 515fb173 ModalPropsIOS, // 664ecb7e ModeChangeEvent, // f64bf69d @@ -5920,15 +5930,15 @@ export { PointerEvent, // ff599afe PressabilityConfig, // fea539a5 PressabilityEventHandlers, // c222648b - Pressable, // 148fa542 + Pressable, // 0888d4a0 PressableAndroidRippleConfig, // ee32eaca PressableInstance, // eebfe911 - PressableProps, // 0610ea0a + PressableProps, // febd3f0c PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // 48034c58 + ProgressBarAndroid, // db2a71f0 ProgressBarAndroidInstance, // ab545ef1 - ProgressBarAndroidProps, // 9c1b93a7 + ProgressBarAndroidProps, // 045cf479 PublicRootInstance, // 8040afd7 PublicTextInstance, // 6937c7bf PushNotificationEventName, // 84e7e150 @@ -5936,9 +5946,9 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 76837d3f - RefreshControlInstance, // 4c4b2643 - RefreshControlProps, // d41ad947 + RefreshControl, // 2b4a6e48 + RefreshControlInstance, // 209d1dc5 + RefreshControlProps, // b8de2eee RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d @@ -5947,27 +5957,27 @@ export { Role, // af7b889d RootTag, // 3cd10504 RootTagContext, // 38bfc8f6 - RootViewStyleProvider, // a4547094 + RootViewStyleProvider, // cbd29769 Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 718d200c + SafeAreaView, // 2d255698 SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 ScrollEvent, // d7abdd0a - ScrollResponderType, // 603c33d5 + ScrollResponderType, // 8bc47f88 ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // a644be1d - ScrollViewImperativeMethods, // 480a85e1 - ScrollViewInstance, // 1030cf7f - ScrollViewProps, // 901ba6eb + ScrollView, // ffbbfd69 + ScrollViewImperativeMethods, // 71f50c92 + ScrollViewInstance, // ae094073 + ScrollViewProps, // eceb867d ScrollViewPropsAndroid, // 02f3df2e ScrollViewPropsIOS, // 807cb4f6 ScrollViewScrollToOptions, // 3313411e SectionBase, // 9f13db00 - SectionList, // d3af1e2c + SectionList, // 8a476d07 SectionListData, // 1a4de01a - SectionListInstance, // 07b91520 - SectionListProps, // ea05da1f + SectionListInstance, // e4120348 + SectionListProps, // e355d2d9 SectionListRenderItem, // 715b2086 SectionListRenderItemInfo, // 4a48a922 Separators, // 6a45f7e3 @@ -5984,19 +5994,19 @@ export { StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // f7fe407a + StyleSheet, // 1a05d208 SubmitBehavior, // c4ddf490 - Switch, // b3e75e79 + Switch, // d8a18a06 SwitchChangeEvent, // 899635b1 SwitchInstance, // 3c50eec5 - SwitchProps, // 31d4e162 + SwitchProps, // 82497648 Systrace, // 626d178c TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 6f2a9453 + Text, // 9702274d TextContentType, // 239b3ecc - TextInput, // e7689ddb + TextInput, // 8b6c410e TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 @@ -6006,44 +6016,44 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 5a0c0e0d TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // 665a095f + TextInputProps, // b54430f7 TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 278dd30a - TextStyle, // bea26d6b + TextProps, // a905782a + TextStyle, // 501318b2 ToastAndroid, // 88a8969a - TouchableHighlight, // 4b9ae440 + TouchableHighlight, // 610d50ce TouchableHighlightInstance, // b510c0eb - TouchableHighlightProps, // 8e8c6680 - TouchableNativeFeedback, // 49b246df - TouchableNativeFeedbackInstance, // 95dc4a1d - TouchableNativeFeedbackProps, // b32639f0 - TouchableOpacity, // 36c0926f + TouchableHighlightProps, // afef80ce + TouchableNativeFeedback, // 10c921f9 + TouchableNativeFeedbackInstance, // 03a71f97 + TouchableNativeFeedbackProps, // 1e08221b + TouchableOpacity, // 21cbed92 TouchableOpacityInstance, // b186055b - TouchableOpacityProps, // d7db3879 - TouchableWithoutFeedback, // 4bf9d65a - TouchableWithoutFeedbackProps, // 931958b6 + TouchableOpacityProps, // 9a7742d3 + TouchableWithoutFeedback, // e8aec8dd + TouchableWithoutFeedbackProps, // 862ca2c8 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 UIManager, // afbcdf05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // c31ebc5e + View, // d125a60a ViewInstance, // ffde5573 - ViewProps, // e2c15ef7 + ViewProps, // 5ada1ad2 ViewPropsAndroid, // 55e81851 ViewPropsIOS, // 58ee19bf - ViewStyle, // 1848a17c + ViewStyle, // 9a414f0b VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e VirtualizedListInstance, // 423ee7c0 - VirtualizedListProps, // 3194847b + VirtualizedListProps, // 2dd840cb VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // 12b706d5 - VirtualizedSectionListProps, // f42f54c4 + VirtualizedSectionListProps, // 4e1c4c63 WrapperComponentProvider, // 9ef54e61 codegenNativeCommands, // 628a7c0a codegenNativeComponent, // 32a1bca6