Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: start and end points linear gradient algorithm #47003

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

intergalacticspacehighway
Copy link
Contributor

@intergalacticspacehighway intergalacticspacehighway commented Oct 13, 2024

Summary:

  • Current implementation does not follow CSS spec for rectangle boxes with corner angles. It is using non spec compliant algorithm to calculate start and end points. This PR follows the spec compliant algorithm to implement and makes sure Web, iOS and Android gradients are identical with corner angles.
  • Also, currently it is using CAGradientLayer which does not support spec compliant start and end points i.e. start and end point can be outside of rectangle bounds. This leads to inconsistent gradients on iOS for corner angles compared to web and android. So this PR replaces it with CGGradient.
  • I have also moved some files to make it easier to add more background image types in future.

Changelog:

[GENERAL] [FIXED] - Linear gradient start and end point algorithm.

Test Plan:

  • Added multiple gradient example which should be identical in all platforms (Web, iOS and Android) and tested thoroughly on all platforms. I think some visual test cases can help here.
  • I have referred to blink's implementation.

Aside

Took a while to understand the spec, but felt great after getting it. Gradients should be 100% identical on all platforms now. Sorry i missed testing cornered angles + rectangles earlier and I found out it is inconsistent on platforms just this weekend 😅

Screenshot 2024-10-14 at 12 24 45 AM

@facebook-github-bot facebook-github-bot added CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 13, 2024
Copy link
Contributor

@jorge-cab jorge-cab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any plans to implement other backgroundImage features in the near future? If so do give us a heads up!

@@ -123,25 +140,30 @@ function parseCSSLinearGradient(
while ((match = linearGradientRegex.exec(cssString))) {
const gradientContent = match[1];
const parts = gradientContent.split(',');
let points = TO_BOTTOM_START_END_POINTS;
let orientation: LinearGradientOrientation = DEFAULT_ORIENTATION;
const trimmedDirection = parts[0].trim().toLowerCase();
const colorStopRegex =
/\s*((?:(?:rgba?|hsla?)\s*\([^)]+\))|#[0-9a-fA-F]+|[a-zA-Z]+)(?:\s+(-?[0-9.]+%?)(?:\s+(-?[0-9.]+%?))?)?\s*/gi;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a comment explaining what this regex matches to and same with the linearGradientRegex?

eg from processFilter: // matches on functions with args and nested functions like "drop-shadow(10 10 10 rgba(0, 0, 0, 1))"

Or maybe a link to the syntax we are matching from the spec?

@@ -123,25 +140,30 @@ function parseCSSLinearGradient(
while ((match = linearGradientRegex.exec(cssString))) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we only call parseCSSLinearGradient if we already know what we are parsing is a linearGradient. I'm thinking it can be a head start for if we add other backgroundImage functions like radial gradients or even url.

We did something similar for drop-shadow on processFilter.js

I'll leave this up to you since its not a blocker for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jorge-cab yes, will follow up on this when adding more support to backgroundImage. First i want to implement color hints syntax support with linear gradients, which will be done in this PR. Thanks for reviewing!

@lunaleaps
Copy link
Contributor

Catching up on context here, was there previous work from you to implement the linear gradient? Or this is a longstanding thing that is being addressed?

Do you have screenshots of the difference between the gradient? What's the con of using a non-standard algorithm today?

@intergalacticspacehighway
Copy link
Contributor Author

@lunaleaps Sure. Linear gradient was done in these PRs (#45434) and #45433. It is still behind experimental flag like box shadow and filters. The con of using non standard algo is I used simple unit square for calculation of start and end points which doesn't work well for rectangle boxes when angles are cornered. Something I found last week while testing 🤦‍♂️ so made this PR. This PR adds standard algo which makes it match to the output of web.

e.g. output of linear-gradient(45deg, red, blue) for box h100, w200 on web and before/after this PR fix. RN will match the web output.

Web React Native Before React Native After
Screenshot 2024-10-15 at 3 35 23 PM Screenshot 2024-10-15 at 3 29 52 PM Screenshot 2024-10-15 at 3 33 39 PM

@lunaleaps
Copy link
Contributor

Thanks @intergalacticspacehighway for the details and including the screenshots!
Okay, glad to hear its behind an experimental flag -- also didn't realize @jorge-cab from the team is reviewing already and has more context!

@facebook-github-bot
Copy link
Contributor

@jorge-cab has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

Copy link
Contributor

@jorge-cab jorge-cab left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Math looks good! Nice job figuring this out.

orientationIt->second.hasType<std::unordered_map<std::string, RawValue>>()) {
auto orientationMap = static_cast<std::unordered_map<std::string, RawValue>>(orientationIt->second);

auto typeIt = orientationMap.find("type");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this naming is shadowing we should rename


auto colorSpace = getDefaultColorSpace() == ColorSpace::sRGB ? CGColorSpaceCreateDeviceRGB() : CGColorSpaceCreateWithName(kCGColorSpaceDisplayP3);

CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (__bridge CFArrayRef)colors, locations);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This naming is shadowing, we should rename

UIImage *gradientImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) {
CGContextRef context = rendererContext.CGContext;
NSMutableArray *colors = [NSMutableArray array];
CGFloat *locations = (CGFloat *)malloc(sizeof(CGFloat) * colorStops.size());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really want to avoid malloc can we do something like:

Suggested change
CGFloat *locations = (CGFloat *)malloc(sizeof(CGFloat) * colorStops.size());
CGFloat locations[colorStops.size()];

Comment on lines 1030 to 1031
backgroundImageLayer.borderWidth = layer.borderWidth;
backgroundImageLayer.borderColor = layer.borderColor;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to set these two here, border layer will be composited on top of backgroundImage

Suggested change
backgroundImageLayer.borderWidth = layer.borderWidth;
backgroundImageLayer.borderColor = layer.borderColor;

backgroundImageLayer.frame = layer.bounds;
backgroundImageLayer.masksToBounds = YES;
// border styling to work with gradient layers
if (useCoreAnimationBorderRendering) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section should just be used to round the corners of the backgroundImage we don't really care about most of the things useCoreAnimationBorderRendering checks for. We can focus on the border being uniform

Suggested change
if (useCoreAnimationBorderRendering) {
if (borderMetrics.borderRadii.isUniform()) {

Comment on lines 1035 to 1042
CAShapeLayer *maskLayer = [CAShapeLayer layer];
CGPathRef path = RCTPathCreateWithRoundedRect(
self.bounds,
RCTGetCornerInsets(RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero),
nil);
maskLayer.path = path;
CGPathRelease(path);
backgroundImageLayer.mask = maskLayer;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use createMaskLayer from line ~1094 instead

maskLayer.path = path;
CGPathRelease(path);
backgroundImageLayer.mask = maskLayer;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to reset corners to 0 in case of a state change backgroundImageLayer.cornerRadius = 0;

@@ -21,63 +20,46 @@ public class Gradient(gradient: ReadableMap?, context: Context) {
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this class internal instead of public. We don' want people to start relying on this yet

import kotlin.math.atan
import kotlin.math.tan

public class LinearGradient(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public class LinearGradient(
internal class LinearGradient(

Comment on lines 26 to 43
private val orientation: Orientation

init {
orientation = when (val type = orientationMap.getString("type")) {
"angle" -> {
val angle = orientationMap.getDouble("value")
Orientation.Angle(angle)
}

"direction" -> {
val direction = orientationMap.getString("value")
?: throw IllegalArgumentException("Direction value cannot be null")
Orientation.Direction(direction)
}

else -> throw IllegalArgumentException("Invalid orientation type: $type")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We should just assign this to the variable no need to use init

@intergalacticspacehighway
Copy link
Contributor Author

intergalacticspacehighway commented Oct 17, 2024

Pushed all fixes @jorge-cab. Thanks for the quick review! <3

@facebook-github-bot
Copy link
Contributor

@jorge-cab has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

@jorge-cab
Copy link
Contributor

@intergalacticspacehighway I should mention that I'm starting to do work to implement the image features of background-image and potentially radial-gradient after so if you are interested in taking on radial-gradient lmk so that we don't step on each other.

@intergalacticspacehighway
Copy link
Contributor Author

@jorge-cab That's amazing to hear, thanks for informing. i was thinking to pick radial gradients after finishing transition hint syntax with linear gradient. I can implement radial gradiant by next weekend. Is that alright? if not you can pick it

@jorge-cab
Copy link
Contributor

@jorge-cab That's amazing to hear, thanks for informing. i was thinking to pick radial gradients after finishing transition hint syntax with linear gradient. I can implement radial gradiant by next weekend. Is that alright? if not you can pick it

Yeah, feel free to work on it. I don't even know when I'd start with radial gradient so no rush there haha

@intergalacticspacehighway
Copy link
Contributor Author

@jorge-cab Is anything pending on this PR. can we get this merged? I want to do some follow up PRs (px and transition hint support) that might be based on this one. Thanks 🙏

btw i have started doing some analysis for radial gradients. Currently it's a bit challenging with CSS spec as both iOS and Android's APIs lack ellipse shape support, circles are easy. I am still looking if there's a way.

) {
const parsedDirection = getDirectionString(bgImage.direction);
if (parsedDirection != null) {
orientation = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if the single keyword variants are directlty convertible to angles, that might make sense to simplify during parsing

https://www.w3.org/TR/css-images-3/#linear-gradient-syntax

Copy link
Contributor

@jorge-cab jorge-cab Oct 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this too but it seems keywords like to top left (which means the angle should align with the view's corner) need information about the view which we can't do during parsing.

We could however preemptively parse to top, to left etc since those map to constant angle values. However naively I feel like we should avoid parsing in such a fragmented way

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i also had same thoughts as @jorge-cab about parsing in fragmented way but i am down to re-using angles and passing only corner styles, will make the change, lmk if sounds good to you @jorge-cab.
i was also thinking in the longer term that dimension-dependent styles like these could be part of layout nodes right before setting native view dimensions, lot of native styling logic could be moved there and code can be reused among platforms. e.g transform-origin, translate with %, gradients, also support for calc function etc. Is there an ongoing effort in that direction? i can also take a look, i know @NickGerleman is working on native CSS parser, but i haven't dug very deep into it yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here, loving it. It made it more concise and reusable.

if (parsedDirection != null) {
orientation = {
type: 'direction',
value: parsedDirection,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apart from those that can be simplified to angles, we have four corners this could point to, so it might be simpler to pass one of four well known values to native for these.

@@ -17,22 +17,37 @@ const processColor = require('./processColor').default;
const DIRECTION_REGEX =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this should also be case insensitive, since CSS keywords generally are


type LinearGradientOrientation =
| {type: 'angle', value: number}
| {type: 'direction', value: string};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider keyword or keywords instead of direction, to map to spec, since angle is considered a direction in verbiage

[colors addObject:(id)color.CGColor];
[locations addObject:location];
// iterate in reverse to match CSS specification
for (auto it = _props->backgroundImage.rbegin(); it != _props->backgroundImage.rend(); ++it) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think we require min XCode which supports std::ranges::reverse_view as well,

) {
private sealed class Orientation {
public data class Angle(val value: Double) : Orientation()
public data class Direction(val value: String) : Orientation()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way we could parse this into one of four possible unique values as enum, instead of keeping string around?


backgroundImage.push_back(gradientValue);
backgroundImage.push_back(BackgroundImage{BackgroundImageType::LinearGradient, linearGradient});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
backgroundImage.push_back(BackgroundImage{BackgroundImageType::LinearGradient, linearGradient});
backgroundImage.push_back(BackgroundImage{BackgroundImageType::LinearGradient, std::move(linearGradient)});

Comment on lines 23 to 25
bool operator==(const BackgroundImage& other) const {
return type == other.type && value == other.value;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and below, does not seem like we need to provide explicit equality operator

Suggested change
bool operator==(const BackgroundImage& other) const {
return type == other.type && value == other.value;
}
bool operator==(const BackgroundImage& other) const = default;

struct ColorStop {
bool operator==(const ColorStop& other) const = default;
SharedColor color;
Float position;
Copy link
Contributor

@NickGerleman NickGerleman Oct 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: provide explicit initial value for primitives


struct GradientOrientation {
GradientOrientationType type;
std::variant<Float, std::string> value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we parse the Direction/Keyword version into enum as early as possible instead of keeping string around?

@NickGerleman
Copy link
Contributor

Left a whole bunch of comments, but I’m also about to be on PTO for two weeks, and want to make sure I don’t block anything while I’m gone 😅.

I left a lot of nits/mechanical pieces that aren’t super critical, but the high level points I’m curious about are:

  1. A case where I wasn’t certain we were safely managing image lifetime
  2. Not wanting to keep strings around in prop storage layer where we have small finite set of possible values
  3. Making parsing on Android side graceful instead of throw
  4. Whether we could do drawing in layer delegate instead of always rasterizing to new bitmap (but the other things already do create a bitmap, even though I’m not sure they should be)
  5. Some z-index/clipping things in iOS side I want @joevilches to double check since there are concurrent changes happening there

@intergalacticspacehighway to give you some more context on delays after import, imported PRs need approver outside of engineer who imports. Sometimes an imported PR has been reviewed thoroughly by owner/expert before importing, sometimes it has gone through some initial pass and needs more eyes, and other times it hasn’t been reviewed at all. This means that this review internally usually carries a similar weight as the external one.

Larger changes usually spend longer on the review queue, especially those that touch multiple platforms (where say, someone with Android expertise might not feel comfortable signing off on a change with major iOS components). This is tricky, because a long latency from PR to commit encourages grouping more changes together. In general though, small/scoped changes usually get through the pipeline quicker though, where there are opportunities to split things up.

Some of the tooling in Metas monorepo encouraging breaking up changes into smaller reviewable “stacked” commits, which also influences some of the culture of small commits. I’ve heard some folks on the React team end up using “ghstack” to make this pattern easier against GitHub, but haven’t used it myself to vouch for it.

@intergalacticspacehighway
Copy link
Contributor Author

intergalacticspacehighway commented Nov 2, 2024

@NickGerleman have a good time on your PTO 😄. Thanks for reviewing the PR and explaining the process in depth. I pushed almost all the changes apart from these two:

  • I just tried drawing in layer and it works. I had followed the existing bitmap approach instead of exploring layer drawing. But layer drawing seems to be working fine. I'd prefer to do another PR to replace it since this one has already gotten a bit huge, but lmk! I am thinking of creating RCTLinearGradientLayer that can handle it's own drawing.
  • @joevilches lmk if there're any relevant changes affecting clipping/borders.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants