diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt index 2b8242dfba3068..622afdfd9b782e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundImageLayer.kt @@ -11,19 +11,33 @@ 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 + + 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) + public fun getShader(bounds: Rect): Shader = + gradient.getShader(bounds.width().toFloat(), bounds.height().toFloat()) } 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 new file mode 100644 index 00000000000000..d9af9c7a087a4f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt @@ -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. + */ + +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, + gradientLineLength: Float + ): List { + val fixedColorStops = Array(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 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 + ): List { + 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(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) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt index dce0dc3fdbd474..cd5642909dcc8a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/Gradient.kt @@ -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 } 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 a8fdfe6cf4c1f0..cc7e50c37956ef 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 @@ -7,104 +7,96 @@ package com.facebook.react.uimanager.style +import ColorStop +import ColorStopUtils 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) - -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) + val direction: Direction, + val colorStops: List +) : 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 + + 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 } + } - "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")}") + val colorStops = gradientMap.takeIf { it.hasKey("colorStops") }?.let { map -> + val colorStopsArray = map.getArray("colorStops") ?: return null + + val stops = ArrayList(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) } - Direction.Keyword(keyword) + else -> colorStop.getInt("color") + } + val colorStopPosition = LengthPercentage.setFromDynamic(colorStop.getDynamic("position")) + stops.add(ColorStop(color, colorStopPosition)) } - - else -> throw IllegalArgumentException("Invalid direction type: $type") + stops } - private val colorStops: ArrayList = run { - val stops = ArrayList(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") - } + if (direction != null && colorStops != null) { + return LinearGradient(direction, colorStops) + } - val position = LengthPercentage.setFromDynamic(colorStop.getDynamic("position")) + return null + } + } - stops.add(ColorStop(color, position)) + 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().find { it.value == value } + } } - stops } - fun getShader(width: Float, height: Float): Shader { - val angle = - when (direction) { - is Direction.Angle -> direction.value - is Direction.Keyword -> - getAngleForKeyword(direction.value, width.toDouble(), height.toDouble()) - } + override fun getShader(width: Float, height: Float): Shader { + val angle = when (direction) { + is Direction.Angle -> direction.angle + is Direction.Keyword -> 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 +120,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 +178,4 @@ internal class LinearGradient( return Pair(firstPoint, secondPoint) } - - private fun getFixedColorStops( - colorStops: ArrayList, - gradientLineLength: Float - ): Array { - val fixedColorStops = Array(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 - ): List { - 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(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) - } - } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt new file mode 100644 index 00000000000000..8805d5aca94994 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt @@ -0,0 +1,374 @@ +/* + * 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 ColorStopUtils +import android.content.Context +import android.graphics.RadialGradient as AndroidRadialGradient +import android.graphics.Matrix +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 +) : 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(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().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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index 49f724944b0366..e14daa748a66d2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -104,8 +104,10 @@ public open class ReactViewManager : ReactClippingViewManager() val backgroundImageLayers = ArrayList(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 { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt new file mode 100644 index 00000000000000..8754dee5a8a0b9 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt @@ -0,0 +1,155 @@ +/* + * 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.graphics.Color +import ColorStop +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) + } +}