diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index f4386d1bd1c..5a276c6eff0 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -4834,17 +4834,28 @@ public final class com/facebook/react/uimanager/events/TouchEventType$Companion public final fun getJSEventName (Lcom/facebook/react/uimanager/events/TouchEventType;)Ljava/lang/String; } -public final class com/facebook/react/uimanager/style/BackgroundImageLayer { +public abstract class com/facebook/react/uimanager/style/BackgroundImageLayer { public static final field Companion Lcom/facebook/react/uimanager/style/BackgroundImageLayer$Companion; - public fun ()V - public synthetic fun (Lcom/facebook/react/uimanager/style/Gradient;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getShader (FF)Landroid/graphics/Shader; + public synthetic fun (Lkotlin/jvm/internal/DefaultConstructorMarker;)V } 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/BackgroundImageLayer$GradientLayer : com/facebook/react/uimanager/style/BackgroundImageLayer { + public final fun getShader (FF)Landroid/graphics/Shader; +} + +public final class com/facebook/react/uimanager/style/BackgroundImageLayer$URLImageLayer : com/facebook/react/uimanager/style/BackgroundImageLayer { + public fun (Ljava/lang/String;Ljava/lang/Float;Ljava/lang/Float;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Float;Ljava/lang/Float;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getIntrinsicHeight ()Ljava/lang/Float; + public final fun getIntrinsicWidth ()Ljava/lang/Float; + public final fun getUri ()Ljava/lang/String; +} + 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; 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 index 66e04d3e7cb..80a3412017e 100644 --- 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 @@ -8,6 +8,7 @@ package com.facebook.react.uimanager.drawable import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.Paint @@ -45,11 +46,13 @@ internal class BackgroundImageDrawable( private var backgroundImageClipPath: Path? = null private var backgroundPositioningArea: RectF? = null private var backgroundPaintingArea: RectF? = null + private val urlImageLoader = BackgroundImageURLLoader(context) var backgroundImageLayers: List? = null set(value) { if (field != value) { field = value + loadUrlImages(value) invalidateSelf() } } @@ -111,7 +114,7 @@ internal class BackgroundImageDrawable( } override fun draw(canvas: Canvas) { - if (backgroundImageLayers == null || backgroundImageLayers?.isEmpty() == true) { + if (backgroundImageLayers.isNullOrEmpty()) { return } @@ -140,13 +143,40 @@ internal class BackgroundImageDrawable( val position = backgroundPosition?.takeIf { it.isNotEmpty() }?.let { it.getOrNull(index % it.size) } + val urlBitmap: Bitmap? + val (intrinsicWidth, intrinsicHeight) = + when (backgroundImageLayer) { + is BackgroundImageLayer.GradientLayer -> { + urlBitmap = null + backgroundPositioningArea.width() to backgroundPositioningArea.height() + } + is BackgroundImageLayer.URLImageLayer -> { + val bitmap = urlImageLoader.loadedBitmapForUri(backgroundImageLayer.uri) + if (bitmap == null) { + continue + } + urlBitmap = bitmap + // Asset (local require() assets) pass intrinsicWidth/Height in DIPs + // So use those dimensions and convert from dp to physical pixels. + val assetWidth = backgroundImageLayer.intrinsicWidth + val assetHeight = backgroundImageLayer.intrinsicHeight + if (assetWidth != null && assetHeight != null) { + assetWidth.dpToPx() to assetHeight.dpToPx() + } else { + // Plain url() images don't pass intrinsic width/height + // so take the decoded pixels as DIPs and convert + bitmap.width.toFloat().dpToPx() to bitmap.height.toFloat().dpToPx() + } + } + } + // 2. Calculate the size of a single tile. val (tileWidth, tileHeight) = calculateBackgroundImageSize( backgroundPositioningArea.width(), backgroundPositioningArea.height(), - backgroundPositioningArea.width(), - backgroundPositioningArea.height(), + intrinsicWidth, + intrinsicHeight, size, repeat, ) @@ -155,8 +185,12 @@ internal class BackgroundImageDrawable( continue } - // 3. Set paint shader - backgroundPaint.setShader(backgroundImageLayer.getShader(tileWidth, tileHeight)) + // 3. Set paint shader for gradients (URL images don't use shaders) + if (backgroundImageLayer is BackgroundImageLayer.GradientLayer) { + backgroundPaint.setShader(backgroundImageLayer.getShader(tileWidth, tileHeight)) + } else { + backgroundPaint.setShader(null) + } // 4. Calculate spacing, x and y tiles count and position for tiles var (initialX, initialY) = calculateBackgroundPosition(tileWidth, tileHeight, position) @@ -255,7 +289,13 @@ internal class BackgroundImageDrawable( repeat(yTilesCount) { canvas.save() canvas.translate(translateX, translateY) - canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) + if (urlBitmap != null) { + val srcRect = Rect(0, 0, urlBitmap.width, urlBitmap.height) + val dstRect = RectF(0f, 0f, tileWidth, tileHeight) + canvas.drawBitmap(urlBitmap, srcRect, dstRect, backgroundPaint) + } else { + canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) + } canvas.restore() translateY += tileHeight + ySpacing } @@ -414,4 +454,14 @@ internal class BackgroundImageDrawable( return translateX to translateY } + + private fun loadUrlImages(layers: List?) { + val uris = layers?.filterIsInstance()?.map { it.uri } + if (uris.isNullOrEmpty()) { + urlImageLoader.cancelAllRequests() + return + } + + urlImageLoader.loadImages(uris) { invalidateSelf() } + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt new file mode 100644 index 00000000000..fab2cd76e56 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt @@ -0,0 +1,109 @@ +/* + * 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.Bitmap +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.logging.FLog +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequestBuilder +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.common.ReactConstants +import com.facebook.react.views.imagehelper.ImageSource +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +internal class BackgroundImageURLLoader(private val context: Context) { + + private val pendingRequests = + ConcurrentHashMap>>() + private val loadedBitmaps = ConcurrentHashMap() + private val outstandingRequests = AtomicInteger(0) + private var onComplete: (() -> Unit)? = null + private var requestedUris: List = emptyList() + + fun loadImages( + uris: List, + onComplete: () -> Unit + ) { + val distinctUris = uris.distinct() + if (distinctUris == requestedUris) { + return + } + + cancelAllRequests() + + if (distinctUris.isEmpty()) { + onComplete() + return + } + + requestedUris = distinctUris + this.onComplete = onComplete + outstandingRequests.set(distinctUris.size) + for (uri in distinctUris) { + val imageRequest = + ImageRequestBuilder.newBuilderWithSource(ImageSource(context, uri).uri).build() + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(imageRequest, null) + + pendingRequests[uri] = dataSource + + dataSource.subscribe( + object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(bitmap: Bitmap?) { + if (bitmap != null) { + val copiedBitmap = bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false) + if (copiedBitmap != null) { + loadedBitmaps[uri] = copiedBitmap + } else { + FLog.w(ReactConstants.TAG, "Could not copy bitmap for background image: %s", uri) + } + } + onRequestComplete(uri) + } + + override fun onFailureImpl(dataSource: DataSource>) { + FLog.w( + ReactConstants.TAG, + dataSource.failureCause, + "Failed to load background image: %s", + uri) + onRequestComplete(uri) + } + }, + CallerThreadExecutor.getInstance() + ) + } + } + + fun loadedBitmapForUri(uri: String): Bitmap? = loadedBitmaps[uri] + + private fun onRequestComplete(uri: String) { + pendingRequests.remove(uri) + if (outstandingRequests.decrementAndGet() == 0) { + UiThreadUtil.runOnUiThread { onComplete?.invoke() } + } + } + + fun cancelAllRequests() { + for (dataSource in pendingRequests.values) { + dataSource.close() + } + pendingRequests.clear() + loadedBitmaps.clear() + outstandingRequests.set(0) + onComplete = null + requestedUris = emptyList() + } +} 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 8e5cc7d8c2a..e264ed06561 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 @@ -13,60 +13,95 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType /** - * Represents a single layer of a background image, typically containing a gradient. + * Represents a single layer of a background image, either a gradient or an image loaded from a URL. * - * This class encapsulates gradient definitions (linear or radial) that can be applied as background - * layers to React Native views. It provides parsing from React Native bridge data and shader - * generation for rendering. + * This class encapsulates the background image definitions (linear gradient, radial gradient, or + * `url()`) that can be applied as background layers to React Native views. It provides parsing from + * React Native bridge data and shader generation for rendering. * * @see LinearGradient * @see RadialGradient */ -public class BackgroundImageLayer() { - private lateinit var gradient: Gradient +public sealed class BackgroundImageLayer { + /** A layer rendered from a linear or radial gradient. */ + public class GradientLayer internal constructor(private val gradient: Gradient) : + BackgroundImageLayer() { + /** + * Creates a shader for rendering this background layer. + * + * @param width The width of the area to fill + * @param height The height of the area to fill + * @return A Shader instance for rendering the gradient + */ + public fun getShader(width: Float, height: Float): Shader = gradient.getShader(width, height) + } - private constructor(gradient: Gradient) : this() { - this.gradient = gradient + /** + * A layer rendered from an image fetched from [uri]. + * + * @param uri The source URI of the image to draw + * @param intrinsicWidth Natural width in DIPs, or null + * @param intrinsicHeight Natural height in DIPs, or null + */ + public class URLImageLayer( + public val uri: String, + public val intrinsicWidth: Float? = null, + public val intrinsicHeight: Float? = null, + ) : BackgroundImageLayer() { + override fun equals(other: Any?): Boolean = + other is URLImageLayer && + uri == other.uri && + intrinsicWidth == other.intrinsicWidth && + intrinsicHeight == other.intrinsicHeight } public companion object { /** * Parses a ReadableMap into a BackgroundImageLayer. * - * The map should contain gradient configuration including a "type" key specifying either - * "linear-gradient" or "radial-gradient". + * The map should contain a "type" key specifying either "linear-gradient", "radial-gradient", + * or "url". * - * @param gradientMap The map containing gradient configuration + * @param backgroundImageMap The map containing the background image configuration * @param context Android context for resource resolution * @return A BackgroundImageLayer instance, or null if parsing fails */ - public fun parse(gradientMap: ReadableMap?, context: Context): BackgroundImageLayer? { - if (gradientMap == null) { + public fun parse(backgroundImageMap: ReadableMap?, context: Context): BackgroundImageLayer? { + if (backgroundImageMap == 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) { + if (!backgroundImageMap.hasKey("type") || + backgroundImageMap.getType("type") != ReadableType.String) { return null } - return when (gradientMap.getString("type")) { - "linear-gradient" -> LinearGradient.parse(gradientMap, context) - "radial-gradient" -> RadialGradient.parse(gradientMap, context) + return when (backgroundImageMap.getString("type")) { + "linear-gradient" -> { + val gradient = LinearGradient.parse(backgroundImageMap, context) ?: return null + GradientLayer(gradient) + } + "radial-gradient" -> { + val gradient = RadialGradient.parse(backgroundImageMap, context) ?: return null + GradientLayer(gradient) + } + "url" -> { + val uri = backgroundImageMap.getString("uri") ?: return null + URLImageLayer( + uri, + readDimension(backgroundImageMap, "intrinsicWidth"), + readDimension(backgroundImageMap, "intrinsicHeight"), + ) + } else -> null } } - } - /** - * Creates a shader for rendering this background layer. - * - * @param width The width of the area to fill - * @param height The height of the area to fill - * @return A Shader instance for rendering the gradient - */ - public fun getShader(width: Float, height: Float): Shader = gradient.getShader(width, height) + private fun readDimension(map: ReadableMap, key: String): Float? = + if (map.hasKey(key) && map.getType(key) == ReadableType.Number) { + map.getDouble(key).toFloat() + } else { + null + } + } }