diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js index 4eb927aa9e4e..289ebbe9c78c 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processBackgroundImage-itest.js @@ -353,6 +353,20 @@ describe('processBackgroundImage', () => { expect(result).toEqual([]); }); + it('should return empty array for non-finite color stop positions', () => { + expect(processBackgroundImage('linear-gradient(red NaN%, blue)')).toEqual( + [], + ); + expect( + processBackgroundImage([ + { + type: 'linear-gradient' as const, + colorStops: [{color: 'red', positions: ['NaN%']}, {color: 'blue'}], + }, + ]), + ).toEqual([]); + }); + it('should process unitless zero color stop position', () => { const input = 'linear-gradient(red 0, blue 100%)'; const result = processBackgroundImage(input); diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index e7985e2737a6..04caa08599c5 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -191,6 +191,17 @@ export default function processBackgroundImage( return result; } +function isValidColorStopPosition(position: unknown): boolean { + if (typeof position === 'number') { + return Number.isFinite(position); + } + return ( + typeof position === 'string' && + position.endsWith('%') && + Number.isFinite(parseFloat(position)) + ); +} + function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ color: ColorStopColor, position: ColorStopPosition, @@ -210,10 +221,7 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ positions.length === 1 ) { const position = positions[0]; - if ( - typeof position === 'number' || - (typeof position === 'string' && position.endsWith('%')) - ) { + if (isValidColorStopPosition(position)) { processedColorStops.push({ color: null, position, @@ -230,10 +238,7 @@ function processColorStops(bgImage: BackgroundImageValue): ReadonlyArray<{ } if (positions != null && positions.length > 0) { for (const position of positions) { - if ( - typeof position === 'number' || - (typeof position === 'string' && position.endsWith('%')) - ) { + if (isValidColorStopPosition(position)) { processedColorStops.push({ color: processedColor, position, @@ -810,10 +815,11 @@ function getPositionFromCSSValue(position: string) { } if (position.endsWith('px')) { - return parseFloat(position); + const value = parseFloat(position); + return Number.isFinite(value) ? value : null; } - if (position.endsWith('%')) { + if (isValidColorStopPosition(position)) { return position; } } diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h index a83c563470ad..311b36dc784a 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.h @@ -17,6 +17,10 @@ NS_ASSUME_NONNULL_BEGIN (const std::vector &)colorStops gradientLineLength:(CGFloat)gradientLineLength; ++ (std::vector) + clipColorStops:(const std::vector &)colorStops + end:(facebook::react::Float)end; + // CAGradientLayer linear gradient squishes the non-square gradient to square gradient. // This function fixes the "squished" effect. // See https://stackoverflow.com/a/43176174 for more information. diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm index ee0f5ff8e0c9..78c1f89f2a90 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm @@ -9,6 +9,9 @@ #import #import #import +#include +#include +#include #include #import @@ -143,19 +146,48 @@ CGSize calculateMultipliers(CGSize bounds) } // namespace +// Like Blink, clamp to finite values so that interpolation never divides infinity by infinity. +static Float clampToFinite(Float position) +{ + return std::clamp(position, -std::numeric_limits::max(), std::numeric_limits::max()); +} + static std::optional resolveColorStopPosition(ValueUnit position, CGFloat gradientLineLength) { if (position.unit == UnitType::Point) { - return position.resolve(0.0f) / gradientLineLength; + return clampToFinite(gradientLineLength > 0 ? position.resolve(0.0f) / gradientLineLength : 0); } if (position.unit == UnitType::Percent) { - return position.resolve(1.0f); + return clampToFinite(position.resolve(1.0f)); } return std::nullopt; } +// Interpolates with premultiplied alpha, as CSS does. +// Spec: https://drafts.csswg.org/css-color-4/#interpolation-alpha +static SharedColor interpolateColor(const SharedColor &from, const SharedColor &to, float weight) +{ + if (!from || !to) { + return from ? from : to; + } + auto start = colorComponentsFromColor(from); + auto end = colorComponentsFromColor(to); + auto alpha = start.alpha + (end.alpha - start.alpha) * weight; + if (alpha <= 0) { + return clearColor(); + } + auto channel = [&](float startChannel, float endChannel) { + return (startChannel * start.alpha * (1 - weight) + endChannel * end.alpha * weight) / alpha; + }; + return colorFromComponents( + {.red = channel(start.red, end.red), + .green = channel(start.green, end.green), + .blue = channel(start.blue, end.blue), + .alpha = alpha}); +} + // 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 @@ -336,6 +368,50 @@ @implementation RCTGradientUtils return processColorTransitionHints(fixedColorStops); } +// Spec: https://drafts.csswg.org/css-images-4/#conic-color-stops +// Color stops outside [0, end] are "never directly consulted for rendering", but "can affect the color of +// color-stops within the rendered region through interpolation". So they are replaced by stops at the range +// boundaries, colored as the gradient would be at that point. ++ (std::vector)clipColorStops:(const std::vector &)colorStops end:(Float)end +{ + if (colorStops.empty() || + (colorStops.front().position.value_or(0) >= 0 && colorStops.back().position.value_or(0) <= end)) { + return colorStops; + } + + auto firstInside = std::find_if( + colorStops.begin(), colorStops.end(), [](const auto &stop) { return stop.position.value_or(0) >= 0; }); + auto lastInside = std::find_if( + colorStops.rbegin(), colorStops.rend(), [end](const auto &stop) { return stop.position.value_or(0) <= end; }); + if (firstInside == colorStops.end()) { + return {{.color = colorStops.back().color, .position = 0}, {.color = colorStops.back().color, .position = end}}; + } + if (lastInside == colorStops.rend()) { + return {{.color = colorStops.front().color, .position = 0}, {.color = colorStops.front().color, .position = end}}; + } + + auto colorAt = [](const ProcessedColorStop &before, const ProcessedColorStop &after, Float position) { + auto weight = (position - before.position.value()) / (after.position.value() - before.position.value()); + return interpolateColor(before.color, after.color, static_cast(weight)); + }; + + std::vector clippedColorStops; + if (firstInside != colorStops.begin() && firstInside->position.value() > 0) { + clippedColorStops.push_back({.color = colorAt(*std::prev(firstInside), *firstInside, 0), .position = 0}); + } + auto insideEnd = lastInside.base(); + if (firstInside < insideEnd) { + clippedColorStops.insert(clippedColorStops.end(), firstInside, insideEnd); + } + if (insideEnd != colorStops.end() && std::prev(insideEnd)->position.value() < end) { + clippedColorStops.push_back({.color = colorAt(*std::prev(insideEnd), *insideEnd, end), .position = end}); + } + if (clippedColorStops.size() == 1) { + clippedColorStops.push_back({.color = clippedColorStops.front().color, .position = end}); + } + return clippedColorStops; +} + // CAGradientLayer linear gradient squishes the non-square gradient to square gradient. // This function fixes the "squished" effect. // See https://stackoverflow.com/a/43176174 for more information. diff --git a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm index 2aa56c860df4..c52032e4bf78 100644 --- a/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm +++ b/packages/react-native/React/Fabric/Utils/RCTLinearGradient.mm @@ -36,8 +36,9 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const LinearGradient & CGFloat dx = endPoint.x - startPoint.x; CGFloat dy = endPoint.y - startPoint.y; CGFloat gradientLineLength = sqrt(dx * dx + dy * dy); - const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops - gradientLineLength:gradientLineLength]; + const auto fixedColorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops + gradientLineLength:gradientLineLength]; + const auto colorStops = [RCTGradientUtils clipColorStops:fixedColorStops end:1]; NSMutableArray *colors = [NSMutableArray array]; NSMutableArray *locations = [NSMutableArray array]; CGPoint relativeStartPoint = CGPointMake(startPoint.x / size.width, startPoint.y / size.height); diff --git a/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm b/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm index 35bcd9ede9ba..2080cc27e376 100644 --- a/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm +++ b/packages/react-native/React/Fabric/Utils/RCTRadialGradient.mm @@ -175,8 +175,15 @@ + (CALayer *)gradientLayerWithSize:(CGSize)size gradient:(const RadialGradient & auto [radiusX, radiusY] = GetRadialGradientRadius(isCircle, gradient.size, centerPoint.x, centerPoint.y, size.width, size.height); const auto gradientLineLength = std::max(radiusX, radiusY); - const auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops - gradientLineLength:gradientLineLength]; + auto colorStops = [RCTGradientUtils getFixedColorStops:gradient.colorStops gradientLineLength:gradientLineLength]; + // Unlike negative stops, stops past the ending shape stay visible outside it, so the radius grows to fit them. + const Float radiusScale = colorStops.empty() ? 1 : std::max(colorStops.back().position.value_or(1), 1); + colorStops = [RCTGradientUtils clipColorStops:colorStops end:radiusScale]; + for (auto &colorStop : colorStops) { + colorStop.position = colorStop.position.value_or(0) / radiusScale; + } + radiusX *= radiusScale; + radiusY *= radiusScale; gradientLayer.startPoint = CGPointMake(centerPoint.x / size.width, centerPoint.y / size.height); // endpoint.x is horizontal length and endpoint.y is vertical length gradientLayer.endPoint = CGPointMake( diff --git a/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm b/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm new file mode 100644 index 000000000000..4bd173fcd067 --- /dev/null +++ b/packages/react-native/React/Tests/Fabric/RCTGradientUtilsTests.mm @@ -0,0 +1,261 @@ +/* + * 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 + +#import + +using namespace facebook::react; + +static SharedColor rgba(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255) +{ + return colorFromRGBA(red, green, blue, alpha); +} + +static NSString *describeColor(const SharedColor &color) +{ + if (!color) { + return @"null"; + } + return [NSString stringWithFormat:@"rgba(%d, %d, %d, %d)", + redFromColor(color), + greenFromColor(color), + blueFromColor(color), + alphaFromColor(color)]; +} + +static NSString *describeStops(const std::vector &colorStops) +{ + NSMutableArray *descriptions = [NSMutableArray new]; + for (const auto &colorStop : colorStops) { + [descriptions addObject:[NSString stringWithFormat:@"%@ %@", + describeColor(colorStop.color), + colorStop.position ? (id) @(*colorStop.position) : @"null"]]; + } + return [descriptions componentsJoinedByString:@", "]; +} + +#define RCTAssertColorStop(colorStop, expectedColor, expectedPosition) \ + do { \ + XCTAssertEqualObjects(describeColor((colorStop).color), describeColor(expectedColor)); \ + XCTAssertTrue((colorStop).position.has_value()); \ + XCTAssertEqualWithAccuracy((colorStop).position.value_or(-1), (expectedPosition), 1e-4); \ + } while (0) + +@interface RCTGradientUtilsTests : XCTestCase + +@end + +@implementation RCTGradientUtilsTests { + SharedColor _red; + SharedColor _green; + SharedColor _blue; + SharedColor _yellow; + SharedColor _transparent; +} + +- (void)setUp +{ + [super setUp]; + _red = rgba(255, 0, 0); + _green = rgba(0, 255, 0); + _blue = rgba(0, 0, 255); + _yellow = rgba(255, 255, 0); + _transparent = rgba(0, 0, 0, 0); +} + +- (std::vector)clip:(const std::vector &)colorStops +{ + return [RCTGradientUtils clipColorStops:colorStops end:1]; +} + +- (void)testClipColorStopsKeepsStopsInsideRange +{ + std::vector colorStops = {{_red, 0.1}, {_blue, 0.9}}; + + XCTAssertTrue([self clip:colorStops] == colorStops); +} + +- (void)testClipColorStopsInterpolatesAtBothEnds +{ + auto clipped = [self clip:{{_red, -0.5}, {_blue, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(191, 0, 64), 0); + RCTAssertColorStop(clipped[1], rgba(64, 0, 191), 1); +} + +- (void)testClipColorStopsKeepsStopsBetweenClippedEnds +{ + auto clipped = [self clip:{{_red, -0.5}, {_yellow, 0.25}, {_blue, 1.5}}]; + + XCTAssertEqual(clipped.size(), 3, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(255, 170, 0), 0); + RCTAssertColorStop(clipped[1], _yellow, 0.25); + RCTAssertColorStop(clipped[2], rgba(102, 102, 153), 1); +} + +- (void)testClipColorStopsOutsideRangeBecomeSolidColor +{ + auto below = [self clip:{{_red, -0.8}, {_blue, -0.2}}]; + XCTAssertEqual(below.size(), 2, @"%@", describeStops(below)); + RCTAssertColorStop(below[0], _blue, 0); + RCTAssertColorStop(below[1], _blue, 1); + + auto above = [self clip:{{_red, 1.2}, {_blue, 1.8}}]; + XCTAssertEqual(above.size(), 2, @"%@", describeStops(above)); + RCTAssertColorStop(above[0], _red, 0); + RCTAssertColorStop(above[1], _red, 1); +} + +- (void)testClipColorStopsAddsSecondStopWhenOnlyOneRemains +{ + auto clipped = [self clip:{{_red, -0.1}, {_blue, 0}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +- (void)testClipColorStopsWithCustomEndOnlyClipsBelowZero +{ + auto clipped = [RCTGradientUtils clipColorStops:{{_red, -0.5}, {_blue, 1.5}} end:1.5]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(191, 0, 64), 0); + RCTAssertColorStop(clipped[1], _blue, 1.5); +} + +- (void)testClipColorStopsInterpolatesWithPremultipliedAlpha +{ + auto clipped = [self clip:{{_red, -0.5}, {_transparent, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(255, 0, 0, 191), 0); + RCTAssertColorStop(clipped[1], rgba(255, 0, 0, 64), 1); +} + +- (void)testClipColorStopsKeepsStopsOnRangeBoundaries +{ + auto clipped = [self clip:{{_red, -0.5}, {_green, 0}, {_blue, 1}, {_yellow, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _green, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +- (void)testClipColorStopsInterpolatesBetweenStopsAroundBoundary +{ + auto clipped = [self clip:{{_red, -1}, {_green, -0.5}, {_blue, 0.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(0, 128, 128), 0); + RCTAssertColorStop(clipped[1], _blue, 0.5); +} + +- (void)testClipColorStopsWithNoStopInsideRange +{ + auto clipped = [self clip:{{_red, -1}, {_green, -0.5}, {_blue, 2}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(0, 204, 51), 0); + RCTAssertColorStop(clipped[1], rgba(0, 102, 153), 1); +} + +- (void)testClipColorStopsInterpolatesBetweenSemiTransparentColors +{ + auto clipped = [self clip:{{rgba(255, 0, 0, 128), -0.5}, {_blue, 1.5}}]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], rgba(153, 0, 102, 160), 0); + RCTAssertColorStop(clipped[1], rgba(37, 0, 218, 223), 1); +} + +- (void)testInfinitePositionsAreClampedBeforeClipping +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_red, ValueUnit(-INFINITY, UnitType::Percent)}, {_blue, ValueUnit(50, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + for (const auto &colorStop : fixed) { + XCTAssertTrue(std::isfinite(colorStop.position.value_or(NAN)), @"%@", describeStops(fixed)); + } + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 0.5); +} + +- (void)testTransitionHintsAreExpandedBeforeClipping +{ + auto fixed = [RCTGradientUtils getFixedColorStops:{{_red, ValueUnit(-30, UnitType::Percent)}, + {SharedColor(), ValueUnit(0, UnitType::Percent)}, + {_blue, ValueUnit(65, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + // The hint is an even mix, 127.5, which RCTInterpolateColorInRange rounds up. + RCTAssertColorStop(clipped.front(), rgba(128, 0, 128), 0); + for (const auto &colorStop : clipped) { + XCTAssertGreaterThanOrEqual(colorStop.position.value_or(-1), 0, @"%@", describeStops(clipped)); + } + RCTAssertColorStop(clipped.back(), _blue, 0.65); +} + +// From WPT: linear-gradient(green -50%, blue -50%) and radial-gradient(green -50%, blue -50%). +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear.html +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial.html +- (void)testCoincidentStopsBelowRangeRenderLastColor +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_green, ValueUnit(-50, UnitType::Percent)}, {_blue, ValueUnit(-50, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +// From WPT: linear-gradient(blue 150%, red 150%). +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear-2.html +- (void)testCoincidentStopsAboveRangeRenderFirstColor +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_blue, ValueUnit(150, UnitType::Percent)}, {_red, ValueUnit(150, UnitType::Percent)}} + gradientLineLength:100]; + auto clipped = [self clip:fixed]; + + XCTAssertEqual(clipped.size(), 2, @"%@", describeStops(clipped)); + RCTAssertColorStop(clipped[0], _blue, 0); + RCTAssertColorStop(clipped[1], _blue, 1); +} + +// From WPT: radial-gradient(blue 150%, red 150%). Radial gradients keep stops past the ending +// shape, and grow the radius to fit them. +// https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial-2.html +- (void)testClipColorStopsKeepsStopsUpToCustomEnd +{ + std::vector colorStops = {{_blue, 1.5}, {_red, 1.5}}; + + XCTAssertTrue([RCTGradientUtils clipColorStops:colorStops end:1.5] == colorStops); +} + +- (void)testLengthPositionsOnZeroLengthGradientLineResolveToZero +{ + auto fixed = [RCTGradientUtils + getFixedColorStops:{{_red, ValueUnit(10, UnitType::Point)}, {_blue, ValueUnit(20, UnitType::Point)}} + gradientLineLength:0]; + + XCTAssertEqual(fixed.size(), 2, @"%@", describeStops(fixed)); + RCTAssertColorStop(fixed[0], _red, 0); + RCTAssertColorStop(fixed[1], _blue, 0); +} + +@end diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt index 32e101bcbcf1..11cd854cefb5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt @@ -7,12 +7,14 @@ package com.facebook.react.uimanager.style +import android.graphics.Color import androidx.core.graphics.ColorUtils 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.ln +import kotlin.math.roundToInt /** * Represents a color stop in a gradient as specified by the user. @@ -137,6 +139,94 @@ internal object ColorStopUtils { return processColorTransitionHints(fixedColorStops) } + /** + * Replaces color stops outside `[0, end]` with stops at the range boundaries. + * + * Per the spec, stops outside the range are "never directly consulted for rendering", but "can + * affect the color of color-stops within the rendered region through interpolation". Android's + * gradient shaders pin them to the range instead, so each boundary gets the color the gradient + * would have at that point. + * + * @see CSS Images Module Level + * 4 + */ + fun clipColorStops( + colorStops: List, + end: Float = 1f, + ): List { + val firstPosition = colorStops.firstOrNull()?.position ?: return colorStops + val lastPosition = colorStops.last().position ?: return colorStops + if (firstPosition >= 0f && lastPosition <= end) { + return colorStops + } + + val firstInside = colorStops.indexOfFirst { (it.position ?: 0f) >= 0f } + val lastInside = colorStops.indexOfLast { (it.position ?: 0f) <= end } + if (firstInside == -1) { + val color = colorStops.last().color + return listOf(ProcessedColorStop(color, 0f), ProcessedColorStop(color, end)) + } + if (lastInside == -1) { + val color = colorStops.first().color + return listOf(ProcessedColorStop(color, 0f), ProcessedColorStop(color, end)) + } + + val clippedColorStops = ArrayList(colorStops.size) + if (firstInside > 0 && (colorStops[firstInside].position ?: 0f) > 0f) { + clippedColorStops.add( + ProcessedColorStop(colorAt(colorStops[firstInside - 1], colorStops[firstInside], 0f), 0f) + ) + } + for (i in firstInside..lastInside) { + clippedColorStops.add(colorStops[i]) + } + if (lastInside < colorStops.lastIndex && (colorStops[lastInside].position ?: 0f) < end) { + clippedColorStops.add( + ProcessedColorStop(colorAt(colorStops[lastInside], colorStops[lastInside + 1], end), end) + ) + } + if (clippedColorStops.size == 1) { + clippedColorStops.add(ProcessedColorStop(clippedColorStops[0].color, end)) + } + return clippedColorStops + } + + private fun colorAt( + before: ProcessedColorStop, + after: ProcessedColorStop, + position: Float, + ): Int? { + val beforeColor = before.color ?: return after.color + val afterColor = after.color ?: return beforeColor + val beforePosition = before.position ?: return beforeColor + val afterPosition = after.position ?: return afterColor + return interpolateColor( + beforeColor, + afterColor, + (position - beforePosition) / (afterPosition - beforePosition), + ) + } + + // Interpolates with premultiplied alpha, as CSS and Android's gradient shaders do. + // Spec: https://drafts.csswg.org/css-color-4/#interpolation-alpha + private fun interpolateColor(from: Int, to: Int, weight: Float): Int { + val fromAlpha = Color.alpha(from) / 255f + val toAlpha = Color.alpha(to) / 255f + val alpha = fromAlpha + (toAlpha - fromAlpha) * weight + if (alpha <= 0f) { + return Color.TRANSPARENT + } + fun channel(fromChannel: Int, toChannel: Int): Int = + ((fromChannel * fromAlpha * (1f - weight) + toChannel * toAlpha * weight) / alpha) + .roundToInt() + return Color.argb( + (alpha * 255f).roundToInt(), + channel(Color.red(from), Color.red(to)), + channel(Color.green(from), Color.green(to)), + channel(Color.blue(from), Color.blue(to)), + ) + } + // 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 @@ -242,11 +332,16 @@ internal object ColorStopUtils { return null } + // Like Blink, clamp to finite values so that interpolation never divides infinity by infinity. return when (position.type) { LengthPercentageType.POINT -> - PixelUtil.toPixelFromDIP(position.resolve(0f)) / gradientLineLength + if (gradientLineLength > 0f) { + PixelUtil.toPixelFromDIP(position.resolve(0f)) / gradientLineLength + } else { + 0f + } LengthPercentageType.PERCENT -> position.resolve(1f) - } + }.coerceIn(-Float.MAX_VALUE, Float.MAX_VALUE) } } 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 41050bf0a358..f46c90c2b136 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 @@ -73,7 +73,10 @@ internal class LinearGradient(val direction: Direction, val colorStops: List= 0f } + assertThat(clipped.last().color).isEqualTo(Color.BLUE) + assertThat(clipped.last().position).isEqualTo(0.65f) + } + + // From WPT: linear-gradient(green -50%, blue -50%) and radial-gradient(green -50%, blue -50%). + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear.html + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial.html + @Test + fun testCoincidentStopsBelowRangeRenderLastColor() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop(Color.GREEN, LengthPercentage(-50f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE, LengthPercentage(-50f, LengthPercentageType.PERCENT)), + ), + 100f, + ) + + assertThat(ColorStopUtils.clipColorStops(fixed).map { it.color }) + .containsExactly(Color.BLUE, Color.BLUE) + } + + // From WPT: linear-gradient(blue 150%, red 150%). + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-linear-2.html + @Test + fun testCoincidentStopsAboveRangeRenderFirstColor() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop(Color.BLUE, LengthPercentage(150f, LengthPercentageType.PERCENT)), + ColorStop(Color.RED, LengthPercentage(150f, LengthPercentageType.PERCENT)), + ), + 100f, + ) + + assertThat(ColorStopUtils.clipColorStops(fixed).map { it.color }) + .containsExactly(Color.BLUE, Color.BLUE) + } + + // From WPT: radial-gradient(blue 150%, red 150%). Radial gradients keep stops past the ending + // shape, and grow the radius to fit them. + // https://github.com/web-platform-tests/wpt/blob/master/css/css-images/normalization-radial-2.html + @Test + fun testClipColorStopsKeepsStopsUpToCustomEnd() { + val colorStops = + listOf(ProcessedColorStop(Color.BLUE, 1.5f), ProcessedColorStop(Color.RED, 1.5f)) + + assertThat(ColorStopUtils.clipColorStops(colorStops, end = 1.5f)).isSameAs(colorStops) + } + + @Test + fun testLengthPositionsOnZeroLengthGradientLineResolveToZero() { + val fixed = + ColorStopUtils.getFixedColorStops( + listOf( + ColorStop(Color.RED, LengthPercentage(10f, LengthPercentageType.POINT)), + ColorStop(Color.BLUE, LengthPercentage(20f, LengthPercentageType.POINT)), + ), + 0f, + ) + + assertThat(fixed.map { it.position }).containsExactly(0f, 0f) + } + + @Test + fun testGradientsParseNegativeColorStopPositions() { + val colorStops = + JavaOnlyArray.of( + JavaOnlyMap.of("color", Color.RED, "position", "-50%"), + JavaOnlyMap.of("color", Color.BLUE, "position", -20.0), + ) + val context = RuntimeEnvironment.getApplication() + + val linear = + LinearGradient.parse( + JavaOnlyMap.of( + "direction", + JavaOnlyMap.of("type", "angle", "value", 90.0), + "colorStops", + colorStops, + ), + context, + ) as LinearGradient + val radial = + RadialGradient.parse( + JavaOnlyMap.of( + "shape", + "circle", + "size", + "farthest-corner", + "position", + JavaOnlyMap.of("top", "50%", "left", "50%"), + "colorStops", + colorStops, + ), + context, + ) as RadialGradient + + for (stops in listOf(linear.colorStops, radial.colorStops)) { + assertThat(stops[0].position?.type).isEqualTo(LengthPercentageType.PERCENT) + assertThat(stops[0].position?.resolve(1f)).isEqualTo(-0.5f) + assertThat(stops[1].position?.type).isEqualTo(LengthPercentageType.POINT) + assertThat(stops[1].position?.resolve(0f)).isEqualTo(-20f) + } + } } diff --git a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js index 9c68fa3ed865..244c5f0531be 100644 --- a/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js +++ b/packages/rn-tester/js/examples/LinearGradient/LinearGradientExample.js @@ -302,4 +302,19 @@ exports.examples = [ ); }, }, + { + title: 'Color stops outside 0–100%', + name: 'out-of-range-color-stops', + render(): React.Node { + return ( + + ); + }, + }, ] as Array; diff --git a/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js b/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js index ebea541a0766..5fedc19207a4 100644 --- a/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js +++ b/packages/rn-tester/js/examples/RadialGradient/RadialGradientExample.js @@ -286,4 +286,21 @@ exports.examples = [ ); }, }, + { + title: 'Color stops outside 0–100%', + name: 'out-of-range-color-stops', + render(): React.Node { + return ( + + ); + }, + }, ] as Array; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 0dd275db8b98..7c15b57c0ca2 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1093,6 +1093,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index af03ebeb5a9c..17755c7d808a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1102,6 +1102,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 6de636e0f56d..e03eafed6af7 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1093,6 +1093,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 7ab648c8073e..a08d8a61fc2f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1093,6 +1093,7 @@ interface RCTGenericDelegateSplitter : public NSObject { interface RCTGradientUtils : public NSObject { public virtual static std::pair pointsForCAGradientLayerLinearGradient:endPoint:bounds:(CGPoint startPoint, CGPoint endPoint, CGSize bounds); + public virtual static std::vector clipColorStops:end:(const std::vector& colorStops, facebook::react::Float end); public virtual static std::vector getFixedColorStops:gradientLineLength:(const std::vector& colorStops, CGFloat gradientLineLength); public virtual static void getColors:andLocations:fromColorStops:(NSMutableArray* colors, NSMutableArray* locations, const std::vector& colorStops); }