feat: radial gradient android changes (#50269)

Summary:
Adds android changes for radial gradient. Previous PR - https://github.com/facebook/react-native/pull/50209

## Changelog:
[ANDROID] [ADDED] - Radial gradient
<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests

Pull Request resolved: https://github.com/facebook/react-native/pull/50269

Test Plan:
- Added tests in processBackgroundImage-test.js in https://github.com/facebook/react-native/pull/50268
- Merge this and [this](https://github.com/facebook/react-native/pull/50268) and check examples in `RadialGradientExample.js`

Reviewed By: NickGerleman

Differential Revision: D71898546

Pulled By: jorge-cab

fbshipit-source-id: 2872bcf16a492c7bc829be10eedb0f6c7dad32c7
This commit is contained in:
nishan (o^▽^o)
2025-04-17 16:39:44 -07:00
committed by Facebook GitHub Bot
parent d7533dce1c
commit a2409941c2
9 changed files with 857 additions and 304 deletions
@@ -5146,10 +5146,16 @@ public abstract interface class com/facebook/react/uimanager/layoutanimation/Lay
}
public final class com/facebook/react/uimanager/style/BackgroundImageLayer {
public fun <init> (Lcom/facebook/react/bridge/ReadableMap;Landroid/content/Context;)V
public static final field Companion Lcom/facebook/react/uimanager/style/BackgroundImageLayer$Companion;
public fun <init> ()V
public synthetic fun <init> (Lcom/facebook/react/uimanager/style/Gradient;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getShader (Landroid/graphics/Rect;)Landroid/graphics/Shader;
}
public final class com/facebook/react/uimanager/style/BackgroundImageLayer$Companion {
public final fun parse (Lcom/facebook/react/bridge/ReadableMap;Landroid/content/Context;)Lcom/facebook/react/uimanager/style/BackgroundImageLayer;
}
public final class com/facebook/react/uimanager/style/BorderRadiusProp : java/lang/Enum {
public static final field BORDER_BOTTOM_END_RADIUS Lcom/facebook/react/uimanager/style/BorderRadiusProp;
public static final field BORDER_BOTTOM_LEFT_RADIUS Lcom/facebook/react/uimanager/style/BorderRadiusProp;
@@ -153,7 +153,7 @@ internal class BackgroundDrawable(
backgroundImageLayers?.let { layers ->
var compositeShader: Shader? = null
for (backgroundImageLayer in layers) {
val currentShader = backgroundImageLayer.getShader(bounds) ?: continue
val currentShader = backgroundImageLayer.getShader(bounds)
compositeShader =
if (compositeShader == null) {
@@ -11,19 +11,37 @@ import android.content.Context
import android.graphics.Rect
import android.graphics.Shader
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
public class BackgroundImageLayer(gradientMap: ReadableMap?, context: Context) {
private val gradient: Gradient? =
if (gradientMap != null) {
try {
Gradient(gradientMap, context)
} catch (e: IllegalArgumentException) {
// Gracefully reject invalid styles
null
}
} else {
null
public class BackgroundImageLayer() {
private lateinit var gradient: Gradient
private constructor(gradient: Gradient) : this() {
this.gradient = gradient
}
public companion object {
public fun parse(gradientMap: ReadableMap?, context: Context): BackgroundImageLayer? {
if (gradientMap == null) {
return null
}
val gradient = parseGradient(gradientMap, context) ?: return null
return BackgroundImageLayer(gradient)
}
private fun parseGradient(gradientMap: ReadableMap, context: Context): Gradient? {
if (!gradientMap.hasKey("type") || gradientMap.getType("type") != ReadableType.String) {
return null
}
public fun getShader(bounds: Rect): Shader? = gradient?.getShader(bounds)
return when (gradientMap.getString("type")) {
"linear-gradient" -> LinearGradient.parse(gradientMap, context)
"radial-gradient" -> RadialGradient.parse(gradientMap, context)
else -> null
}
}
}
public fun getShader(bounds: Rect): Shader =
gradient.getShader(bounds.width().toFloat(), bounds.height().toFloat())
}
@@ -0,0 +1,206 @@
/*
* 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.
*/
package com.facebook.react.uimanager.style
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
// ColorStop type is passed by user, so color and position both could be null.
// e.g.
// color is null in transition hint syntax: (red, 20%, green)
// position can be null too (red 20%, green, purple)
internal class ColorStop(var color: Int? = null, val position: LengthPercentage? = null)
// ProcessedColorStop type describes type after processing.
// Here both types are nullable to keep it convenient for the color stop fix up algorithm.
// Final Color stop will have both non-null, we check for non null after calling getFixedColorStop.
internal class ProcessedColorStop(var color: Int? = null, val position: Float? = null)
internal object ColorStopUtils {
public fun getFixedColorStops(
colorStops: List<ColorStop>,
gradientLineLength: Float
): List<ProcessedColorStop> {
val fixedColorStops = Array<ProcessedColorStop>(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
val startPosition = fixedColorStops[lastDefinedIndex].position
val unpositionedStops = i - lastDefinedIndex - 1
if (endPosition != null && startPosition != null && unpositionedStops > 0) {
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 processColorTransitionHints(fixedColorStops)
}
// 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).
private fun processColorTransitionHints(
originalStops: Array<ProcessedColorStop>
): List<ProcessedColorStop> {
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<ProcessedColorStop>(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)
}
}
}
@@ -7,44 +7,8 @@
package com.facebook.react.uimanager.style
import android.content.Context
import android.graphics.Rect
import android.graphics.Shader
import com.facebook.react.bridge.ReadableMap
internal class Gradient(gradient: ReadableMap?, context: Context) {
private enum class GradientType {
LINEAR_GRADIENT
}
private val type: GradientType
private val linearGradient: LinearGradient
init {
gradient ?: throw IllegalArgumentException("Gradient cannot be null")
val typeString = gradient.getString("type")
type =
when (typeString) {
"linearGradient" -> GradientType.LINEAR_GRADIENT
else -> throw IllegalArgumentException("Unsupported gradient type: $typeString")
}
val directionMap =
gradient.getMap("direction")
?: throw IllegalArgumentException("Gradient must have direction")
val colorStops =
gradient.getArray("colorStops")
?: throw IllegalArgumentException("Invalid colorStops array")
linearGradient = LinearGradient(directionMap, colorStops, context)
}
fun getShader(bounds: Rect): Shader? {
return when (type) {
GradientType.LINEAR_GRADIENT ->
linearGradient.getShader(bounds.width().toFloat(), bounds.height().toFloat())
}
}
internal interface Gradient {
public fun getShader(width: Float, height: Float): Shader
}
@@ -10,101 +10,101 @@ package com.facebook.react.uimanager.style
import android.content.Context
import android.graphics.LinearGradient as AndroidLinearGradient
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
private data class ColorStop(var color: Int? = null, val position: LengthPercentage? = null)
internal class LinearGradient(val direction: Direction, val colorStops: List<ColorStop>) :
Gradient {
companion object {
fun parse(gradientMap: ReadableMap, context: Context): Gradient? {
val direction =
gradientMap
.takeIf { it.hasKey("direction") }
?.let { map ->
val directionMap = map.getMap("direction") ?: return null
private data class ProcessedColorStop(var color: Int? = null, val position: Float? = null)
internal class LinearGradient(
directionMap: ReadableMap,
private val colorStopsArray: ReadableArray,
private val context: Context
) {
private sealed class Direction {
data class Angle(val value: Double) : Direction()
enum class Keywords {
TO_TOP_RIGHT,
TO_BOTTOM_RIGHT,
TO_TOP_LEFT,
TO_BOTTOM_LEFT
}
data class Keyword(val value: Keywords) : Direction()
}
private val direction: Direction =
when (val type = directionMap.getString("type")) {
"angle" -> {
val angle = directionMap.getDouble("value")
Direction.Angle(angle)
}
"keyword" -> {
val keyword =
when (directionMap.getString("value")) {
"to top right" -> Direction.Keywords.TO_TOP_RIGHT
"to bottom right" -> Direction.Keywords.TO_BOTTOM_RIGHT
"to top left" -> Direction.Keywords.TO_TOP_LEFT
"to bottom left" -> Direction.Keywords.TO_BOTTOM_LEFT
else ->
throw IllegalArgumentException(
"Invalid linear gradient direction keyword: ${directionMap.getString("value")}")
when (directionMap.getString("type")) {
"angle" -> {
val angle = directionMap.getDouble("value")
Direction.Angle(angle)
}
"keyword" ->
Direction.KeywordType.fromString(directionMap.getString("value"))?.let {
keywordType ->
Direction.Keyword(keywordType)
}
else -> null
}
}
Direction.Keyword(keyword)
}
else -> throw IllegalArgumentException("Invalid direction type: $type")
val colorStops =
gradientMap
.takeIf { it.hasKey("colorStops") }
?.let { map ->
val colorStopsArray = map.getArray("colorStops") ?: return null
val stops = ArrayList<ColorStop>(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 colorStopPosition =
LengthPercentage.setFromDynamic(colorStop.getDynamic("position"))
stops.add(ColorStop(color, colorStopPosition))
}
stops
}
if (direction != null && colorStops != null) {
return LinearGradient(direction, colorStops)
}
private val colorStops: ArrayList<ColorStop> = run {
val stops = ArrayList<ColorStop>(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))
return null
}
stops
}
fun getShader(width: Float, height: Float): Shader {
sealed class Direction {
class Angle(val angle: Double) : Direction()
class Keyword(val keyword: KeywordType) : Direction()
enum class KeywordType(val value: String) {
TO_TOP_RIGHT("to top right"),
TO_BOTTOM_RIGHT("to bottom right"),
TO_TOP_LEFT("to top left"),
TO_BOTTOM_LEFT("to bottom left");
companion object {
fun fromString(value: String?) = enumValues<KeywordType>().find { it.value == value }
}
}
}
override fun getShader(width: Float, height: Float): Shader {
val angle =
when (direction) {
is Direction.Angle -> direction.value
is Direction.Angle -> direction.angle
is Direction.Keyword ->
getAngleForKeyword(direction.value, width.toDouble(), height.toDouble())
getAngleForKeyword(direction.keyword, 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 finalStops = ColorStopUtils.getFixedColorStops(colorStops, gradientLineLength)
val colors = IntArray(finalStops.size)
val positions = FloatArray(finalStops.size)
@@ -128,18 +128,18 @@ internal class LinearGradient(
// Spec: https://www.w3.org/TR/css-images-3/#linear-gradient-syntax
// Refer `using keywords` section
private fun getAngleForKeyword(
keyword: Direction.Keywords,
keyword: Direction.KeywordType,
width: Double,
height: Double
): Double {
return when (keyword) {
Direction.Keywords.TO_TOP_RIGHT -> {
Direction.KeywordType.TO_TOP_RIGHT -> {
val angleDeg = Math.toDegrees(atan(width / height))
90 - angleDeg
}
Direction.Keywords.TO_BOTTOM_RIGHT -> Math.toDegrees(atan(width / height)) + 90
Direction.Keywords.TO_TOP_LEFT -> Math.toDegrees(atan(width / height)) + 270
Direction.Keywords.TO_BOTTOM_LEFT -> Math.toDegrees(atan(height / width)) + 180
Direction.KeywordType.TO_BOTTOM_RIGHT -> Math.toDegrees(atan(width / height)) + 90
Direction.KeywordType.TO_TOP_LEFT -> Math.toDegrees(atan(width / height)) + 270
Direction.KeywordType.TO_BOTTOM_LEFT -> Math.toDegrees(atan(height / width)) + 180
}
}
@@ -186,178 +186,4 @@ internal class LinearGradient(
return Pair(firstPoint, secondPoint)
}
private fun getFixedColorStops(
colorStops: ArrayList<ColorStop>,
gradientLineLength: Float
): Array<ProcessedColorStop> {
val fixedColorStops = Array<ProcessedColorStop>(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<ProcessedColorStop>
): List<ProcessedColorStop> {
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<ProcessedColorStop>(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)
}
}
}
@@ -0,0 +1,375 @@
/*
* 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.
*/
package com.facebook.react.uimanager.style
import android.content.Context
import android.graphics.Matrix
import android.graphics.RadialGradient as AndroidRadialGradient
import android.graphics.Shader
import com.facebook.react.bridge.ColorPropConverter
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.dpToPx
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
internal class RadialGradient(
val shape: Shape,
val size: GradientSize,
val position: Position,
val colorStops: List<ColorStop>
) : Gradient {
companion object {
fun parse(gradientMap: ReadableMap, context: Context): Gradient? {
val shape =
gradientMap
.takeIf { it.hasKey("shape") }
?.let { map ->
map.getString("shape")?.let { shapeString -> Shape.fromString(shapeString) }
}
val size: GradientSize? =
gradientMap
.takeIf { it.hasKey("size") }
?.let { map ->
when (map.getType("size")) {
ReadableType.String ->
GradientSize.KeywordType.fromString(map.getString("size"))?.let { keywordType
->
GradientSize.Keyword(keywordType)
}
ReadableType.Map ->
map.getMap("size")
?.takeIf { it.hasKey("x") && it.hasKey("y") }
?.let { sizeMap ->
val x = LengthPercentage.setFromDynamic(sizeMap.getDynamic("x"))
val y = LengthPercentage.setFromDynamic(sizeMap.getDynamic("y"))
if (x != null && y != null) {
GradientSize.Dimensions(x, y)
} else null
}
else -> null
}
}
val position =
gradientMap
.takeIf { it.hasKey("position") }
?.let { map ->
val positionMap = map.getMap("position") ?: return null
var top: LengthPercentage? = null
var left: LengthPercentage? = null
var right: LengthPercentage? = null
var bottom: LengthPercentage? = null
if (positionMap.hasKey("top")) {
val rawTop = positionMap.getDynamic("top")
top = LengthPercentage.setFromDynamic(rawTop)
} else if (positionMap.hasKey("bottom")) {
val rawBottom = positionMap.getDynamic("bottom")
bottom = LengthPercentage.setFromDynamic(rawBottom)
} else {
return null
}
if (positionMap.hasKey("left")) {
val rawLeft = positionMap.getDynamic("left")
left = LengthPercentage.setFromDynamic(rawLeft)
} else if (positionMap.hasKey("right")) {
val rawRight = positionMap.getDynamic("right")
right = LengthPercentage.setFromDynamic(rawRight)
} else {
return null
}
Position(top, left, right, bottom)
}
val colorStops =
gradientMap
.takeIf { it.hasKey("colorStops") }
?.let { map ->
val colorStopsArray = map.getArray("colorStops") ?: return null
val stops = ArrayList<ColorStop>(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 colorStopPosition =
LengthPercentage.setFromDynamic(colorStop.getDynamic("position"))
stops.add(ColorStop(color, colorStopPosition))
}
stops
}
if (shape != null && size != null && position != null && colorStops != null) {
return RadialGradient(shape, size, position, colorStops)
}
return null
}
}
internal enum class Shape {
CIRCLE,
ELLIPSE;
companion object {
fun fromString(value: String): Shape? {
return when (value) {
"circle" -> CIRCLE
"ellipse" -> ELLIPSE
else -> null
}
}
}
}
sealed class GradientSize {
class Keyword(val keyword: KeywordType) : GradientSize()
class Dimensions(val x: LengthPercentage, val y: LengthPercentage) : GradientSize()
enum class KeywordType(val value: String) {
CLOSEST_SIDE("closest-side"),
FARTHEST_SIDE("farthest-side"),
CLOSEST_CORNER("closest-corner"),
FARTHEST_CORNER("farthest-corner");
companion object {
fun fromString(value: String?) = enumValues<KeywordType>().find { it.value == value }
}
}
}
internal class Position(
val top: LengthPercentage? = null,
val left: LengthPercentage? = null,
val right: LengthPercentage? = null,
val bottom: LengthPercentage? = null
)
override fun getShader(width: Float, height: Float): Shader {
var centerX: Float = width / 2f
var centerY: Float = height / 2f
if (position.top != null) {
centerY =
if (position.top.type == LengthPercentageType.PERCENT) position.top.resolve(height)
else position.top.resolve(height).dpToPx()
} else if (position.bottom != null) {
centerY =
if (position.bottom.type == LengthPercentageType.PERCENT)
height - position.bottom.resolve(height)
else height - position.bottom.resolve(height).dpToPx()
}
if (position.left != null) {
centerX =
if (position.left.type == LengthPercentageType.PERCENT) position.left.resolve(width)
else position.left.resolve(width).dpToPx()
} else if (position.right != null) {
centerX =
if (position.right.type == LengthPercentageType.PERCENT)
width - position.right.resolve(width)
else width - position.right.resolve(width).dpToPx()
}
val (radiusX, radiusY) = calculateRadius(centerX, centerY, width, height)
val finalStops = ColorStopUtils.getFixedColorStops(colorStops, max(radiusX, radiusY))
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
}
}
// max is used to handle 0 radius user input. Radius has to be a positive float
val radius = max(radiusX, 0.00001f)
val shader =
AndroidRadialGradient(centerX, centerY, radius, colors, positions, Shader.TileMode.CLAMP)
val isCircle = shape == Shape.CIRCLE
// If not a circle and radiusX != radiusY, apply transformation to make it elliptical
if (!isCircle && !FloatUtil.floatsEqual(radiusX, radiusY)) {
val matrix = Matrix()
matrix.setScale(1f, radiusY / radiusX, centerX, centerY)
shader.setLocalMatrix(matrix)
}
return shader
}
private fun radiusToSide(
centerX: Float,
centerY: Float,
width: Float,
height: Float,
sizeKeyword: GradientSize.KeywordType
): Pair<Float, Float> {
val radiusXFromLeftSide = centerX
val radiusYFromTopSide = centerY
val radiusXFromRightSide = width - centerX
val radiusYFromBottomSide = height - centerY
val radiusX: Float
val radiusY: Float
if (sizeKeyword == GradientSize.KeywordType.CLOSEST_SIDE) {
radiusX = min(radiusXFromLeftSide, radiusXFromRightSide)
radiusY = min(radiusYFromTopSide, radiusYFromBottomSide)
} else { // FARTHEST_SIDE
radiusX = max(radiusXFromLeftSide, radiusXFromRightSide)
radiusY = max(radiusYFromTopSide, radiusYFromBottomSide)
}
val isCircle = shape == Shape.CIRCLE
if (isCircle) {
val radius =
if (sizeKeyword == GradientSize.KeywordType.CLOSEST_SIDE) {
min(radiusX, radiusY)
} else {
max(radiusX, radiusY)
}
return Pair(radius, radius)
}
return Pair(radiusX, radiusY)
}
private fun calculateEllipseRadius(
offsetX: Float,
offsetY: Float,
aspectRatio: Float
): Pair<Float, Float> {
if (aspectRatio == 0f || !aspectRatio.isFinite()) {
return Pair(0f, 0f)
}
// Ellipse that passes through a point formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
// a = semi major axis length
// b = semi minor axis length = a / aspectRatio
// x - h = offsetX
// y - k = offsetY
val a = sqrt(offsetX * offsetX + offsetY * offsetY * aspectRatio * aspectRatio)
return Pair(a, a / aspectRatio)
}
private fun radiusToCorner(
centerX: Float,
centerY: Float,
width: Float,
height: Float,
sizeKeyword: GradientSize.KeywordType
): Pair<Float, Float> {
val corners =
arrayOf(
Pair(0f, 0f), // top-left
Pair(width, 0f), // top-right
Pair(width, height), // bottom-right
Pair(0f, height) // bottom-left
)
var cornerIndex = 0
var distance =
sqrt(
(centerX - corners[cornerIndex].first).pow(2) +
(centerY - corners[cornerIndex].second).pow(2))
val isClosestCorner = sizeKeyword == GradientSize.KeywordType.CLOSEST_CORNER
for (i in 1 until corners.size) {
val newDistance =
sqrt((centerX - corners[i].first).pow(2) + (centerY - corners[i].second).pow(2))
if (isClosestCorner) {
if (newDistance < distance) {
distance = newDistance
cornerIndex = i
}
} else {
if (newDistance > distance) {
distance = newDistance
cornerIndex = i
}
}
}
val isCircle = shape == Shape.CIRCLE
if (isCircle) {
return Pair(distance, distance)
}
// https://www.w3.org/TR/css-images-3/#typedef-radial-size
// Aspect ratio of corner size ellipse is same as the respective side size ellipse
val sideKeyword =
if (isClosestCorner) GradientSize.KeywordType.CLOSEST_SIDE
else GradientSize.KeywordType.FARTHEST_SIDE
val sideRadius = radiusToSide(centerX, centerY, width, height, sideKeyword)
// Calculate ellipse radii based on the aspect ratio of the side ellipse
return calculateEllipseRadius(
corners[cornerIndex].first - centerX,
corners[cornerIndex].second - centerY,
sideRadius.first / sideRadius.second)
}
private fun calculateRadius(
centerX: Float,
centerY: Float,
width: Float,
height: Float
): Pair<Float, Float> {
if (size is GradientSize.Keyword) {
return when (val keyword = size.keyword) {
GradientSize.KeywordType.CLOSEST_SIDE,
GradientSize.KeywordType.FARTHEST_SIDE -> {
radiusToSide(centerX, centerY, width, height, keyword)
}
GradientSize.KeywordType.CLOSEST_CORNER,
GradientSize.KeywordType.FARTHEST_CORNER -> {
radiusToCorner(centerX, centerY, width, height, keyword)
}
}
} else if (size is GradientSize.Dimensions) {
val radiusX =
if (size.x.type == LengthPercentageType.PERCENT) size.x.resolve(width)
else size.x.resolve(width).dpToPx()
val radiusY =
if (size.y.type == LengthPercentageType.PERCENT) size.y.resolve(height)
else size.y.resolve(height).dpToPx()
val isCircle = shape == Shape.CIRCLE
return if (isCircle) {
val radius = max(radiusX, radiusY)
Pair(radius, radius)
} else {
Pair(radiusX, radiusY)
}
} else {
return radiusToCorner(
centerX, centerY, width, height, GradientSize.KeywordType.FARTHEST_CORNER)
}
}
}
@@ -104,8 +104,10 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()
val backgroundImageLayers = ArrayList<BackgroundImageLayer>(backgroundImage.size())
for (i in 0 until backgroundImage.size()) {
val backgroundImageMap = backgroundImage.getMap(i)
val layer = BackgroundImageLayer(backgroundImageMap, view.context)
backgroundImageLayers.add(layer)
val layer = BackgroundImageLayer.parse(backgroundImageMap, view.context)
if (layer != null) {
backgroundImageLayers.add(layer)
}
}
BackgroundStyleApplicator.setBackgroundImage(view, backgroundImageLayers)
} else {
@@ -0,0 +1,156 @@
/*
* 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.
*/
package com.facebook.react.uimanager.style
import ColorStop
import android.graphics.Color
import android.util.DisplayMetrics
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.LengthPercentage
import com.facebook.react.uimanager.LengthPercentageType
import org.assertj.core.api.Assertions.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/** Tests for [ColorStopUtils] */
@RunWith(RobolectricTestRunner::class)
class ColorStopTest {
@Before
fun setUp() {
val metrics = DisplayMetrics()
metrics.density = 1f
DisplayMetricsHolder.setWindowDisplayMetrics(metrics)
}
@Test
fun testBasicColorStops() {
val colorStops =
listOf(
ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)),
ColorStop(Color.GREEN, LengthPercentage(42f, LengthPercentageType.PERCENT)))
val processed = ColorStopUtils.getFixedColorStops(colorStops, 60f)
assertThat(processed).hasSize(2)
assertThat(processed[0].color).isEqualTo(Color.RED)
assertThat(processed[0].position).isEqualTo(0f)
assertThat(processed[1].color).isEqualTo(Color.GREEN)
assertThat(processed[1].position).isEqualTo(0.42f)
}
@Test
fun testColorStopsWithFirstAndLastPositionsMissing() {
val colorStops =
listOf(
ColorStop(Color.RED),
ColorStop(Color.GREEN, LengthPercentage(30f, LengthPercentageType.PERCENT)),
ColorStop(Color.BLUE))
val processed = ColorStopUtils.getFixedColorStops(colorStops, 80f)
assertThat(processed).hasSize(3)
assertThat(processed[0].color).isEqualTo(Color.RED)
assertThat(processed[0].position).isEqualTo(0f)
assertThat(processed[1].color).isEqualTo(Color.GREEN)
assertThat(processed[1].position).isEqualTo(0.3f)
assertThat(processed[2].color).isEqualTo(Color.BLUE)
assertThat(processed[2].position).isEqualTo(1f)
}
@Test
fun testColorStopsWithLessPositionValueThanPreviousPosition() {
val colorStops =
listOf(
ColorStop(Color.RED),
ColorStop(Color.GREEN, LengthPercentage(30f, LengthPercentageType.PERCENT)),
ColorStop(Color.BLUE, LengthPercentage(20f, LengthPercentageType.PERCENT)),
ColorStop(Color.GRAY, LengthPercentage(60f, LengthPercentageType.PERCENT)),
ColorStop(Color.CYAN, LengthPercentage(50f, LengthPercentageType.PERCENT)))
val processed = ColorStopUtils.getFixedColorStops(colorStops, 80f)
assertThat(processed).hasSize(5)
assertThat(processed[0].color).isEqualTo(Color.RED)
assertThat(processed[0].position).isEqualTo(0f)
assertThat(processed[1].color).isEqualTo(Color.GREEN)
assertThat(processed[1].position).isEqualTo(0.3f)
assertThat(processed[2].color).isEqualTo(Color.BLUE)
assertThat(processed[2].position).isEqualTo(0.3f)
assertThat(processed[3].color).isEqualTo(Color.GRAY)
assertThat(processed[3].position).isEqualTo(0.6f)
assertThat(processed[4].color).isEqualTo(Color.CYAN)
assertThat(processed[4].position).isEqualTo(0.6f)
}
@Test
fun testColorStopsWithMissingMiddlePositions() {
val colorStops =
listOf(
ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)),
ColorStop(Color.GREEN),
ColorStop(Color.BLUE),
ColorStop(Color.TRANSPARENT, LengthPercentage(100f, LengthPercentageType.PERCENT)))
val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f)
assertThat(processed).hasSize(4)
assertThat(processed[0].color).isEqualTo(Color.RED)
assertThat(processed[0].position).isEqualTo(0f)
assertThat(processed[1].color).isEqualTo(Color.GREEN)
assertThat(processed[1].position).isEqualTo(0.33333334f)
assertThat(processed[2].color).isEqualTo(Color.BLUE)
assertThat(processed[2].position).isEqualTo(0.6666667f)
assertThat(processed[3].color).isEqualTo(Color.TRANSPARENT)
assertThat(processed[3].position).isEqualTo(1f)
}
@Test
fun testColorStopsWithMixedUnits() {
val colorStops =
listOf(
ColorStop(Color.YELLOW, LengthPercentage(100f, LengthPercentageType.POINT)),
ColorStop(Color.BLUE, LengthPercentage(50f, LengthPercentageType.PERCENT)))
val processed200px = ColorStopUtils.getFixedColorStops(colorStops, 200f)
assertThat(processed200px).hasSize(2)
assertThat(processed200px[0].color).isEqualTo(Color.YELLOW)
assertThat(processed200px[0].position).isEqualTo(0.5f)
assertThat(processed200px[1].color).isEqualTo(Color.BLUE)
assertThat(processed200px[1].position).isEqualTo(0.5f)
// positions should be corrected
val processed150px = ColorStopUtils.getFixedColorStops(colorStops, 150f)
assertThat(processed150px).hasSize(2)
assertThat(processed150px[0].color).isEqualTo(Color.YELLOW)
assertThat(processed150px[0].position).isEqualTo(0.6666667f) // 100px / 150px ≈ 0.6667
assertThat(processed150px[1].color).isEqualTo(Color.BLUE)
assertThat(processed150px[1].position)
.isEqualTo(0.6666667f) // Corrected to match yellow's position
}
@Test
fun testColorStopsWithMultipleTransitionHints() {
val colorStops =
listOf(
ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)),
ColorStop(null, LengthPercentage(10f, LengthPercentageType.PERCENT)),
ColorStop(Color.GREEN, LengthPercentage(50f, LengthPercentageType.PERCENT)),
ColorStop(null, LengthPercentage(85f, LengthPercentageType.PERCENT)),
ColorStop(Color.BLUE, LengthPercentage(100f, LengthPercentageType.PERCENT)))
val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f)
assertThat(processed.size).isEqualTo(21)
assertThat(processed.first().color).isEqualTo(Color.RED)
assertThat(processed.first().position).isEqualTo(0f)
// 9 interpolated colors and positions between RED and GREEN
assertThat(processed.subList(1, 10).all { it.color != null && it.position != null }).isTrue()
assertThat(processed[10].color).isEqualTo(Color.GREEN)
assertThat(processed[10].position).isEqualTo(0.5f)
// 9 interpolated colors and positions between GREEN and BLUE
assertThat(processed.subList(11, 20).all { it.color != null && it.position != null }).isTrue()
assertThat(processed[20].color).isEqualTo(Color.BLUE)
assertThat(processed[20].position).isEqualTo(1f)
}
}