diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index ac67b1901b1c..ab8dc9138fd7 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -279,7 +279,7 @@ export type GradientValue = { direction: string | undefined; colorStops: Array<{ color: ColorValue; - position: number | undefined; + positions: string[] | undefined; }>; }; diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index c92c63016dc7..00808916d83e 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -715,7 +715,7 @@ export type GradientValue = { direction?: string, colorStops: $ReadOnlyArray<{ color: ____ColorValue_Internal, - position?: string, + positions?: string[], }>, }; diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js index 6a7c3c47e69a..d99d51b7af60 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-test.js @@ -197,9 +197,9 @@ describe('processBackgroundImage', () => { const result = processBackgroundImage(input); expect(result[0].colorStops).toEqual([ {color: processColor('red'), position: 0}, - {color: processColor('green'), position: 0.25}, + {color: processColor('green'), position: 0.3}, {color: processColor('blue'), position: 0.6}, - {color: processColor('yellow'), position: 0.75}, + {color: processColor('yellow'), position: 0.8}, {color: processColor('purple'), position: 1}, ]); }); @@ -282,8 +282,8 @@ describe('processBackgroundImage', () => { type: 'linearGradient', direction: 'to bottom right', colorStops: [ - {color: 'red', position: '0%'}, - {color: 'blue', position: '100%'}, + {color: 'red', positions: ['0%']}, + {color: 'blue', positions: ['100%']}, ], }, ]; @@ -341,41 +341,227 @@ describe('processBackgroundImage', () => { expect(result[0].end.y).toBeCloseTo(0.146447, 5); }); - it('should process an style object with mix of default and undefined stop positions', () => { + it('should fix up stop positions #1', () => { const input = [ { type: 'linearGradient', colorStops: [ - {color: 'red'}, + {color: 'red', positions: ['40%']}, {color: 'blue'}, {color: 'green'}, - {color: 'purple', position: '80%'}, - {color: 'pink'}, + {color: 'purple'}, ], }, ]; - const result = processBackgroundImage(input); - expect(result[0].colorStops).toEqual([ + const output = [ { color: processColor('red'), - position: 0, + position: 0.4, }, { color: processColor('blue'), - position: 0.25, + position: 0.6, }, { color: processColor('green'), - position: 0.5, + position: 0.8, }, { color: processColor('purple'), + position: 1, + }, + ]; + const result = processBackgroundImage(input); + const result1 = processBackgroundImage( + `linear-gradient(red 40%, blue, green, purple)`, + ); + expect(result[0].colorStops).toEqual(output); + expect(result1[0].colorStops).toEqual(output); + }); + + it('should process multiple stop positions', () => { + const input = [ + { + type: 'linearGradient', + colorStops: [ + {color: 'red', positions: ['40%', '80%']}, + {color: 'blue'}, + {color: 'green'}, + ], + }, + ]; + const result = processBackgroundImage(input); + const result2 = processBackgroundImage( + `linear-gradient(red 40% 80%, blue, green)`, + ); + const output = [ + { + color: processColor('red'), + position: 0.4, + }, + { + color: processColor('red'), position: 0.8, }, { - color: processColor('pink'), + color: processColor('blue'), + position: 0.9, + }, + { + color: processColor('green'), position: 1, }, + ]; + expect(result[0].colorStops).toEqual(output); + expect(result2[0].colorStops).toEqual(output); + }); + + it('should fix up stop positions #2', () => { + const input = [ + { + type: 'linearGradient', + colorStops: [ + {color: 'red'}, + {color: 'blue', positions: ['20%']}, + {color: 'green'}, + ], + }, + ]; + const output = [ + { + color: processColor('red'), + position: 0, + }, + { + color: processColor('blue'), + position: 0.2, + }, + { + color: processColor('green'), + position: 1, + }, + ]; + const result = processBackgroundImage(input); + const result1 = processBackgroundImage( + `linear-gradient(red , blue 20%, green)`, + ); + expect(result[0].colorStops).toEqual(output); + expect(result1[0].colorStops).toEqual(output); + }); + + it('should fix up stop positions #3', () => { + const input = [ + { + type: 'linearGradient', + colorStops: [ + {color: 'red', positions: ['-50%']}, + {color: 'blue'}, + {color: 'green'}, + ], + }, + ]; + const output = [ + { + color: processColor('red'), + position: -0.5, + }, + { + color: processColor('blue'), + position: 0.25, + }, + { + color: processColor('green'), + position: 1, + }, + ]; + const result = processBackgroundImage(input); + const result1 = processBackgroundImage( + `linear-gradient(red -50%, blue, green)`, + ); + expect(result[0].colorStops).toEqual(output); + expect(result1[0].colorStops).toEqual(output); + }); + + it('should fix up stop positions #4', () => { + const input = [ + { + type: 'linearGradient', + colorStops: [ + {color: 'red'}, + {color: 'blue', positions: ['-50%']}, + {color: 'green', positions: ['150%']}, + {color: 'yellow'}, + ], + }, + ]; + const output = [ + { + color: processColor('red'), + position: 0, + }, + { + color: processColor('blue'), + position: 0, + }, + { + color: processColor('green'), + position: 1.5, + }, + { + color: processColor('yellow'), + position: 1.5, + }, + ]; + const result = processBackgroundImage(input); + const result1 = processBackgroundImage( + `linear-gradient(red, blue -50%, green 150%, yellow)`, + ); + expect(result[0].colorStops).toEqual(output); + expect(result1[0].colorStops).toEqual(output); + }); + + it('should fix up stop positions #5', () => { + const result = 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}, + ]); + }); + + it('should fix up stop positions #6', () => { + const result = 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}, ]); }); + + it('should return empty array for invalid multiple stop positions', () => { + const result = processBackgroundImage([ + { + type: 'linearGradient', + colorStops: [ + {color: 'red', positions: ['40% 20']}, + {color: 'blue', positions: ['90% 120%']}, + {color: 'green', positions: ['200% 300%']}, + ], + }, + ]); + const result1 = processBackgroundImage( + 'linear-gradient(red 40% 20, blue 90% 120% , green 200% 300%)', + ); + expect(result).toEqual([]); + expect(result1).toEqual([]); + }); }); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 975d132aaaf2..38dda2e4b445 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -45,33 +45,34 @@ export default function processBackgroundImage( result = parseCSSLinearGradient(backgroundImage); } else if (Array.isArray(backgroundImage)) { for (const bgImage of backgroundImage) { - const processedColorStops = []; + const processedColorStops: Array<{ + color: ProcessedColorValue, + position: number | null, + }> = []; for (let index = 0; index < bgImage.colorStops.length; index++) { - const stop = bgImage.colorStops[index]; - const processedColor = processColor(stop.color); - let processedPosition: number | null = null; - - // Currently we only support percentage and undefined value for color stop position. - if (typeof stop.position === 'undefined') { - processedPosition = - bgImage.colorStops.length === 1 - ? 1 - : index / (bgImage.colorStops.length - 1); - } else if (stop.position.endsWith('%')) { - processedPosition = parseFloat(stop.position) / 100; - } else { - // If a color stop position is invalid, return an empty array and do not apply gradient. Same as web. + 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 (processedColor != null) { + 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 []; + } + } + } else { processedColorStops.push({ color: processedColor, - position: processedPosition, + position: null, }); - } else { - // If a color is invalid, return an empty array and do not apply gradient. Same as web. - return []; } } @@ -96,12 +97,14 @@ export default function processBackgroundImage( } } + const fixedColorStops = getFixedColorStops(processedColorStops); + if (points != null) { result = result.concat({ type: 'linearGradient', start: points.start, end: points.end, - colorStops: processedColorStops, + colorStops: fixedColorStops, }); } } @@ -123,7 +126,7 @@ function parseCSSLinearGradient( let points = TO_BOTTOM_START_END_POINTS; const trimmedDirection = parts[0].trim().toLowerCase(); const colorStopRegex = - /\s*((?:(?:rgba?|hsla?)\s*\([^)]+\))|#[0-9a-fA-F]+|[a-zA-Z]+)(?:\s+([0-9.]+%?))?\s*/gi; + /\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 angle = parseAngle(trimmedDirection); @@ -154,32 +157,50 @@ function parseCSSLinearGradient( const fullColorStopsStr = parts.join(','); let colorStopMatch; while ((colorStopMatch = colorStopRegex.exec(fullColorStopsStr))) { - const [, color, position] = colorStopMatch; + const [, color, position1, position2] = colorStopMatch; const processedColor = processColor(color.trim().toLowerCase()); - if ( - processedColor != null && - (typeof position === 'undefined' || position.endsWith('%')) - ) { + 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 a position is invalid, return an empty array and do not apply any gradient. Same as web. + return []; + } + } else { colorStops.push({ color: processedColor, - position: position ? parseFloat(position) / 100 : null, + position: null, }); - } else { - // If a color or position is invalid, return an empty array and do not apply any gradient. Same as web. - return []; + } + + if (typeof position2 !== 'undefined') { + if (position2.endsWith('%')) { + colorStops.push({ + color: processedColor, + position: parseFloat(position2) / 100, + }); + } else { + // If a position is invalid, return an empty array and do not apply any gradient. Same as web. + return []; + } } } + const fixedColorStops = getFixedColorStops(colorStops); + gradients.push({ type: 'linearGradient', start: points.start, end: points.end, - colorStops: colorStops.map((stop, index, array) => ({ - color: stop.color, - position: - stop.position ?? - (array.length === 1 ? 1 : index / (array.length - 1)), - })), + colorStops: fixedColorStops, }); } @@ -284,3 +305,80 @@ function parseAngle(angle: string): ?number { return null; } } + +// 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; + } + } + + // 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; + } + } + } + + 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 32fe6394c1f5..f89a22652823 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 @@ -8330,7 +8330,7 @@ export type GradientValue = { direction?: string, colorStops: $ReadOnlyArray<{ color: ____ColorValue_Internal, - position?: string, + positions?: string[], }>, }; export type BoxShadowPrimitive = { diff --git a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js index 0a3486adc366..add517122311 100644 --- a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js +++ b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js @@ -116,8 +116,8 @@ exports.examples = [ type: 'linearGradient', direction: 'to bottom', colorStops: [ - {color: 'purple', position: '0%'}, - {color: 'orange', position: '100%'}, + {color: 'purple', positions: ['0%']}, + {color: 'orange', positions: ['100%']}, ], }, ],