diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index 05a8b6410f454e..2857fae6e6ab06 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -372,7 +372,7 @@ export type GradientValue = { // Angle or direction enums direction?: string | undefined; colorStops: ReadonlyArray<{ - color: ColorValue; + color: ColorValue | null; positions?: ReadonlyArray | undefined; }>; }; diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js index 624f79df47885d..306e43bc7107ad 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js @@ -30,8 +30,8 @@ describe('processBackgroundImage', () => { type: 'linearGradient', direction: {type: 'angle', value: 90}, colorStops: [ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ], }, ]); @@ -45,8 +45,8 @@ describe('processBackgroundImage', () => { type: 'linearGradient', direction: {type: 'keyword', value: 'to bottom right'}, colorStops: [ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ], }, ]); @@ -74,8 +74,8 @@ describe('processBackgroundImage', () => { type: 'linearGradient', direction: {type: 'keyword', value: 'to bottom right'}, colorStops: [ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ], }, ]); @@ -90,8 +90,8 @@ describe('processBackgroundImage', () => { type: 'linearGradient', direction: {type: 'keyword', value: 'to bottom right'}, colorStops: [ - {color: processColor('red'), position: 0.3}, - {color: processColor('blue'), position: 0.8}, + {color: processColor('red'), position: '30%'}, + {color: processColor('blue'), position: '80%'}, ], }, ]); @@ -103,8 +103,8 @@ describe('processBackgroundImage', () => { expect(result[0].type).toBe('linearGradient'); expect(result[0].direction).toEqual({type: 'angle', value: 45}); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); }); @@ -114,8 +114,8 @@ describe('processBackgroundImage', () => { expect(result[0].type).toBe('linearGradient'); expect(result[0].direction).toEqual({type: 'angle', value: 45}); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); }); @@ -131,8 +131,8 @@ describe('processBackgroundImage', () => { expect(result[0].type).toBe('linearGradient'); expect(result[0].direction).toEqual({type: 'angle', value: 180}); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); }); @@ -148,8 +148,8 @@ describe('processBackgroundImage', () => { expect(result[0].type).toBe('linearGradient'); expect(result[0].direction).toEqual({type: 'angle', value: 45}); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); }); @@ -162,8 +162,8 @@ describe('processBackgroundImage', () => { value: 180, }); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); }); @@ -175,8 +175,8 @@ describe('processBackgroundImage', () => { value: 90, }); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); }); @@ -189,9 +189,9 @@ describe('processBackgroundImage', () => { value: 270, }); 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}, + {color: processColor('rgba(0, 0, 0, 0.5)'), position: null}, + {color: processColor('blue'), position: null}, + {color: processColor('hsla(0, 100%, 50%, 0.5)'), position: null}, ]); }); @@ -207,8 +207,8 @@ describe('processBackgroundImage', () => { value: 0, }); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); expect(result[1].type).toEqual('linearGradient'); expect(result[1].direction).toEqual({ @@ -217,8 +217,8 @@ describe('processBackgroundImage', () => { }); expect(result[1].colorStops).toEqual([ - {color: processColor('green'), position: 0}, - {color: processColor('yellow'), position: 1}, + {color: processColor('green'), position: null}, + {color: processColor('yellow'), position: null}, ]); }); @@ -234,8 +234,8 @@ describe('processBackgroundImage', () => { value: 270, }); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('blue'), position: null}, ]); expect(result[1].type).toEqual('linearGradient'); expect(result[1].direction).toEqual({ @@ -244,8 +244,8 @@ describe('processBackgroundImage', () => { }); expect(result[1].colorStops).toEqual([ - {color: processColor('green'), position: 0}, - {color: processColor('yellow'), position: 1}, + {color: processColor('green'), position: null}, + {color: processColor('yellow'), position: null}, ]); }); @@ -253,9 +253,9 @@ describe('processBackgroundImage', () => { const input = 'linear-gradient(to bottom, 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}, + {color: processColor('red'), position: '0%'}, + {color: processColor('green'), position: '50%'}, + {color: processColor('blue'), position: '100%'}, ]); }); @@ -264,11 +264,11 @@ describe('processBackgroundImage', () => { '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.3}, - {color: processColor('blue'), position: 0.6}, - {color: processColor('yellow'), position: 0.8}, - {color: processColor('purple'), position: 1}, + {color: processColor('red'), position: null}, + {color: processColor('green'), position: null}, + {color: processColor('blue'), position: '60%'}, + {color: processColor('yellow'), position: null}, + {color: processColor('purple'), position: null}, ]); }); @@ -277,8 +277,8 @@ describe('processBackgroundImage', () => { '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}, + {color: processColor('rgba(255,0,0,0.5)'), position: null}, + {color: processColor('rgba(0,0,255,0.8)'), position: null}, ]); }); @@ -286,8 +286,8 @@ describe('processBackgroundImage', () => { 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}, + {color: processColor('hsl(330, 100%, 45.1%)'), position: null}, + {color: processColor('hsl(0, 100%, 50%)'), position: null}, ]); }); @@ -295,8 +295,8 @@ describe('processBackgroundImage', () => { 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}, + {color: processColor('#e66465'), position: null}, + {color: processColor('#9198e5'), position: null}, ]); }); @@ -315,12 +315,12 @@ describe('processBackgroundImage', () => { value: 180, }); 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}, + {color: processColor('rgba(255,0,0,0.5)'), position: null}, + {color: processColor('rgba(0,0,255,0.8)'), position: null}, ]); 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}, + {color: processColor('rgba(255,0,0,0.9)'), position: null}, + {color: processColor('rgba(0,0,255,0.2)'), position: null}, ]); }); @@ -365,8 +365,8 @@ describe('processBackgroundImage', () => { type: 'linearGradient', direction: {type: 'keyword', value: 'to bottom right'}, colorStops: [ - {color: processColor('red'), position: 0}, - {color: processColor('blue'), position: 1}, + {color: processColor('red'), position: '0%'}, + {color: processColor('blue'), position: '100%'}, ], }, ]); @@ -431,19 +431,19 @@ describe('processBackgroundImage', () => { const output = [ { color: processColor('red'), - position: 0.4, + position: '40%', }, { color: processColor('blue'), - position: 0.6, + position: null, }, { color: processColor('green'), - position: 0.8, + position: null, }, { color: processColor('purple'), - position: 1, + position: null, }, ]; const result = processBackgroundImage(input); @@ -472,19 +472,19 @@ describe('processBackgroundImage', () => { const output = [ { color: processColor('red'), - position: 0.4, + position: '40%', }, { color: processColor('red'), - position: 0.8, + position: '80%', }, { color: processColor('blue'), - position: 0.9, + position: null, }, { color: processColor('green'), - position: 1, + position: null, }, ]; expect(result[0].colorStops).toEqual(output); @@ -505,15 +505,15 @@ describe('processBackgroundImage', () => { const output = [ { color: processColor('red'), - position: 0, + position: null, }, { color: processColor('blue'), - position: 0.2, + position: '20%', }, { color: processColor('green'), - position: 1, + position: null, }, ]; const result = processBackgroundImage(input); @@ -538,15 +538,15 @@ describe('processBackgroundImage', () => { const output = [ { color: processColor('red'), - position: -0.5, + position: '-50%', }, { color: processColor('blue'), - position: 0.25, + position: null, }, { color: processColor('green'), - position: 1, + position: null, }, ]; const result = processBackgroundImage(input); @@ -572,19 +572,19 @@ describe('processBackgroundImage', () => { const output = [ { color: processColor('red'), - position: 0, + position: null, }, { color: processColor('blue'), - position: 0, + position: '-50%', }, { color: processColor('green'), - position: 1.5, + position: '150%', }, { color: processColor('yellow'), - position: 1.5, + position: null, }, ]; const result = processBackgroundImage(input); @@ -600,11 +600,11 @@ describe('processBackgroundImage', () => { 'linear-gradient(red 40% 20%, blue 90% 120% , green)', ); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0.4}, - {color: processColor('red'), position: 0.4}, - {color: processColor('blue'), position: 0.9}, - {color: processColor('blue'), position: 1.2}, - {color: processColor('green'), position: 1.2}, + {color: processColor('red'), position: '40%'}, + {color: processColor('red'), position: '20%'}, + {color: processColor('blue'), position: '90%'}, + {color: processColor('blue'), position: '120%'}, + {color: processColor('green'), position: null}, ]); }); @@ -613,12 +613,12 @@ describe('processBackgroundImage', () => { 'linear-gradient(red 40% 20%, blue 90% 120% , green 200% 300%)', ); expect(result[0].colorStops).toEqual([ - {color: processColor('red'), position: 0.4}, - {color: processColor('red'), position: 0.4}, - {color: processColor('blue'), position: 0.9}, - {color: processColor('blue'), position: 1.2}, - {color: processColor('green'), position: 2}, - {color: processColor('green'), position: 3}, + {color: processColor('red'), position: '40%'}, + {color: processColor('red'), position: '20%'}, + {color: processColor('blue'), position: '90%'}, + {color: processColor('blue'), position: '120%'}, + {color: processColor('green'), position: '200%'}, + {color: processColor('green'), position: '300%'}, ]); }); @@ -697,4 +697,198 @@ describe('processBackgroundImage', () => { }); } }); + + it('should process color transition hint in object style', () => { + const input = [ + { + type: 'linearGradient', + direction: 'To Bottom', + colorStops: [{color: 'red'}, {positions: ['20%']}, {color: 'blue'}], + }, + ]; + const result = processBackgroundImage(input); + expect(result[0].type).toBe('linearGradient'); + expect(result[0].direction).toEqual({type: 'angle', value: 180}); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: null}, + {color: null, position: '20%'}, + {color: processColor('blue'), position: null}, + ]); + }); + + it('should process color transition hint', () => { + const input = 'linear-gradient(red, 40%, blue)'; + const result = processBackgroundImage(input); + expect(result[0].type).toBe('linearGradient'); + expect(result[0].direction).toEqual({type: 'angle', value: 180}); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: null}, + {color: null, position: '40%'}, + {color: processColor('blue'), position: null}, + ]); + }); + + it('should return empty array for invalid transition hints', () => { + let result = processBackgroundImage('linear-gradient(red, 40, blue)'); + expect(result).toEqual([]); + + // Multiple hints in a row + result = processBackgroundImage('linear-gradient(red, 20%, 40%, blue)'); + expect(result).toEqual([]); + + // Invalid object syntax + result = processBackgroundImage([ + { + type: 'linearGradient', + colorStops: [{color: 'red'}, {positions: ['40']}, {color: 'blue'}], + }, + ]); + expect(result).toEqual([]); + }); + + it('should process complex gradients with multiple transitioon hints', () => { + const input = 'linear-gradient(red, 20%, blue, 60%, green, 80%, yellow)'; + const result = processBackgroundImage(input); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: null}, + {color: null, position: '20%'}, + {color: processColor('blue'), position: null}, + {color: null, position: '60%'}, + {color: processColor('green'), position: null}, + {color: null, position: '80%'}, + {color: processColor('yellow'), position: null}, + ]); + }); + + it('should process object syntax with multiple hints', () => { + const input = [ + { + type: 'linearGradient', + direction: 'to right', + colorStops: [ + {color: 'red'}, + {positions: ['20%']}, + {color: 'blue'}, + {positions: ['60%']}, + {color: 'green'}, + {positions: ['80%']}, + {color: 'yellow'}, + ], + }, + ]; + const result = processBackgroundImage(input); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: null}, + {color: null, position: '20%'}, + {color: processColor('blue'), position: null}, + {color: null, position: '60%'}, + {color: processColor('green'), position: null}, + {color: null, position: '80%'}, + {color: processColor('yellow'), position: null}, + ]); + }); + + it('should process hints with explicit color stops', () => { + const input = 'linear-gradient(red 0%, 25%, blue 50%, 75%, green 100%)'; + const result = processBackgroundImage(input); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: '0%'}, + {color: null, position: '25%'}, + {color: processColor('blue'), position: '50%'}, + {color: null, position: '75%'}, + {color: processColor('green'), position: '100%'}, + ]); + }); + + it('should handle very complex gradients', () => { + const input = `linear-gradient( + red 0%, + 20% , + blue 30%, + 45%, + green 50%, + 65%, + yellow 70% , + 85%, + purple 100% + )`; + const result = processBackgroundImage(input); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: '0%'}, + {color: null, position: '20%'}, + {color: processColor('blue'), position: '30%'}, + {color: null, position: '45%'}, + {color: processColor('green'), position: '50%'}, + {color: null, position: '65%'}, + {color: processColor('yellow'), position: '70%'}, + {color: null, position: '85%'}, + {color: processColor('purple'), position: '100%'}, + ]); + }); + + it('should handle multiple gradients with hints', () => { + const input = ` + linear-gradient(red, 30%, blue), + linear-gradient(to right, green, 60%, yellow) + `; + const result = processBackgroundImage(input); + expect(result).toHaveLength(2); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: null}, + {color: null, position: '30%'}, + {color: processColor('blue'), position: null}, + ]); + expect(result[1].colorStops).toEqual([ + {color: processColor('green'), position: null}, + {color: null, position: '60%'}, + {color: processColor('yellow'), position: null}, + ]); + }); + + it('should handle invalid transition hint', () => { + const input = ` + linear-gradient(red, 30%, blue, 60%, green, 80%) + `; + const result = processBackgroundImage(input); + expect(result).toEqual([]); + const input1 = ` + linear-gradient(red, 30%, 60%, green) + `; + const result1 = processBackgroundImage(input1); + expect(result1).toEqual([]); + + const input2 = ` + linear-gradient(20%, red, green) + `; + const result2 = processBackgroundImage(input2); + expect(result2).toEqual([]); + }); + + it('should process gradient with % and px color stop positions', () => { + const input = 'linear-gradient(red 10%, 20px, blue 30%, purple 40px)'; + const result = processBackgroundImage(input); + expect(result[0].colorStops).toEqual([ + {color: processColor('red'), position: '10%'}, + {color: null, position: 20}, + {color: processColor('blue'), position: '30%'}, + {color: processColor('purple'), position: 40}, + ]); + + const input1 = [ + { + type: 'linearGradient', + colorStops: [ + {color: 'red', positions: ['10%', 20]}, + {color: 'blue', positions: ['30%', 40]}, + ], + }, + ]; + const result1 = processBackgroundImage(input1); + expect(result1[0].colorStops).toEqual([ + {color: processColor('red'), position: '10%'}, + {color: processColor('red'), position: 20}, + {color: processColor('blue'), position: '30%'}, + {color: processColor('blue'), position: 40}, + ]); + }); }); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 5457579ea3714d..d6599c50c6f91d 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -22,12 +22,17 @@ type LinearGradientDirection = | {type: 'angle', value: number} | {type: 'keyword', value: string}; +// 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 ParsedGradientValue = { type: 'linearGradient', direction: LinearGradientDirection, colorStops: $ReadOnlyArray<{ - color: ProcessedColorValue, - position: number, + color: ColorStopColor, + position: ColorStopPosition, }>, }; @@ -49,33 +54,58 @@ export default function processBackgroundImage( } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { const processedColorStops: Array<{ - color: ProcessedColorValue, - position: number | null, + color: ColorStopColor, + position: ColorStopPosition, }> = []; for (let index = 0; index < bgImage.colorStops.length; index++) { const colorStop = bgImage.colorStops[index]; - const processedColor = processColor(colorStop.color); - if (processedColor == null) { - // If a color is invalid, return an empty array and do not apply gradient. Same as web. - return []; - } - if (colorStop.positions != null && colorStop.positions.length > 0) { - for (const position of colorStop.positions) { - if (position.endsWith('%')) { - processedColorStops.push({ - color: processedColor, - position: parseFloat(position) / 100, - }); - } else { - // If a position is invalid, return an empty array and do not apply gradient. Same as web. - return []; - } + const positions = colorStop.positions; + // Color transition hint syntax (red, 20%, blue) + if ( + colorStop.color == null && + 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 an empty array and do not apply gradient. Same as web. + return []; } } else { - processedColorStops.push({ - color: processedColor, - position: null, - }); + const processedColor = processColor(colorStop.color); + if (processedColor == null) { + // If a color is invalid, return an empty array and do not apply gradient. Same as web. + return []; + } + 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 an empty array and do not apply gradient. Same as web. + return []; + } + } + } else { + processedColorStops.push({ + color: processedColor, + position: null, + }); + } } } @@ -109,12 +139,10 @@ export default function processBackgroundImage( } } - const fixedColorStops = getFixedColorStops(processedColorStops); - result = result.concat({ type: 'linearGradient', direction, - colorStops: fixedColorStops, + colorStops: processedColorStops, }); } } @@ -137,13 +165,6 @@ function parseCSSLinearGradient( let direction: LinearGradientDirection = DEFAULT_DIRECTION; const trimmedDirection = parts[0].trim().toLowerCase(); - // matches individual color stops in a gradient function - // supports various color formats: named colors, hex colors, rgb(a), and hsl(a) - // e.g. "red 20%", "blue 50%", "rgba(0, 0, 0, 0.5) 30% 50%" - // TODO: does not support color hint syntax yet. It is WIP. - const colorStopRegex = - /\s*((?:(?:rgba?|hsla?)\s*\([^)]+\))|#[0-9a-fA-F]+|[a-zA-Z]+)(?:\s+(-?[0-9.]+%?)(?:\s+(-?[0-9.]+%?))?)?\s*/gi; - if (ANGLE_UNIT_REGEX.test(trimmedDirection)) { const parsedAngle = getAngleInDegrees(trimmedDirection); if (parsedAngle != null) { @@ -165,59 +186,107 @@ function parseCSSLinearGradient( // 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 colorStopsString = parts.join(','); const colorStops = []; - const fullColorStopsStr = parts.join(','); - let colorStopMatch; - while ((colorStopMatch = colorStopRegex.exec(fullColorStopsStr))) { - const [, color, position1, position2] = colorStopMatch; - const processedColor = processColor(color.trim().toLowerCase()); - if (processedColor == null) { - // If a color is invalid, return an empty array and do not apply any gradient. Same as web. + // split by comma, but not if it's inside a parentheses. e.g. red, rgba(0, 0, 0, 0.5), green => ["red", "rgba(0, 0, 0, 0.5)", "green"] + const stops = colorStopsString.split(/,(?![^(]*\))/); + let prevStop = null; + for (let i = 0; i < stops.length; i++) { + const stop = stops[i]; + const trimmedStop = stop.trim().toLowerCase(); + // Match function like pattern or single words + const colorStopParts = trimmedStop.match(/\S+\([^)]*\)|\S+/g); + if (colorStopParts == null) { + // If a color stop is invalid, return an empty array and do not apply any gradient. Same as web. return []; } + // Case 1: [color, position, position] + if (colorStopParts.length === 3) { + const color = colorStopParts[0]; + const position1 = getPositionFromCSSValue(colorStopParts[1]); + const position2 = getPositionFromCSSValue(colorStopParts[2]); + const processedColor = processColor(color); + if (processedColor == null) { + // If a color is invalid, return an empty array and do not apply any gradient. Same as web. + return []; + } - if (typeof position1 !== 'undefined') { - if (position1.endsWith('%')) { - colorStops.push({ - color: processedColor, - position: parseFloat(position1) / 100, - }); - } else { + if (position1 == null || position2 == null) { // If a position is invalid, return an empty array and do not apply any gradient. Same as web. return []; } - } else { + + colorStops.push({ + color: processedColor, + position: position1, + }); colorStops.push({ color: processedColor, - position: null, + position: position2, }); } - - if (typeof position2 !== 'undefined') { - if (position2.endsWith('%')) { + // Case 2: [color, position] + else if (colorStopParts.length === 2) { + const color = colorStopParts[0]; + const position = getPositionFromCSSValue(colorStopParts[1]); + const processedColor = processColor(color); + if (processedColor == null) { + // If a color is invalid, return an empty array and do not apply any gradient. Same as web. + return []; + } + if (position == null) { + // If a position is invalid, return an empty array and do not apply any gradient. Same as web. + return []; + } + colorStops.push({ + color: processedColor, + position, + }); + } + // Case 3: [color] + // Case 4: [position] => transition hint syntax + else if (colorStopParts.length === 1) { + const position = getPositionFromCSSValue(colorStopParts[0]); + 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) || + i === stops.length - 1 || + i === 0 + ) { + // If the last stop is a transition hint syntax, return an empty array and do not apply any gradient. Same as web. + return []; + } colorStops.push({ - color: processedColor, - position: parseFloat(position2) / 100, + color: null, + position, }); } else { - // If a position is invalid, return an empty array and do not apply any gradient. Same as web. - return []; + const processedColor = processColor(colorStopParts[0]); + if (processedColor == null) { + // If a color is invalid, return an empty array and do not apply any gradient. Same as web. + return []; + } + colorStops.push({ + color: processedColor, + position: null, + }); } + } else { + // If a color stop is invalid, return an empty array and do not apply any gradient. Same as web. + return []; } + prevStop = colorStopParts; } - const fixedColorStops = getFixedColorStops(colorStops); - gradients.push({ type: 'linearGradient', direction, - colorStops: fixedColorStops, + colorStops, }); } @@ -283,79 +352,12 @@ function getAngleInDegrees(angle?: string): ?number { } } -// https://drafts.csswg.org/css-images-4/#color-stop-fixup -function getFixedColorStops( - colorStops: $ReadOnlyArray<{ - color: ProcessedColorValue, - position: number | null, - }>, -): Array<{ - color: ProcessedColorValue, - position: number, -}> { - let fixedColorStops: Array<{ - color: ProcessedColorValue, - position: number, - }> = []; - let hasNullPositions = false; - let maxPositionSoFar = colorStops[0].position ?? 0; - for (let i = 0; i < colorStops.length; i++) { - const colorStop = colorStops[i]; - let newPosition = colorStop.position; - if (newPosition === null) { - // Step 1: - // If the first color stop does not have a position, - // set its position to 0%. If the last color stop does not have a position, - // set its position to 100%. - if (i === 0) { - newPosition = 0; - } else if (i === colorStops.length - 1) { - newPosition = 1; - } - } - // Step 2: - // If a color stop or transition hint has a position - // that is less than the specified position of any color stop or transition hint - // before it in the list, set its position to be equal to the - // largest specified position of any color stop or transition hint before it. - if (newPosition !== null) { - newPosition = Math.max(newPosition, maxPositionSoFar); - fixedColorStops[i] = { - color: colorStop.color, - position: newPosition, - }; - maxPositionSoFar = newPosition; - } else { - hasNullPositions = true; - } +function getPositionFromCSSValue(position: string) { + if (position.endsWith('px')) { + return parseFloat(position); } - // Step 3: - // If any color stop still does not have a position, - // then, for each run of adjacent color stops without positions, - // set their positions so that they are evenly spaced between the preceding and - // following color stops with positions. - if (hasNullPositions) { - let lastDefinedIndex = 0; - for (let i = 1; i < fixedColorStops.length; i++) { - if (fixedColorStops[i] !== undefined) { - const unpositionedStops = i - lastDefinedIndex - 1; - if (unpositionedStops > 0) { - const startPosition = fixedColorStops[lastDefinedIndex].position; - const endPosition = fixedColorStops[i].position; - const increment = - (endPosition - startPosition) / (unpositionedStops + 1); - for (let j = 1; j <= unpositionedStops; j++) { - fixedColorStops[lastDefinedIndex + j] = { - color: colorStops[lastDefinedIndex + j].color, - position: startPosition + increment * j, - }; - } - } - lastDefinedIndex = i; - } - } + if (position.endsWith('%')) { + return position; } - - return fixedColorStops; } 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 d974a6b27bd338..d92e8c017c157a 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 @@ -8375,12 +8375,14 @@ exports[`public API should not change unintentionally Libraries/StyleSheet/proce "type LinearGradientDirection = | { type: \\"angle\\", value: number } | { type: \\"keyword\\", value: string }; +type ColorStopColor = ProcessedColorValue | null; +type ColorStopPosition = number | string | null; type ParsedGradientValue = { type: \\"linearGradient\\", direction: LinearGradientDirection, colorStops: $ReadOnlyArray<{ - color: ProcessedColorValue, - position: number, + color: ColorStopColor, + position: ColorStopPosition, }>, }; declare export default function processBackgroundImage( diff --git a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm index 47181ea3e0ada2..fe442342b988f4 100644 --- a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm +++ b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm @@ -8,6 +8,9 @@ #import "RCTLinearGradient.h" #import +#import +#import +#include using namespace facebook::react; @@ -17,22 +20,7 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient & { UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size]; const auto &direction = gradient.direction; - const auto &colorStops = gradient.colorStops; - UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull rendererContext) { - CGContextRef context = rendererContext.CGContext; - NSMutableArray *colors = [NSMutableArray array]; - CGFloat locations[colorStops.size()]; - - for (size_t i = 0; i < colorStops.size(); ++i) { - const auto &colorStop = colorStops[i]; - CGColorRef cgColor = RCTCreateCGColorRefFromSharedColor(colorStop.color); - [colors addObject:(__bridge id)cgColor]; - locations[i] = colorStop.position; - } - - CGGradientRef cgGradient = CGGradientCreateWithColors(NULL, (__bridge CFArrayRef)colors, locations); - CGPoint startPoint; CGPoint endPoint; @@ -48,6 +36,25 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient & startPoint = CGPointMake(0.0, 0.0); endPoint = CGPointMake(0.0, size.height); } + + CGFloat dx = endPoint.x - startPoint.x; + CGFloat dy = endPoint.y - startPoint.y; + CGFloat gradientLineLength = sqrt(dx * dx + dy * dy); + const auto processedStops = getFixedColorStops(gradient.colorStops, gradientLineLength); + const auto colorStops = processColorTransitionHints(processedStops); + + CGContextRef context = rendererContext.CGContext; + NSMutableArray *colors = [NSMutableArray array]; + CGFloat locations[colorStops.size()]; + + for (size_t i = 0; i < colorStops.size(); ++i) { + const auto &colorStop = colorStops[i]; + CGColorRef cgColor = RCTCreateCGColorRefFromSharedColor(colorStop.color); + [colors addObject:(__bridge id)cgColor]; + locations[i] = std::max(std::min(colorStop.position.value(), 1.0), 0.0); + } + + CGGradientRef cgGradient = CGGradientCreateWithColors(NULL, (__bridge CFArrayRef)colors, locations); CGContextDrawLinearGradient(context, cgGradient, startPoint, endPoint, 0); @@ -131,4 +138,216 @@ static CGFloat getAngleForKeyword(GradientKeyword keyword, CGSize size) } } +// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint section) +// Browsers add 9 intermediate color stops when a transition hint is present +// Algorithm is referred from Blink engine [source](https://github.com/chromium/chromium/blob/a296b1bad6dc1ed9d751b7528f7ca2134227b828/third_party/blink/renderer/core/css/css_gradient_value.cc#L240). +static std::vector processColorTransitionHints(const std::vector& originalStops) +{ + auto colorStops = std::vector(originalStops); + int indexOffset = 0; + + for (size_t i = 1; i < originalStops.size() - 1; ++i) { + // Skip if not a color hint + if (originalStops[i].color) { + continue; + } + + size_t x = i + indexOffset; + if (x < 1) { + continue; + } + + auto offsetLeft = colorStops[x - 1].position.value(); + auto offsetRight = colorStops[x + 1].position.value(); + auto offset = colorStops[x].position.value(); + auto leftDist = offset - offsetLeft; + auto rightDist = offsetRight - offset; + auto totalDist = offsetRight - offsetLeft; + SharedColor leftSharedColor = colorStops[x - 1].color; + SharedColor rightSharedColor = colorStops[x + 1].color; + + if (facebook::react::floatEquality(leftDist, rightDist)) { + colorStops.erase(colorStops.begin() + x); + --indexOffset; + continue; + } + + if (facebook::react::floatEquality(leftDist, .0f)) { + colorStops[x].color = rightSharedColor; + continue; + } + + if (facebook::react::floatEquality(rightDist, .0f)) { + colorStops[x].color = leftSharedColor; + continue; + } + + std::vector newStops; + newStops.reserve(9); + + // Position the new color stops + if (leftDist > rightDist) { + for (int y = 0; y < 7; ++y) { + ProcessedColorStop newStop{ + SharedColor(), + offsetLeft + leftDist * ((7.0f + y) / 13.0f) + }; + newStops.push_back(newStop); + } + ProcessedColorStop stop1{ + SharedColor(), + offset + rightDist * (1.0f / 3.0f) + }; + ProcessedColorStop stop2 { + SharedColor(), + offset + rightDist * (2.0f / 3.0f) + }; + newStops.push_back(stop1); + newStops.push_back(stop2); + } else { + ProcessedColorStop stop1 { + SharedColor(), + offsetLeft + leftDist * (1.0f / 3.0f) + }; + ProcessedColorStop stop2 { + SharedColor(), + offsetLeft + leftDist * (2.0f / 3.0f) + }; + newStops.push_back(stop1); + newStops.push_back(stop2); + for (int y = 0; y < 7; ++y) { + ProcessedColorStop newStop { + SharedColor(), + offset + rightDist * (y / 13.0f) + }; + newStops.push_back(newStop); + } + } + + // calculate colors for the new color hints. + // The color weighting for the new color stops will be + // pointRelativeOffset^(ln(0.5)/ln(hintRelativeOffset)). + auto hintRelativeOffset = leftDist / totalDist; + const auto logRatio = log(0.5) / log(hintRelativeOffset); + auto leftColor = RCTUIColorFromSharedColor(leftSharedColor); + auto rightColor = RCTUIColorFromSharedColor(rightSharedColor); + NSArray *inputRange = @[@0.0, @1.0]; + NSArray *outputRange = @[leftColor, rightColor]; + + for (auto &newStop : newStops) { + auto pointRelativeOffset = (newStop.position.value() - offsetLeft) / totalDist; + auto weighting = pow( + pointRelativeOffset, + logRatio + ); + + if (!std::isfinite(weighting) || std::isnan(weighting)) { + continue; + } + + auto interpolatedColor = RCTInterpolateColorInRange(weighting, inputRange, outputRange); + + auto alpha = (interpolatedColor >> 24) & 0xFF; + auto red = (interpolatedColor >> 16) & 0xFF; + auto green = (interpolatedColor >> 8) & 0xFF; + auto blue = interpolatedColor & 0xFF; + + newStop.color = facebook::react::colorFromRGBA(red, green, blue, alpha); + + } + + // Replace the color hint with new color stops + colorStops.erase(colorStops.begin() + x); + colorStops.insert(colorStops.begin() + x, newStops.begin(), newStops.end()); + indexOffset += 8; + } + + return colorStops; +} + +// https://drafts.csswg.org/css-images-4/#color-stop-fixup +static std::vector getFixedColorStops(const std::vector& colorStops, CGFloat gradientLineLength) { + std::vector fixedColorStops(colorStops.size()); + bool hasNullPositions = false; + auto maxPositionSoFar = resolveColorStopPosition(colorStops[0].position, gradientLineLength); + if (!maxPositionSoFar.has_value()) { + maxPositionSoFar = 0.0f; + } + + for (size_t i = 0; i < colorStops.size(); i++) { + const auto& colorStop = colorStops[i]; + auto newPosition = resolveColorStopPosition(colorStop.position, gradientLineLength); + + if (!newPosition.has_value()) { + // Step 1: + // If the first color stop does not have a position, + // set its position to 0%. If the last color stop does not have a position, + // set its position to 100%. + if (i == 0) { + newPosition = 0.0f; + } else if (i == colorStops.size() - 1) { + newPosition = 1.0f; + } + } + + // Step 2: + // If a color stop or transition hint has a position + // that is less than the specified position of any color stop or transition hint + // before it in the list, set its position to be equal to the + // largest specified position of any color stop or transition hint before it. + if (newPosition.has_value()) { + newPosition = std::max(newPosition.value(), maxPositionSoFar.value()); + fixedColorStops[i] = ProcessedColorStop{ + colorStop.color, + newPosition + }; + maxPositionSoFar = newPosition; + } else { + hasNullPositions = true; + } + } + + // Step 3: + // If any color stop still does not have a position, + // then, for each run of adjacent color stops without positions, + // set their positions so that they are evenly spaced between the preceding and + // following color stops with positions. + if (hasNullPositions) { + size_t lastDefinedIndex = 0; + for (size_t i = 1; i < fixedColorStops.size(); i++) { + auto endPosition = fixedColorStops[i].position; + if (endPosition.has_value()) { + size_t unpositionedStops = i - lastDefinedIndex - 1; + if (unpositionedStops > 0) { + auto startPosition = fixedColorStops[lastDefinedIndex].position; + if (startPosition.has_value()) { + auto increment = (endPosition.value() - startPosition.value()) / (unpositionedStops + 1); + for (size_t j = 1; j <= unpositionedStops; j++) { + fixedColorStops[lastDefinedIndex + j] = ProcessedColorStop{ + colorStops[lastDefinedIndex + j].color, + startPosition.value() + increment * j + }; + } + } + } + lastDefinedIndex = i; + } + } + } + + return fixedColorStops; +} + +static std::optional resolveColorStopPosition(ValueUnit position, CGFloat gradientLineLength) { + if (position.unit == UnitType::Point) { + return position.resolve(0.0f) / gradientLineLength; + } + + if (position.unit == UnitType::Percent) { + return position.resolve(1.0f); + } + + return std::nullopt; +} + @end diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt index 6b6e3a0c776f4f..fe6348042e79bf 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt @@ -70,5 +70,13 @@ public data class LengthPercentage( return CornerRadii(value, value) } + public fun resolve(referenceLength: Float): Float { + if (type == LengthPercentageType.PERCENT) { + return (value / 100) * referenceLength + } + + return value + } + public constructor() : this(0f, LengthPercentageType.POINT) } 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 index 1591d43c576887..01501a60d54b78 100644 --- 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 @@ -10,9 +10,8 @@ package com.facebook.react.uimanager.style import android.content.Context import android.graphics.Rect import android.graphics.Shader -import com.facebook.react.bridge.ColorPropConverter import com.facebook.react.bridge.ReadableMap -import com.facebook.react.bridge.ReadableType + internal class Gradient(gradient: ReadableMap?, context: Context) { private enum class GradientType { @@ -40,22 +39,7 @@ internal class Gradient(gradient: ReadableMap?, context: Context) { gradient.getArray("colorStops") ?: throw IllegalArgumentException("Invalid colorStops array") - val size = colorStops.size() - val colors = IntArray(size) - val positions = FloatArray(size) - - for (i in 0 until size) { - val colorStop = colorStops.getMap(i) ?: continue - colors[i] = - if (colorStop.getType("color") == ReadableType.Map) { - ColorPropConverter.getColor(colorStop.getMap("color"), context) - } else { - colorStop.getInt("color") - } - positions[i] = colorStop.getDouble("position").toFloat() - } - - linearGradient = LinearGradient(directionMap, colors, positions) + linearGradient = LinearGradient(directionMap, colorStops, context) } public fun getShader(bounds: Rect): Shader? { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt index ababf792b341b9..71c880a15364d4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/LinearGradient.kt @@ -7,16 +7,38 @@ package com.facebook.react.uimanager.style -import android.graphics.LinearGradient as AndroidLinearGradient +import android.content.Context +import android.graphics.Color import android.graphics.Shader +import androidx.core.graphics.ColorUtils +import com.facebook.react.bridge.ColorPropConverter +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.uimanager.FloatUtil +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil import kotlin.math.atan +import kotlin.math.ln +import kotlin.math.sqrt import kotlin.math.tan +import android.graphics.LinearGradient as AndroidLinearGradient + +private data class ColorStop( + var color: Int? = null, + val position: LengthPercentage? = null +) + +private data class ProcessedColorStop( + var color: Int? = null, + val position: Float? = null +) internal class LinearGradient( directionMap: ReadableMap, - private val colors: IntArray, - private val positions: FloatArray + private val colorStopsArray: ReadableArray, + private val context: Context ) { private sealed class Direction { public data class Angle(val value: Double) : Direction() @@ -55,6 +77,27 @@ internal class LinearGradient( else -> throw IllegalArgumentException("Invalid direction type: $type") } + private val colorStops: ArrayList = run { + val stops = 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 position = LengthPercentage.setFromDynamic(colorStop.getDynamic("position")) + + stops.add(ColorStop(color, position)) + } + stops; + } + public fun getShader(width: Float, height: Float): Shader { val angle = when (direction) { @@ -63,6 +106,21 @@ internal class LinearGradient( getAngleForKeyword(direction.value, width.toDouble(), height.toDouble()) } val (startPoint, endPoint) = endPointsFromAngle(angle, height, width) + val dx = endPoint[0] - startPoint[0]; + val dy = endPoint[1] - startPoint[1]; + val gradientLineLength = sqrt(dx * dx + dy * dy) + val processedColorStops = getFixedColorStops(colorStops, gradientLineLength) + val finalStops = processColorTransitionHints(processedColorStops); + val colors = IntArray(finalStops.size) + val positions = FloatArray(finalStops.size) + + finalStops.forEachIndexed { i, colorStop -> + val color = colorStop.color + if (color != null && colorStop.position != null) { + colors[i] = color; + positions[i] = colorStop.position + } + } return AndroidLinearGradient( startPoint[0], startPoint[1], @@ -134,4 +192,202 @@ internal class LinearGradient( return Pair(firstPoint, secondPoint) } + + private fun getFixedColorStops( + colorStops: ArrayList, + gradientLineLength: Float + ): Array { + val fixedColorStops = Array(colorStops.size) { ProcessedColorStop() } + var hasNullPositions = false + var maxPositionSoFar = resolveColorStopPosition(colorStops[0].position, gradientLineLength) ?: 0f + + for (i in colorStops.indices) { + val colorStop = colorStops[i] + var newPosition = resolveColorStopPosition(colorStop.position, gradientLineLength) + + // Step 1: + // If the first color stop does not have a position, + // set its position to 0%. If the last color stop does not have a position, + // set its position to 100%. + newPosition = newPosition ?: when (i) { + 0 -> 0f + colorStops.size - 1 -> 1f + else -> null + } + + // Step 2: + // If a color stop or transition hint has a position + // that is less than the specified position of any color stop or transition hint + // before it in the list, set its position to be equal to the + // largest specified position of any color stop or transition hint before it. + if (newPosition != null) { + newPosition = maxOf(newPosition, maxPositionSoFar) + fixedColorStops[i] = ProcessedColorStop(colorStop.color, newPosition) + maxPositionSoFar = newPosition + } else { + hasNullPositions = true + } + } + + // Step 3: + // If any color stop still does not have a position, + // then, for each run of adjacent color stops without positions, + // set their positions so that they are evenly spaced between the preceding and + // following color stops with positions. + if (hasNullPositions) { + var lastDefinedIndex = 0 + for (i in 1 until fixedColorStops.size) { + val endPosition = fixedColorStops[i].position + if (endPosition != null) { + val unpositionedStops = i - lastDefinedIndex - 1 + if (unpositionedStops > 0) { + val startPosition = fixedColorStops[lastDefinedIndex].position + if (startPosition != null) { + val increment = (endPosition - startPosition) / (unpositionedStops + 1) + for (j in 1..unpositionedStops) { + fixedColorStops[lastDefinedIndex + j] = ProcessedColorStop( + colorStops[lastDefinedIndex + j].color, + startPosition + increment * j + ) + } + } + } + lastDefinedIndex = i + } + } + } + + return fixedColorStops + } + + private fun processColorTransitionHints(originalStops: Array): List { + val colorStops = originalStops.toMutableList() + var indexOffset = 0 + + for (i in 1 until originalStops.size - 1) { + // Skip if not a color hint + if (originalStops[i].color != null) { + continue + } + + val x = i + indexOffset + if (x < 1) { + continue + } + + val offsetLeft = colorStops[x - 1].position + val offsetRight = colorStops[x + 1].position + val offset = colorStops[x].position + if (offsetLeft == null || offsetRight == null || offset == null) { + continue + } + val leftDist = offset - offsetLeft + val rightDist = offsetRight - offset + val totalDist = offsetRight - offsetLeft + val leftColor = colorStops[x - 1].color + val rightColor = colorStops[x + 1].color + + if (FloatUtil.floatsEqual(leftDist, rightDist)) { + colorStops.removeAt(x) + --indexOffset + continue + } + + if (FloatUtil.floatsEqual(leftDist, 0f)) { + colorStops[x].color = rightColor + continue + } + + if (FloatUtil.floatsEqual(rightDist, 0f)) { + colorStops[x].color = leftColor + continue + } + + val newStops = ArrayList(9) + + // Position the new color stops + if (leftDist > rightDist) { + for (y in 0..6) { + newStops.add( + ProcessedColorStop( + null, + offsetLeft + leftDist * ((7f + y) / 13f) + ) + ) + } + newStops.add( + ProcessedColorStop( + null, + offset + rightDist * (1f / 3f) + ) + ) + newStops.add( + ProcessedColorStop( + null, + offset + rightDist * (2f / 3f) + ) + ) + } else { + newStops.add( + ProcessedColorStop( + null, + offsetLeft + leftDist * (1f / 3f) + ) + ) + newStops.add( + ProcessedColorStop( + null, + offsetLeft + leftDist * (2f / 3f) + ) + ) + for (y in 0..6) { + newStops.add( + ProcessedColorStop( + null, + offset + rightDist * (y / 13f) + ) + ) + } + } + + // Calculate colors for the new stops + val hintRelativeOffset = leftDist / totalDist + val logRatio = ln(0.5) / ln(hintRelativeOffset) + + for (newStop in newStops) { + if (newStop.position == null) { + continue + } + val pointRelativeOffset = (newStop.position - offsetLeft) / totalDist + val weighting = Math.pow(pointRelativeOffset.toDouble(), logRatio).toFloat() + + if (!weighting.isFinite() || weighting.isNaN()) { + continue + } + + // Interpolate color using the calculated weighting + leftColor?.let { left -> + rightColor?.let { right -> + newStop.color = ColorUtils.blendARGB(left, right, weighting) + } + } + } + + // Replace the color hint with new color stops + colorStops.removeAt(x) + colorStops.addAll(x, newStops) + indexOffset += 8 + } + + return colorStops + } + + private fun resolveColorStopPosition(position: LengthPercentage?, gradientLineLength: Float): Float? { + if (position == null) return null + + return when (position.type) { + LengthPercentageType.POINT -> PixelUtil.toPixelFromDIP(position.resolve(0f)) / gradientLineLength + LengthPercentageType.PERCENT -> position.resolve(1f) + } + } } 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 ef4202ce9580b8..a0722ff56115ba 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h @@ -1327,11 +1327,21 @@ inline void fromRawValue( auto positionIt = stopMap.find("position"); auto colorIt = stopMap.find("color"); - if (positionIt != stopMap.end() && colorIt != stopMap.end() && - positionIt->second.hasType()) { + if (positionIt != stopMap.end() && colorIt != stopMap.end()) { ColorStop colorStop; - colorStop.position = (Float)(positionIt->second); - fromRawValue(context, colorIt->second, colorStop.color); + if (positionIt->second.hasValue()) { + fromRawValue( + context, + positionIt->second, + colorStop.position); + } + if (colorIt->second.hasValue()) { + fromRawValue( + context.contextContainer, + context.surfaceId, + colorIt->second, + colorStop.color); + } linearGradient.colorStops.push_back(colorStop); } } diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/LinearGradient.h b/packages/react-native/ReactCommon/react/renderer/graphics/LinearGradient.h index c7d1e0b602e48b..987afb977ffcf6 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/LinearGradient.h +++ b/packages/react-native/ReactCommon/react/renderer/graphics/LinearGradient.h @@ -8,6 +8,7 @@ #pragma once #include +#include #include #include #include @@ -35,7 +36,13 @@ struct GradientDirection { struct ColorStop { bool operator==(const ColorStop& other) const = default; SharedColor color; - Float position = 0.0f; + ValueUnit position; +}; + +struct ProcessedColorStop { + bool operator==(const ProcessedColorStop& other) const = default; + SharedColor color; + std::optional position; }; struct LinearGradient { diff --git a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js index 620da9d4079320..9c9e493ca28c3f 100644 --- a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js +++ b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js @@ -19,12 +19,13 @@ import {Platform, PlatformColor, StyleSheet, View} from 'react-native'; type Props = $ReadOnly<{ style: ViewStyleProp, testID?: string, + children?: React.Node, }>; function GradientBox(props: Props): React.Node { return ( - Linear Gradient + {props.children} ); } @@ -56,8 +57,9 @@ exports.examples = [ style={{ experimental_backgroundImage: 'linear-gradient(#e66465, #9198e5);', }} - testID="linear-gradient-basic" - /> + testID="linear-gradient-basic"> + Linear Gradient + ); }, }, @@ -224,4 +226,39 @@ exports.examples = [ ); }, }, + { + title: 'Transition hint', + render(): React.Node { + return ( + + ); + }, + }, + { + title: 'with px and % combination', + render(): React.Node { + return ( + + ); + }, + }, ];