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

feat: radial gradient android changes #50269

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

Conversation

intergalacticspacehighway
Copy link
Contributor

@intergalacticspacehighway intergalacticspacehighway commented Mar 25, 2025

Summary:

Adds android changes for radial gradient. Previous PR - #50209

Changelog:

[ANDROID] [ADDED] - Radial gradient

Test Plan:

@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 Mar 25, 2025
@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.

1 bug, crashing on radius of 0 and the rest code styling stuff

val shader = AndroidRadialGradient(
centerX,
centerY,
radiusX,
Copy link
Contributor

@jorge-cab jorge-cab Mar 26, 2025

Choose a reason for hiding this comment

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

I think this will crash when passing a radius of 0?

Something like radial-gradient(0px 0px at center, red, blue)

Negative radius seems fine though it should fail like on web, I would add test for both cases

Comment on lines 33 to 46
private enum class Shape {
CIRCLE,
ELLIPSE;

companion object {
fun fromString(value: String): Shape {
return when (value.lowercase()) {
"circle" -> CIRCLE
"ellipse" -> ELLIPSE
else -> ELLIPSE
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can do:

Suggested change
private enum class Shape {
CIRCLE,
ELLIPSE;
companion object {
fun fromString(value: String): Shape {
return when (value.lowercase()) {
"circle" -> CIRCLE
"ellipse" -> ELLIPSE
else -> ELLIPSE
}
}
}
}
private enum class Shape(val value: String) {
CIRCLE("circle"),
ELLIPSE("ellipse");
companion object {
fun fromString(value: String): Shape =
enumValues<Shape>().find { it.value == value.lowercase() } ?: ELLIPSE
}
}

Since the fromString() method feels a bit arbitrary like that

Comment on lines 48 to 64
private enum class SizeKeyword {
CLOSEST_SIDE,
FARTHEST_SIDE,
CLOSEST_CORNER,
FARTHEST_CORNER;

companion object {
fun fromString(value: String?): SizeKeyword {
return when (value?.lowercase()) {
"closest-side" -> CLOSEST_SIDE
"farthest-side" -> FARTHEST_SIDE
"closest-corner" -> CLOSEST_CORNER
else -> FARTHEST_CORNER
}
}
}
}
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
private enum class SizeKeyword {
CLOSEST_SIDE,
FARTHEST_SIDE,
CLOSEST_CORNER,
FARTHEST_CORNER;
companion object {
fun fromString(value: String?): SizeKeyword {
return when (value?.lowercase()) {
"closest-side" -> CLOSEST_SIDE
"farthest-side" -> FARTHEST_SIDE
"closest-corner" -> CLOSEST_CORNER
else -> FARTHEST_CORNER
}
}
}
}
private enum class SizeKeyword {
CLOSEST_SIDE("closest-side"),
FARTHEST_SIDE("farthest-side"),
CLOSEST_CORNER("closest-corner"),
FARTHEST_CORNER("farthest-corner");
companion object {
fun fromString(value: String?): SizeKeyword =
enumValues<SizeKeyword>().find { it.value == value.lowercase() } ?: FARTHEST_CORNER
}
}

Comment on lines 88 to 91
private val shape: Shape = run {
val shapeString = gradientMap.getString("shape") ?: throw IllegalArgumentException("Radial gradient must have shape")
Shape.fromString(shapeString)
}
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
private val shape: Shape = run {
val shapeString = gradientMap.getString("shape") ?: throw IllegalArgumentException("Radial gradient must have shape")
Shape.fromString(shapeString)
}
private val shape: Shape = gradientMap.getString("shape")?.let { Shape.fromString(it) }
?: throw IllegalArgumentException("Radial gradient must have shape")

}

else -> throw IllegalArgumentException("Invalid direction type: $type")
private val direction: Direction = run {
Copy link
Contributor

Choose a reason for hiding this comment

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

We are parsing AND initializing here. I think we should do a parseDirection function to split the logic a bit.

Copy link
Contributor

Choose a reason for hiding this comment

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

A way that would be nice to layer these, is if we have some ctor taking the concrete data types, and then some function that extracts those out of ReadableMap, and constructs from it. That will also make these types generic in the future to changes in how we might store the intermediates.

}
private val isCircle: Boolean = shape == Shape.CIRCLE

private val position: Position = run {
Copy link
Contributor

Choose a reason for hiding this comment

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

For all run blocks. Maybe instead we can have a parsePosition(), parseSize and parseColorStops instead of doing the logic on initialization so we are not both initializing and parsing at the same time? @NickGerleman What do you think?

linearGradient.getShader(bounds.width().toFloat(), bounds.height().toFloat())
}
}
public interface Gradient {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it intentional we moved this from internal to public?

Suggested change
public interface Gradient {
internal interface Gradient {

Comment on lines 21 to 22
"linearGradient" -> LinearGradient(gradientMap, context)
"radialGradient" -> RadialGradient(gradientMap, context)
Copy link
Contributor

Choose a reason for hiding this comment

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

Left a comment on the JS PR, but we might want to change these for consistency

Suggested change
"linearGradient" -> LinearGradient(gradientMap, context)
"radialGradient" -> RadialGradient(gradientMap, context)
"linear-gradient" -> LinearGradient(gradientMap, context)
"radial-gradient" -> RadialGradient(gradientMap, context)

Comment on lines 27 to 30
null
}
} else {
null
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 is a little tricky to read. Consider

  1. Early return null on null (or, only accept non-null? Seems like caller would need to check that anyways)
  2. Explicitly check data types and return null instead of swallowing exception. That could help avoid over-swallowing logic errors too

@@ -14,16 +14,22 @@ import com.facebook.react.bridge.ReadableMap

public class BackgroundImageLayer(gradientMap: 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.

nit: can we make this internal?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we're using this in BackgroundStyleApplicator so need to keep it public! 😕


public fun getShader(bounds: Rect): Shader? = gradient?.getShader(bounds)
public fun getShader(bounds: Rect): Shader? =
gradient?.getShader(bounds.width().toFloat(), bounds.height().toFloat())
Copy link
Contributor

Choose a reason for hiding this comment

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

Backing up a bit, wondering what the possible null state means here? I.e. why can we have a no-op BackgroundImageLayer? Wondering how this will also eventually fit into the work @jorge-cab will be taking on more in the future about image support for background-image.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If parsing any gradient fails we return null failing silently and do not draw the gradient like web. So this won't return a shader in that case!


public data class ProcessedColorStop(var color: Int? = null, val position: Float? = null)

public object ColorStopUtils {
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is pulled out of old code, but now this seems pretty unit testable. Could we maybe add some tests for these? BorderRadiusStyleTest is a test for something in the same structure that could be matched I think.

import com.facebook.react.uimanager.PixelUtil
import kotlin.math.ln

public data class ColorStop(var color: Int? = null, val position: LengthPercentage? = null)
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 prefer class over data class where we don't need equality or hashing bc they can lead to binary bloat. Some folks have been looking to remove data class usages from RN to save a bit on binary.


public data class ColorStop(var color: Int? = null, val position: LengthPercentage? = null)

public data class ProcessedColorStop(var color: Int? = null, val position: Float? = null)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we allow each part to be nullable?

Could we document what these types mean, and how they are used?

}

else -> throw IllegalArgumentException("Invalid direction type: $type")
private val direction: Direction = run {
Copy link
Contributor

Choose a reason for hiding this comment

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

A way that would be nice to layer these, is if we have some ctor taking the concrete data types, and then some function that extracts those out of ReadableMap, and constructs from it. That will also make these types generic in the future to changes in how we might store the intermediates.

return when (value.lowercase()) {
"circle" -> CIRCLE
"ellipse" -> ELLIPSE
else -> ELLIPSE
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we ever get here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, JS processor handles all the defaults. Removed.

@intergalacticspacehighway
Copy link
Contributor Author

intergalacticspacehighway commented Mar 29, 2025

@jorge-cab @NickGerleman Thanks for all the inputs and catching a bug. I have addressed all the comments (also pushed related changes to iOS and JS PRs). Let me know if anything else is needed!

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.

4 participants