diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index b7cc724027db..40eb1547a8b4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -24,6 +24,7 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.common.UIManagerType import com.facebook.react.uimanager.common.ViewUtil import com.facebook.react.uimanager.drawable.BackgroundDrawable +import com.facebook.react.uimanager.drawable.BackgroundImageDrawable import com.facebook.react.uimanager.drawable.BorderDrawable import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable import com.facebook.react.uimanager.drawable.InsetBoxShadowDrawable @@ -32,6 +33,9 @@ import com.facebook.react.uimanager.drawable.MIN_OUTSET_BOX_SHADOW_SDK_VERSION import com.facebook.react.uimanager.drawable.OutlineDrawable import com.facebook.react.uimanager.drawable.OutsetBoxShadowDrawable import com.facebook.react.uimanager.style.BackgroundImageLayer +import com.facebook.react.uimanager.style.BackgroundPosition +import com.facebook.react.uimanager.style.BackgroundRepeat +import com.facebook.react.uimanager.style.BackgroundSize import com.facebook.react.uimanager.style.BorderInsets import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderRadiusStyle @@ -65,7 +69,31 @@ public object BackgroundStyleApplicator { view: View, backgroundImageLayers: List?, ): Unit { - ensureBackgroundDrawable(view).backgroundImageLayers = backgroundImageLayers + ensureBackgroundImageDrawable(view).backgroundImageLayers = backgroundImageLayers + } + + @JvmStatic + public fun setBackgroundSize( + view: View, + backgroundSizes: List? + ): Unit { + ensureBackgroundImageDrawable(view).backgroundSize = backgroundSizes + } + + @JvmStatic + public fun setBackgroundPosition( + view: View, + backgroundPositions: List? + ): Unit { + ensureBackgroundImageDrawable(view).backgroundPosition = backgroundPositions + } + + @JvmStatic + public fun setBackgroundRepeat( + view: View, + backgroundRepeats: List? + ): Unit { + ensureBackgroundImageDrawable(view).backgroundRepeat = backgroundRepeats } @JvmStatic @@ -82,9 +110,11 @@ public object BackgroundStyleApplicator { ensureBorderDrawable(view).setBorderWidth(edge.toSpacingType(), width?.dpToPx() ?: Float.NaN) composite.background?.borderInsets = composite.borderInsets + composite.backgroundImage?.borderInsets = composite.borderInsets composite.border?.borderInsets = composite.borderInsets composite.background?.invalidateSelf() + composite.backgroundImage?.invalidateSelf() composite.border?.invalidateSelf() composite.borderInsets = composite.borderInsets ?: BorderInsets() @@ -133,9 +163,11 @@ public object BackgroundStyleApplicator { ensureBackgroundDrawable(view) } compositeBackgroundDrawable.background?.borderRadius = compositeBackgroundDrawable.borderRadius + compositeBackgroundDrawable.backgroundImage?.borderRadius = compositeBackgroundDrawable.borderRadius compositeBackgroundDrawable.border?.borderRadius = compositeBackgroundDrawable.borderRadius compositeBackgroundDrawable.background?.invalidateSelf() + compositeBackgroundDrawable.backgroundImage?.invalidateSelf() compositeBackgroundDrawable.border?.invalidateSelf() if (Build.VERSION.SDK_INT >= MIN_OUTSET_BOX_SHADOW_SDK_VERSION) { @@ -381,6 +413,27 @@ public object BackgroundStyleApplicator { private fun getBackground(view: View): BackgroundDrawable? = getCompositeBackgroundDrawable(view)?.background + private fun ensureBackgroundImageDrawable(view: View): BackgroundImageDrawable { + val compositeBackgroundDrawable = ensureCompositeBackgroundDrawable(view) + var backgroundImage = compositeBackgroundDrawable.backgroundImage + + return if (backgroundImage != null) { + backgroundImage + } else { + backgroundImage = + BackgroundImageDrawable( + view.context, + compositeBackgroundDrawable.borderRadius, + compositeBackgroundDrawable.borderInsets, + ) + view.background = compositeBackgroundDrawable.withNewBackgroundImage(backgroundImage) + backgroundImage + } + } + + private fun getBackgroundImage(view: View): BackgroundImageDrawable? = + getCompositeBackgroundDrawable(view)?.backgroundImage + private fun getBorder(view: View): BorderDrawable? = getCompositeBackgroundDrawable(view)?.border private fun ensureBorderDrawable(view: View): BorderDrawable { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt index 6a9a79ea7197..1da94fa2672e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LengthPercentage.kt @@ -24,26 +24,24 @@ public data class LengthPercentage( ) { public companion object { @JvmStatic - public fun setFromDynamic(dynamic: Dynamic): LengthPercentage? { + public fun setFromDynamic(dynamic: Dynamic, allowNegative: Boolean = false): LengthPercentage? { return when (dynamic.type) { ReadableType.Number -> { val value = dynamic.asDouble() - if (value >= 0f) { - LengthPercentage(value.toFloat(), LengthPercentageType.POINT) - } else { - null + if (value < 0 && !allowNegative) { + return null } + LengthPercentage(value.toFloat(), LengthPercentageType.POINT) } ReadableType.String -> { val s = dynamic.asString() if (s != null && s.endsWith("%")) { try { val value = s.substring(0, s.length - 1).toFloat() - if (value >= 0f) { - LengthPercentage(value, LengthPercentageType.PERCENT) - } else { - null + if (value < 0 && !allowNegative) { + return null } + LengthPercentage(value, LengthPercentageType.PERCENT) } catch (e: NumberFormatException) { FLog.w(ReactConstants.TAG, "Invalid percentage format: $s") null diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index dd4883e23e02..16176a0f29c2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -73,6 +73,9 @@ public object ViewProps { public const val ENABLED: String = "enabled" public const val BACKGROUND_COLOR: String = "backgroundColor" public const val BACKGROUND_IMAGE: String = "experimental_backgroundImage" + public const val BACKGROUND_SIZE: String = "experimental_backgroundSize" + public const val BACKGROUND_POSITION: String = "experimental_backgroundPosition" + public const val BACKGROUND_REPEAT: String = "experimental_backgroundRepeat" public const val FOREGROUND_COLOR: String = "foregroundColor" public const val COLOR: String = "color" public const val FONT_SIZE: String = "fontSize" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt index 474d148e79bc..1a53a1425e5b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt @@ -22,7 +22,6 @@ import android.graphics.Shader import android.graphics.drawable.Drawable import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp -import com.facebook.react.uimanager.style.BackgroundImageLayer import com.facebook.react.uimanager.style.BorderInsets import com.facebook.react.uimanager.style.BorderRadiusStyle import com.facebook.react.uimanager.style.ComputedBorderRadius @@ -59,14 +58,6 @@ internal class BackgroundDrawable( private var backgroundRect: RectF = RectF() private var backgroundRenderPath: Path? = null - var backgroundImageLayers: List? = null - set(value) { - if (field != value) { - field = value - invalidateSelf() - } - } - private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL @@ -111,8 +102,8 @@ internal class BackgroundDrawable( if (backgroundPaint.alpha != 0) { if (computedBorderRadius?.isUniform() == true && borderRadius?.hasRoundedBorders() == true) { canvas.drawRoundRect( - backgroundRect, - computedBorderRadius?.topLeft?.horizontal?.dpToPx() ?: 0f, + backgroundRect, + computedBorderRadius?.topLeft?.horizontal?.dpToPx() ?: 0f, computedBorderRadius?.topLeft?.vertical?.dpToPx() ?: 0f, backgroundPaint, ) @@ -123,24 +114,6 @@ internal class BackgroundDrawable( } } - backgroundPaint.alpha = 255 - if (backgroundImageLayers != null && backgroundImageLayers?.isNotEmpty() == true) { - backgroundPaint.setShader(getBackgroundImageShader()) - if (computedBorderRadius?.isUniform() == true && borderRadius?.hasRoundedBorders() == true) { - canvas.drawRoundRect( - backgroundRect, - computedBorderRadius?.topLeft?.horizontal?.dpToPx() ?: 0f, - computedBorderRadius?.topLeft?.vertical?.dpToPx() ?: 0f, - backgroundPaint, - ) - } else if (borderRadius?.hasRoundedBorders() != true) { - canvas.drawRect(backgroundRect, backgroundPaint) - } else { - canvas.drawPath(checkNotNull(backgroundRenderPath), backgroundPaint) - } - backgroundPaint.setShader(null) - } - backgroundPaint.alpha = Color.alpha(backgroundColor) canvas.restore() } @@ -152,24 +125,6 @@ internal class BackgroundDrawable( it?.right?.dpToPx() ?: 0f, it?.bottom?.dpToPx() ?: 0f, ) - } - - private fun getBackgroundImageShader(): Shader? { - backgroundImageLayers?.let { layers -> - var compositeShader: Shader? = null - for (backgroundImageLayer in layers) { - val currentShader = backgroundImageLayer.getShader(bounds) - - compositeShader = - if (compositeShader == null) { - currentShader - } else { - ComposeShader(currentShader, compositeShader, PorterDuff.Mode.SRC_OVER) - } - } - return compositeShader - } - return null } private fun updatePath() { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt new file mode 100644 index 000000000000..cffb731d11d2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageDrawable.kt @@ -0,0 +1,383 @@ +/* + * 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.drawable + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.Drawable +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 com.facebook.react.uimanager.PixelUtil.pxToDp +import com.facebook.react.uimanager.style.BackgroundImageLayer +import com.facebook.react.uimanager.style.BackgroundPosition +import com.facebook.react.uimanager.style.BackgroundRepeat +import com.facebook.react.uimanager.style.BackgroundRepeatKeyword +import com.facebook.react.uimanager.style.BackgroundSize +import com.facebook.react.uimanager.style.BorderInsets +import com.facebook.react.uimanager.style.BorderRadiusStyle +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.round + +internal class BackgroundImageDrawable( + private val context: Context, + /* + * We assume borderRadius & borderInsets to be shared across multiple drawables + * therefore we should manually invalidate this drawable when changing either of them + */ + var borderRadius: BorderRadiusStyle? = null, + var borderInsets: BorderInsets? = null, +) : Drawable() { + private var needUpdatePath = true + private var backgroundImageClipPath: Path? = null + private var backgroundPositioningArea: RectF? = null + private var backgroundPaintingArea: RectF? = null + + var backgroundImageLayers: List? = null + set(value) { + if (field != value) { + field = value + invalidateSelf() + } + } + + var backgroundSize: List? = null + set(value) { + if (field != value) { + field = value + invalidateSelf() + } + } + + var backgroundPosition: List? = null + set(value) { + if (field != value) { + field = value + invalidateSelf() + } + } + + var backgroundRepeat: List? = null + set(value) { + if (field != value) { + field = value + invalidateSelf() + } + } + + private val backgroundPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + override fun invalidateSelf() { + needUpdatePath = true + super.invalidateSelf() + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + needUpdatePath = true + } + + override fun setAlpha(alpha: Int) { + backgroundPaint.alpha = alpha + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + // do nothing + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int { + val alpha = backgroundPaint.alpha + return when (alpha) { + 255 -> PixelFormat.OPAQUE + in 1..254 -> PixelFormat.TRANSLUCENT + else -> PixelFormat.TRANSPARENT + } + } + + override fun draw(canvas: Canvas) { + if (backgroundImageLayers == null || backgroundImageLayers?.isEmpty() == true) { + return + } + + updatePath() + + val backgroundPaintingArea = backgroundPaintingArea ?: return + val backgroundPositioningArea = backgroundPositioningArea ?: return + + if (hasInvalidDimensions(backgroundPositioningArea, backgroundPaintingArea)) { + return + } + + canvas.save() + + + // 1. Clip the canvas to match the rounded border path and truncate repeating tiles + backgroundImageClipPath?.let { canvas.clipPath(it) } + + backgroundImageLayers?.let { layers -> + // iterate in reverse to match CSS spec i.e first background image appears closer to user. + // So we draw in reverse (last drawn in canvas appears closest) + for (index in layers.indices.reversed()) { + val backgroundImageLayer = layers[index] + val size = backgroundSize?.let { it.getOrNull(index % it.size) } + val repeat = backgroundRepeat?.let { it.getOrNull(index % it.size) } + val position = backgroundPosition?.let { it.getOrNull(index % it.size) } + + // 2. Calculate the size of a single tile. + val (tileWidth, tileHeight) = calculateBackgroundImageSize( + backgroundPositioningArea.width(), backgroundPositioningArea.height(), backgroundPositioningArea.width(), backgroundPositioningArea.height(), size, repeat) + + if (tileWidth <= 0 || tileHeight <= 0) { + continue + } + + // 3. Set paint shader + backgroundPaint.setShader(backgroundImageLayer.getShader(tileWidth, tileHeight)) + + // 4. Calculate spacing, x and y tiles count and position for tiles + var (initialX, initialY) = calculateBackgroundPosition(tileWidth, tileHeight, position) + + val repeatX = repeat?.x ?: BackgroundRepeatKeyword.Repeat + var xTilesCount = 1; + var xSpacing = 0f + + if (repeatX == BackgroundRepeatKeyword.Space) { + // The image is repeated as much as possible + // without clipping. The first and last images are pinned to either side of + // the element, and whitespace is distributed evenly between the images. + // The background-position property is ignored unless only one image can be displayed without + // clipping. + val widthOfEdgePinnedImages = tileWidth * 2 + val availableWidthForCenterImages = backgroundPaintingArea.width() - widthOfEdgePinnedImages + val roundedTileWidth = round(tileWidth) + if (roundedTileWidth > 0 && (availableWidthForCenterImages > 0 || FloatUtil.floatsEqual(availableWidthForCenterImages, 0f))) { + // round the values when flooring to avoid floating point precision issues + val centerImagesCount = floor(round(availableWidthForCenterImages) / roundedTileWidth).toInt() + val centerImagesWidth = centerImagesCount * tileWidth + val totalFreeSpace = availableWidthForCenterImages - centerImagesWidth + val totalInstances = centerImagesCount + 2 + xSpacing = totalFreeSpace / (totalInstances - 1) + xTilesCount = totalInstances + initialX = backgroundPaintingArea.left + } else { + xTilesCount = 1 + } + } else if (repeatX == BackgroundRepeatKeyword.Round || repeatX == BackgroundRepeatKeyword.Repeat) { + val roundedTileWidth = round(tileWidth) + if (roundedTileWidth > 0) { + val tilesBeforeX = ceil(round(initialX) / roundedTileWidth).toInt() + val tilesAfterX = ceil(round((backgroundPaintingArea.width() - initialX)) / roundedTileWidth).toInt() + xTilesCount = tilesBeforeX + tilesAfterX + initialX -= (tilesBeforeX * tileWidth) + } + xSpacing = 0f + } + + val repeatY = repeat?.y ?: BackgroundRepeatKeyword.Repeat + var yTilesCount = 1; + var ySpacing = 0f + + if (repeatY == BackgroundRepeatKeyword.Space) { + val heightOfEdgePinnedImages = tileHeight * 2 + val availableHeightForCenterImages = backgroundPaintingArea.height() - heightOfEdgePinnedImages + val roundedTileHeight = round(tileHeight) + if (roundedTileHeight > 0 && (availableHeightForCenterImages > 0 || FloatUtil.floatsEqual(availableHeightForCenterImages, 0f))) { + val centerImagesCount = floor(round(availableHeightForCenterImages) / roundedTileHeight).toInt() + val centerImagesHeight = centerImagesCount * tileHeight + val totalFreeSpace = availableHeightForCenterImages - centerImagesHeight + val totalInstances = centerImagesCount + 2 + ySpacing = totalFreeSpace / (totalInstances - 1) + yTilesCount = totalInstances + initialY = backgroundPaintingArea.top + } else { + yTilesCount = 1 + } + } else if (repeatY == BackgroundRepeatKeyword.Round || repeatY == BackgroundRepeatKeyword.Repeat) { + val roundedTileHeight = round(tileHeight) + if (roundedTileHeight > 0) { + val tilesBeforeY = ceil(round(initialY) / roundedTileHeight).toInt() + val tilesAfterY = ceil(round((backgroundPaintingArea.height() - initialY)) / roundedTileHeight).toInt() + yTilesCount = tilesBeforeY + tilesAfterY + initialY -= (tilesBeforeY * tileHeight) + } + ySpacing = 0f + } + + // 5. draw the repeating tiles using translate + var translateX = initialX + var translateY: Float + repeat(xTilesCount) { + translateY = initialY + repeat(yTilesCount) { + canvas.save() + canvas.translate(translateX, translateY) + canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) + canvas.restore() + translateY += tileHeight + ySpacing + } + translateX += tileWidth + xSpacing + } + } + } + canvas.restore() + } + + private fun computeBorderInsets(): RectF = + borderInsets?.resolve(layoutDirection, context).let { + RectF( + it?.left?.dpToPx() ?: 0f, + it?.top?.dpToPx() ?: 0f, + it?.right?.dpToPx() ?: 0f, + it?.bottom?.dpToPx() ?: 0f, + ) + } + + private fun hasInvalidDimensions(positioningArea: RectF, paintingArea: RectF): Boolean = + FloatUtil.floatsEqual(positioningArea.width(), 0f) || positioningArea.width() < 0f || + FloatUtil.floatsEqual(positioningArea.height(), 0f) || positioningArea.height() < 0f || + FloatUtil.floatsEqual(paintingArea.width(), 0f) || paintingArea.width() < 0f || + FloatUtil.floatsEqual(paintingArea.height(), 0f) || paintingArea.height() < 0f + + private fun updatePath() { + if (!needUpdatePath) { + return + } + needUpdatePath = false + + val computedBorderInsets = computeBorderInsets() + + // background-origin: padding-box + backgroundPositioningArea = RectF( + bounds.left + computedBorderInsets.left, + bounds.top + computedBorderInsets.top, + bounds.right - computedBorderInsets.right, + bounds.bottom - computedBorderInsets.bottom) + + // background-clip: border-box + backgroundPaintingArea = RectF(bounds) + + val computedBorderRadius = + borderRadius?.resolve( + layoutDirection, + context, + bounds.width().pxToDp(), + bounds.height().pxToDp(), + ) + + if (borderRadius?.hasRoundedBorders() == true) { + val paintingArea = backgroundPaintingArea ?: return + backgroundImageClipPath = Path() + backgroundImageClipPath?.addRoundRect( + paintingArea, + floatArrayOf( + computedBorderRadius?.topLeft?.horizontal?.dpToPx() ?: 0f, + computedBorderRadius?.topLeft?.vertical?.dpToPx() ?: 0f, + computedBorderRadius?.topRight?.horizontal?.dpToPx() ?: 0f, + computedBorderRadius?.topRight?.vertical?.dpToPx() ?: 0f, + computedBorderRadius?.bottomRight?.horizontal?.dpToPx() ?: 0f, + computedBorderRadius?.bottomRight?.vertical?.dpToPx() ?: 0f, + computedBorderRadius?.bottomLeft?.horizontal?.dpToPx() ?: 0f, + computedBorderRadius?.bottomLeft?.vertical?.dpToPx() ?: 0f, + ), + Path.Direction.CW, + ) + } else { + val paintingArea = backgroundPaintingArea ?: return + backgroundImageClipPath = Path() + backgroundImageClipPath?.addRect(paintingArea, Path.Direction.CW) + } + } + + private fun positionToPixels(lengthPercentage: LengthPercentage, availableSpace: Float): Float = + if (lengthPercentage.type == LengthPercentageType.PERCENT) { + lengthPercentage.resolve(availableSpace) + } else { + lengthPercentage.resolve(availableSpace).dpToPx() + } + + private fun calculateBackgroundImageSize( + containerWidth: Float, + containerHeight: Float, + imageWidth: Float, + imageHeight: Float, + backgroundSize: BackgroundSize?, + repeat: BackgroundRepeat? + ): Pair { + var finalWidth = imageWidth + var finalHeight = imageHeight + + if (backgroundSize is BackgroundSize.LengthPercentageAuto) { + val w = backgroundSize.lengthPercentage.x + val h = backgroundSize.lengthPercentage.y + if (w != null && h != null) { + finalWidth = positionToPixels(w, containerWidth) + finalHeight = positionToPixels(h, containerHeight) + } + } + + if (repeat?.x == BackgroundRepeatKeyword.Round && finalWidth > 0) { + if (!FloatUtil.floatsEqual(containerWidth.rem(finalWidth), 0f)) { + val numRepeats = round(containerWidth / finalWidth) + if (numRepeats > 0) { + finalWidth = containerWidth / numRepeats + } + } + } + + if (repeat?.y == BackgroundRepeatKeyword.Round && finalHeight > 0) { + if (!FloatUtil.floatsEqual(containerHeight.rem(finalHeight), 0f)) { + val numRepeats = round(containerHeight / finalHeight) + if (numRepeats > 0) { + finalHeight = containerHeight / numRepeats + } + } + } + + return finalWidth to finalHeight + } + + private fun calculateBackgroundPosition( + tileWidth: Float, + tileHeight: Float, + position: BackgroundPosition? + ): Pair { + val backgroundPositioningArea = backgroundPositioningArea ?: return 0f to 0f + + val availableSpaceX = backgroundPositioningArea.width() - tileWidth + val availableSpaceY = backgroundPositioningArea.height() - tileHeight + + val translateX = + when { + position?.left != null -> positionToPixels(position.left, availableSpaceX) + position?.right != null -> availableSpaceX - positionToPixels(position.right, availableSpaceX) + else -> 0.0f + } + backgroundPositioningArea.left + + val translateY = + when { + position?.top != null -> positionToPixels(position.top, availableSpaceY) + position?.bottom != null -> availableSpaceY - positionToPixels(position.bottom, availableSpaceY) + else -> 0.0f + } + backgroundPositioningArea.top + + return translateX to translateY + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt index 06acd3acaa07..d41da75564ed 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt @@ -38,6 +38,9 @@ internal class CompositeBackgroundDrawable( /** Background rendering Layer */ val background: BackgroundDrawable? = null, + /** Background image rendering Layer */ + val backgroundImage: BackgroundImageDrawable? = null, + /** Border rendering Layer */ val border: BorderDrawable? = null, @@ -61,6 +64,7 @@ internal class CompositeBackgroundDrawable( originalBackground, outerShadows, background, + backgroundImage, border, feedbackUnderlay, innerShadows, @@ -75,12 +79,29 @@ internal class CompositeBackgroundDrawable( setPaddingMode(LayerDrawable.PADDING_MODE_STACK) } + fun withNewBackgroundImage(backgroundImage: BackgroundImageDrawable?): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable( + context, + originalBackground, + outerShadows, + background, + backgroundImage, + border, + feedbackUnderlay, + innerShadows, + outline, + borderInsets, + borderRadius, + ) + } + fun withNewBackground(background: BackgroundDrawable?): CompositeBackgroundDrawable { return CompositeBackgroundDrawable( context, originalBackground, outerShadows, background, + backgroundImage, border, feedbackUnderlay, innerShadows, @@ -99,6 +120,7 @@ internal class CompositeBackgroundDrawable( originalBackground, outerShadows, background, + backgroundImage, border, feedbackUnderlay, innerShadows, @@ -114,6 +136,7 @@ internal class CompositeBackgroundDrawable( originalBackground, outerShadows, background, + backgroundImage, border, feedbackUnderlay, innerShadows, @@ -129,6 +152,7 @@ internal class CompositeBackgroundDrawable( originalBackground, outerShadows, background, + backgroundImage, border, feedbackUnderlay, innerShadows, @@ -144,6 +168,7 @@ internal class CompositeBackgroundDrawable( originalBackground, outerShadows, background, + backgroundImage, border, newUnderlay, innerShadows, @@ -201,6 +226,7 @@ internal class CompositeBackgroundDrawable( originalBackground: Drawable?, outerShadows: List, background: BackgroundDrawable?, + backgroundImage: BackgroundImageDrawable?, border: BorderDrawable?, feedbackUnderlay: Drawable?, innerShadows: List, @@ -210,6 +236,7 @@ internal class CompositeBackgroundDrawable( originalBackground?.let { layers.add(it) } layers.addAll(outerShadows.asReversed()) background?.let { layers.add(it) } + backgroundImage?.let { layers.add(it) } border?.let { layers.add(it) } feedbackUnderlay?.let { layers.add(it) } layers.addAll(innerShadows.asReversed()) 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 4193a6677d36..f07494d84fe6 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 @@ -8,7 +8,6 @@ package com.facebook.react.uimanager.style import android.content.Context -import android.graphics.Rect import android.graphics.Shader import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType @@ -42,6 +41,6 @@ public class BackgroundImageLayer() { } } - public fun getShader(bounds: Rect): Shader = - gradient.getShader(bounds.width().toFloat(), bounds.height().toFloat()) + public fun getShader(width: Float, height: Float): Shader = + gradient.getShader(width, height) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundPosition.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundPosition.kt new file mode 100644 index 000000000000..6a09e11fdf49 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundPosition.kt @@ -0,0 +1,47 @@ +/* + * 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 com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.uimanager.LengthPercentage + +public class BackgroundPosition( + public val top: LengthPercentage?, + public val left: LengthPercentage?, + public val right: LengthPercentage?, + public val bottom: LengthPercentage? +) { + public companion object { + public fun parse(backgroundPositionMap: ReadableMap?): BackgroundPosition? { + if (backgroundPositionMap == null) return null + + val top = + if (backgroundPositionMap.hasKey("top") && backgroundPositionMap.getType("top") != ReadableType.Null) { + LengthPercentage.setFromDynamic(backgroundPositionMap.getDynamic("top"), true) + } else null + + val left = + if (backgroundPositionMap.hasKey("left") && backgroundPositionMap.getType("left") != ReadableType.Null) { + LengthPercentage.setFromDynamic(backgroundPositionMap.getDynamic("left"), true) + } else null + + val right = + if (backgroundPositionMap.hasKey("right") && backgroundPositionMap.getType("right") != ReadableType.Null) { + LengthPercentage.setFromDynamic(backgroundPositionMap.getDynamic("right"), true) + } else null + + val bottom = + if (backgroundPositionMap.hasKey("bottom") && backgroundPositionMap.getType("bottom") != ReadableType.Null) { + LengthPercentage.setFromDynamic(backgroundPositionMap.getDynamic("bottom"), true) + } else null + + return BackgroundPosition(top, left, right, bottom) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundRepeat.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundRepeat.kt new file mode 100644 index 000000000000..1631ce5c8a08 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundRepeat.kt @@ -0,0 +1,46 @@ +/* + * 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 com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType + +public enum class BackgroundRepeatKeyword { + Repeat, + Space, + Round, + NoRepeat +} + +public class BackgroundRepeat( + public val x: BackgroundRepeatKeyword, + public val y: BackgroundRepeatKeyword +) { + public companion object { + public fun parse(backgroundRepeatMap: ReadableMap?): BackgroundRepeat? { + if (backgroundRepeatMap == null) return null + + val x = parseRepeatStyle(backgroundRepeatMap, "x") ?: BackgroundRepeatKeyword.Repeat + val y = parseRepeatStyle(backgroundRepeatMap, "y") ?: BackgroundRepeatKeyword.Repeat + + return BackgroundRepeat(x, y) + } + + private fun parseRepeatStyle(map: ReadableMap, key: String): BackgroundRepeatKeyword? { + if (!map.hasKey(key) || map.getType(key) != ReadableType.String) return null + + return when (map.getString(key)) { + "repeat" -> BackgroundRepeatKeyword.Repeat + "space" -> BackgroundRepeatKeyword.Space + "round" -> BackgroundRepeatKeyword.Round + "no-repeat" -> BackgroundRepeatKeyword.NoRepeat + else -> null + } + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundSize.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundSize.kt new file mode 100644 index 000000000000..73abe64082a0 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/BackgroundSize.kt @@ -0,0 +1,97 @@ +/* + * 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 com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.uimanager.LengthPercentage + +public class BackgroundSizeLengthPercentage( + public val x: LengthPercentage?, + public val y: LengthPercentage? +) { + public fun isXAuto(): Boolean = x == null + public fun isYAuto(): Boolean = y == null + + public companion object { + public fun parse(backgroundSizeMap: ReadableMap?): BackgroundSizeLengthPercentage? { + if (backgroundSizeMap == null) return null + + val x = + if (backgroundSizeMap.hasKey("x") && backgroundSizeMap.getType("x") != ReadableType.Null) { + when (backgroundSizeMap.getType("x")) { + ReadableType.Number -> LengthPercentage.setFromDynamic(backgroundSizeMap.getDynamic("x")) + ReadableType.String -> { + when (val xStr = backgroundSizeMap.getString("x")) { + "auto" -> null + else -> { + if (xStr != null && xStr.endsWith("%")) { + LengthPercentage.setFromDynamic(backgroundSizeMap.getDynamic(("x"))) + } else { + null + } + } + } + } + + else -> null + } + } else null + + val y = + if (backgroundSizeMap.hasKey("y") && backgroundSizeMap.getType("y") != ReadableType.Null) { + when (backgroundSizeMap.getType("y")) { + ReadableType.Number -> LengthPercentage.setFromDynamic(backgroundSizeMap.getDynamic("y")) + ReadableType.String -> { + val yStr = backgroundSizeMap.getString("y") + when (yStr) { + "auto" -> null + else -> { + if (yStr != null && yStr.endsWith("%")) { + LengthPercentage.setFromDynamic(backgroundSizeMap.getDynamic("y")) + } else { + null + } + } + } + } + + else -> null + } + } else null + + return BackgroundSizeLengthPercentage(x, y) + } + } +} + +public sealed class BackgroundSize { + public class LengthPercentageAuto(public val lengthPercentage: BackgroundSizeLengthPercentage) : + BackgroundSize() + + public companion object { + public fun parse(backgroundSizeValue: Dynamic?): BackgroundSize? { + if (backgroundSizeValue == null) return null + + return when (backgroundSizeValue.type) { + ReadableType.Map -> { + val backgroundSizeValueMap = backgroundSizeValue.asMap() ?: return null; + val lengthPercentage = BackgroundSizeLengthPercentage.parse(backgroundSizeValueMap) + if (lengthPercentage != null) { + LengthPercentageAuto(lengthPercentage) + } else { + null + } + } + else -> null + } + } + } + +} 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 d614e3f914ee..152899b389cd 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 @@ -35,6 +35,9 @@ import com.facebook.react.uimanager.annotations.ReactPropGroup import com.facebook.react.uimanager.common.UIManagerType import com.facebook.react.uimanager.common.ViewUtil import com.facebook.react.uimanager.style.BackgroundImageLayer +import com.facebook.react.uimanager.style.BackgroundPosition +import com.facebook.react.uimanager.style.BackgroundRepeat +import com.facebook.react.uimanager.style.BackgroundSize import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderStyle import com.facebook.react.uimanager.style.LogicalEdge @@ -152,6 +155,63 @@ public open class ReactViewManager : ReactClippingViewManager() } } + @ReactProp(name = ViewProps.BACKGROUND_SIZE, customType = "BackgroundSize") + public open fun setBackgroundSize(view: ReactViewGroup, backgroundSize: ReadableArray?) { + if (ViewUtil.getUIManagerType(view) == UIManagerType.FABRIC) { + if (backgroundSize != null && backgroundSize.size() > 0) { + val backgroundSizes = ArrayList(backgroundSize.size()) + for (i in 0 until backgroundSize.size()) { + val backgroundSizeValue = backgroundSize.getDynamic(i) + val parsedBackgroundSize = BackgroundSize.parse(backgroundSizeValue); + if (parsedBackgroundSize != null) { + backgroundSizes.add(parsedBackgroundSize) + } + } + BackgroundStyleApplicator.setBackgroundSize(view, backgroundSizes) + } + } else { + BackgroundStyleApplicator.setBackgroundSize(view, null) + } + } + + @ReactProp(name = ViewProps.BACKGROUND_POSITION, customType = "BackgroundPosition") + public open fun setBackgroundPosition(view: ReactViewGroup, backgroundPosition: ReadableArray?) { + if (ViewUtil.getUIManagerType(view) == UIManagerType.FABRIC) { + if (backgroundPosition != null && backgroundPosition.size() > 0) { + val backgroundPositions = ArrayList(backgroundPosition.size()) + for (i in 0 until backgroundPosition.size()) { + val backgroundPositionMap = backgroundPosition.getMap(i) + val parsedBackgroundPosition = BackgroundPosition.parse(backgroundPositionMap) + if (parsedBackgroundPosition != null) { + backgroundPositions.add(parsedBackgroundPosition) + } + } + BackgroundStyleApplicator.setBackgroundPosition(view, backgroundPositions) + } else { + BackgroundStyleApplicator.setBackgroundPosition(view, null) + } + } + } + + @ReactProp(name = ViewProps.BACKGROUND_REPEAT, customType = "BackgroundRepeat") + public open fun setBackgroundRepeat(view: ReactViewGroup, backgroundRepeat: ReadableArray?) { + if (ViewUtil.getUIManagerType(view) == UIManagerType.FABRIC) { + if (backgroundRepeat != null && backgroundRepeat.size() > 0) { + val backgroundRepeats = ArrayList(backgroundRepeat.size()) + for (i in 0 until backgroundRepeat.size()) { + val backgroundRepeatMap = backgroundRepeat.getMap(i) + val parsedBackgroundRepeat = BackgroundRepeat.parse(backgroundRepeatMap) + if (parsedBackgroundRepeat != null) { + backgroundRepeats.add(parsedBackgroundRepeat) + } + } + BackgroundStyleApplicator.setBackgroundRepeat(view, backgroundRepeats) + } else { + BackgroundStyleApplicator.setBackgroundRepeat(view, null) + } + } + } + @ReactProp(name = "nextFocusDown", defaultInt = View.NO_ID) public open fun nextFocusDown(view: ReactViewGroup, viewId: Int) { view.nextFocusDownId = viewId