From 23ea9fc4321f65dd7d8e6d20c623c88d6b78064e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Mon, 29 Dec 2025 18:07:22 +0530 Subject: [PATCH 1/7] url() function support in background on android copy bitmap on background thread --- .../ReactAndroid/api/ReactAndroid.api | 19 ++- .../drawable/BackgroundImageDrawable.kt | 62 +++++++++- .../drawable/BackgroundImageURLLoader.kt | 109 ++++++++++++++++++ .../uimanager/style/BackgroundImageLayer.kt | 95 ++++++++++----- 4 files changed, 245 insertions(+), 40 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoader.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index a5fa388742a7..e9aca4dcaa76 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -4804,17 +4804,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 66e04d3e7cbc..80a3412017ed 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 000000000000..fab2cd76e568 --- /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 8e5cc7d8c2a4..e264ed06561b 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 + } + } } From 66fa14a12a2c29fc413981e08537ff1b6a21d512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 10:10:33 +0530 Subject: [PATCH 2/7] Rework background url() loading on Android to match iOS BackgroundImageURLLoader keeps one entry for each unique uri, keeps the entries for uris that did not change, redraws after each image, and ignores callbacks from cancelled entries. All state changes happen on the UI thread. The loaded bitmap is copied with its EXIF orientation applied, in the same way as Fresco's OrientedDrawable. An auto dimension in backgroundSize keeps the image's aspect ratio for url() layers. Pending requests are cancelled when the view is detached or hidden, and started again when it is shown. --- .../drawable/BackgroundImageDrawable.kt | 78 ++++++-- .../drawable/BackgroundImageURLLoader.kt | 187 ++++++++++++------ 2 files changed, 181 insertions(+), 84 deletions(-) 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 80a3412017ed..03623c9ef728 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 @@ -46,13 +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) + private val backgroundImageURLLoader = BackgroundImageURLLoader(context) { invalidateSelf() } var backgroundImageLayers: List? = null set(value) { if (field != value) { field = value - loadUrlImages(value) + updateBackgroundImageURLRequests() invalidateSelf() } } @@ -94,6 +94,19 @@ internal class BackgroundImageDrawable( needUpdatePath = true } + // The view calls this when it is detached from the window or hidden, and again when it is shown. + override fun setVisible(visible: Boolean, restart: Boolean): Boolean { + val changed = super.setVisible(visible, restart) + if (changed) { + if (visible) { + updateBackgroundImageURLRequests() + } else { + backgroundImageURLLoader.cancelPendingRequests() + } + } + return changed + } + override fun setAlpha(alpha: Int) { backgroundPaint.alpha = alpha invalidateSelf() @@ -143,19 +156,19 @@ internal class BackgroundImageDrawable( val position = backgroundPosition?.takeIf { it.isNotEmpty() }?.let { it.getOrNull(index % it.size) } - val urlBitmap: Bitmap? + val urlImage: Bitmap? val (intrinsicWidth, intrinsicHeight) = when (backgroundImageLayer) { is BackgroundImageLayer.GradientLayer -> { - urlBitmap = null + urlImage = null backgroundPositioningArea.width() to backgroundPositioningArea.height() } is BackgroundImageLayer.URLImageLayer -> { - val bitmap = urlImageLoader.loadedBitmapForUri(backgroundImageLayer.uri) - if (bitmap == null) { + val image = backgroundImageURLLoader.imageForUri(backgroundImageLayer.uri) + if (image == null) { continue } - urlBitmap = bitmap + urlImage = image // Asset (local require() assets) pass intrinsicWidth/Height in DIPs // So use those dimensions and convert from dp to physical pixels. val assetWidth = backgroundImageLayer.intrinsicWidth @@ -165,7 +178,7 @@ internal class BackgroundImageDrawable( } 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() + image.width.toFloat().dpToPx() to image.height.toFloat().dpToPx() } } } @@ -177,6 +190,7 @@ internal class BackgroundImageDrawable( backgroundPositioningArea.height(), intrinsicWidth, intrinsicHeight, + backgroundImageLayer is BackgroundImageLayer.URLImageLayer, size, repeat, ) @@ -289,10 +303,10 @@ internal class BackgroundImageDrawable( repeat(yTilesCount) { canvas.save() canvas.translate(translateX, translateY) - if (urlBitmap != null) { - val srcRect = Rect(0, 0, urlBitmap.width, urlBitmap.height) + if (urlImage != null) { + val srcRect = Rect(0, 0, urlImage.width, urlImage.height) val dstRect = RectF(0f, 0f, tileWidth, tileHeight) - canvas.drawBitmap(urlBitmap, srcRect, dstRect, backgroundPaint) + canvas.drawBitmap(urlImage, srcRect, dstRect, backgroundPaint) } else { canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) } @@ -390,16 +404,37 @@ internal class BackgroundImageDrawable( containerHeight: Float, imageWidth: Float, imageHeight: Float, + hasIntrinsicRatio: Boolean, backgroundSize: BackgroundSize?, repeat: BackgroundRepeat?, ): Pair { var finalWidth = imageWidth var finalHeight = imageHeight + // An `auto` dimension keeps the image's aspect ratio. Without a ratio (gradients), it stays at + // its intrinsic size. + val keepsRatio = hasIntrinsicRatio && imageWidth > 0 && imageHeight > 0 + val ratio = if (keepsRatio) imageWidth / imageHeight else 0f + var xAuto = true + var yAuto = true if (backgroundSize is BackgroundSize.LengthPercentageAuto) { val w = backgroundSize.lengthPercentage.x val h = backgroundSize.lengthPercentage.y - if (w != null && h != null) { + xAuto = backgroundSize.lengthPercentage.isXAuto() + yAuto = backgroundSize.lengthPercentage.isYAuto() + if (keepsRatio) { + if (w != null) { + finalWidth = positionToPixels(w, containerWidth) + } + if (h != null) { + finalHeight = positionToPixels(h, containerHeight) + } + if (!xAuto && yAuto) { + finalHeight = finalWidth / ratio + } else if (xAuto && !yAuto) { + finalWidth = finalHeight * ratio + } + } else if (w != null && h != null) { finalWidth = positionToPixels(w, containerWidth) finalHeight = positionToPixels(h, containerHeight) } @@ -423,6 +458,15 @@ internal class BackgroundImageDrawable( } } + // With `round` on one axis only, an `auto` other axis is scaled again to restore the ratio. + val roundX = repeat?.x == BackgroundRepeatKeyword.Round + val roundY = repeat?.y == BackgroundRepeatKeyword.Round + if (keepsRatio && roundX && !roundY && yAuto) { + finalHeight = finalWidth / ratio + } else if (keepsRatio && roundY && !roundX && xAuto) { + finalWidth = finalHeight * ratio + } + return finalWidth to finalHeight } @@ -455,13 +499,7 @@ 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() } + private fun updateBackgroundImageURLRequests() { + backgroundImageURLLoader.loadBackgroundImages(backgroundImageLayers.orEmpty()) } } 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 index fab2cd76e568..ebbde88f7c08 100644 --- 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 @@ -9,101 +9,160 @@ package com.facebook.react.uimanager.drawable import android.content.Context import android.graphics.Bitmap +import android.graphics.Matrix import com.facebook.common.executors.CallerThreadExecutor import com.facebook.common.logging.FLog import com.facebook.common.references.CloseableReference +import com.facebook.datasource.BaseDataSubscriber 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.image.CloseableStaticBitmap import com.facebook.imagepipeline.request.ImageRequestBuilder import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.style.BackgroundImageLayer 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) { +/** + * Loads the `url()` layers of a view's background image. Keeps one request for each unique uri and + * calls [onLoad] on the UI thread each time an image finishes loading. Use it only on the UI + * thread. + */ +internal class BackgroundImageURLLoader( + private val context: Context, + private val onLoad: () -> Unit, +) { - private val pendingRequests = - ConcurrentHashMap>>() - private val loadedBitmaps = ConcurrentHashMap() - private val outstandingRequests = AtomicInteger(0) - private var onComplete: (() -> Unit)? = null - private var requestedUris: List = emptyList() + private var entries: List = emptyList() - fun loadImages( - uris: List, - onComplete: () -> Unit - ) { - val distinctUris = uris.distinct() - if (distinctUris == requestedUris) { + /** + * Requests every `url()` layer in [backgroundImageLayers]. Does nothing if the uris did not + * change since the last call. Cancels all requests when there are no `url()` layers. + */ + fun loadBackgroundImages(backgroundImageLayers: List) { + val distinctUris = + backgroundImageLayers + .filterIsInstance() + .map { it.uri } + .filter { it.isNotEmpty() } + .distinct() + if (distinctUris == entries.map { it.uri }) { return } - cancelAllRequests() + // Keep the entries for uris that are still used, so their images do not disappear while the + // new ones load. + val newEntries = mutableListOf() + val nextEntries = + distinctUris.map { uri -> + entries.find { it.uri == uri } ?: Entry(uri).also { newEntries.add(it) } + } + entries.filter { it !in nextEntries }.forEach { it.cancel() } + entries = nextEntries + newEntries.forEach { it.start() } + } - if (distinctUris.isEmpty()) { - onComplete() - return - } + /** + * Cancels the requests that are still loading. Loaded images stay. The next + * [loadBackgroundImages] call requests the cancelled uris again. + */ + fun cancelPendingRequests() { + val (loaded, pending) = entries.partition { it.image != null } + pending.forEach { it.cancel() } + entries = loaded + } - 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) + /** Returns null until the image for [uri] has loaded. */ + fun imageForUri(uri: String): Bitmap? = entries.find { it.uri == uri }?.image + + private inner class Entry(val uri: String) { + var image: Bitmap? = null + private set - pendingRequests[uri] = dataSource + private var dataSource: DataSource>? = null + private var cancelled = false + fun start() { + val imageRequest = + ImageRequestBuilder.newBuilderWithSource(ImageSource(context, uri).uri).build() + val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, null) + this.dataSource = 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 { + object : BaseDataSubscriber>() { + override fun onNewResultImpl( + dataSource: DataSource> + ) { + if (!dataSource.isFinished) { + return + } + val imageReference = dataSource.result ?: return + // The pipeline reuses the decoded bitmap after the reference is closed, so keep a + // copy. + val uprightImage = + try { + (imageReference.get() as? CloseableStaticBitmap)?.let { uprightImage(it) } + } finally { + CloseableReference.closeSafely(imageReference) + } + if (uprightImage == null) { FLog.w(ReactConstants.TAG, "Could not copy bitmap for background image: %s", uri) + return + } + UiThreadUtil.runOnUiThread { + if (!cancelled) { + image = uprightImage + onLoad() + } } } - onRequestComplete(uri) - } - override fun onFailureImpl(dataSource: DataSource>) { - FLog.w( - ReactConstants.TAG, - dataSource.failureCause, - "Failed to load background image: %s", - uri) - onRequestComplete(uri) - } - }, - CallerThreadExecutor.getInstance() + override fun onFailureImpl(dataSource: DataSource>) { + FLog.w( + ReactConstants.TAG, + dataSource.failureCause, + "Failed to load background image: %s", + 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 cancel() { + cancelled = true + dataSource?.close() } } - fun cancelAllRequests() { - for (dataSource in pendingRequests.values) { - dataSource.close() + private companion object { + /** + * Copies the decoded bitmap with its EXIF orientation applied. The pipeline only reports the + * orientation, and Fresco's drawee applies it when it draws, in the same way as below. + */ + fun uprightImage(image: CloseableStaticBitmap): Bitmap? { + val source = image.underlyingBitmap ?: return null + val exifOrientation = image.exifOrientation + val rotationAngle = image.rotationAngle + if (rotationAngle <= 0 && exifOrientation <= 1) { + return source.copy(source.config ?: Bitmap.Config.ARGB_8888, false) + } + val matrix = Matrix() + when (exifOrientation) { + 2 -> matrix.setScale(-1f, 1f) + 4 -> matrix.setScale(1f, -1f) + 5 -> { + matrix.setRotate(270f) + matrix.postScale(1f, -1f) + } + 7 -> { + matrix.setRotate(270f) + matrix.postScale(-1f, 1f) + } + else -> matrix.setRotate(rotationAngle.toFloat()) + } + return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) } - pendingRequests.clear() - loadedBitmaps.clear() - outstandingRequests.set(0) - onComplete = null - requestedUris = emptyList() } } From e3b791b7e010da5768220ecd21930de96ba2cb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 10:39:16 +0530 Subject: [PATCH 3/7] Address review feedback for background url() on Android - Keep the public BackgroundImageLayer API unchanged. url() data is in internal fields, so ReactAndroid.api matches main again. - Cap the decode at screen size and keep the natural size from the encoded image, so large photos do not crash the hardware canvas. - Show the first frame of animated images, as iOS does. - Return null for a non-string uri instead of throwing. - Start requests only while the drawable is visible, and catch up in draw() when the view skipped setVisible(true). - Filter scaled bitmaps, and create the tile rectangles once per layer. --- .../ReactAndroid/api/ReactAndroid.api | 19 +-- .../drawable/BackgroundImageDrawable.kt | 63 +++++----- .../drawable/BackgroundImageURLLoader.kt | 57 +++++++-- .../uimanager/style/BackgroundImageLayer.kt | 111 +++++++++--------- 4 files changed, 143 insertions(+), 107 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index e9aca4dcaa76..a5fa388742a7 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -4804,28 +4804,17 @@ public final class com/facebook/react/uimanager/events/TouchEventType$Companion public final fun getJSEventName (Lcom/facebook/react/uimanager/events/TouchEventType;)Ljava/lang/String; } -public abstract class com/facebook/react/uimanager/style/BackgroundImageLayer { +public final class com/facebook/react/uimanager/style/BackgroundImageLayer { public static final field Companion Lcom/facebook/react/uimanager/style/BackgroundImageLayer$Companion; - public synthetic fun (Lkotlin/jvm/internal/DefaultConstructorMarker;)V + 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 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 03623c9ef728..c27d63b6946b 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 @@ -47,12 +47,17 @@ internal class BackgroundImageDrawable( private var backgroundPositioningArea: RectF? = null private var backgroundPaintingArea: RectF? = null private val backgroundImageURLLoader = BackgroundImageURLLoader(context) { invalidateSelf() } + private var needsBackgroundImageURLRequests = false var backgroundImageLayers: List? = null set(value) { if (field != value) { field = value - updateBackgroundImageURLRequests() + if (isVisible) { + updateBackgroundImageURLRequests() + } else { + needsBackgroundImageURLRequests = true + } invalidateSelf() } } @@ -82,7 +87,7 @@ internal class BackgroundImageDrawable( } private val backgroundPaint: Paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { style = Paint.Style.FILL } override fun invalidateSelf() { needUpdatePath = true @@ -102,6 +107,7 @@ internal class BackgroundImageDrawable( updateBackgroundImageURLRequests() } else { backgroundImageURLLoader.cancelPendingRequests() + needsBackgroundImageURLRequests = true } } return changed @@ -127,6 +133,10 @@ internal class BackgroundImageDrawable( } override fun draw(canvas: Canvas) { + if (needsBackgroundImageURLRequests) { + updateBackgroundImageURLRequests() + } + if (backgroundImageLayers.isNullOrEmpty()) { return } @@ -156,30 +166,28 @@ internal class BackgroundImageDrawable( val position = backgroundPosition?.takeIf { it.isNotEmpty() }?.let { it.getOrNull(index % it.size) } + val uri = backgroundImageLayer.uri val urlImage: Bitmap? val (intrinsicWidth, intrinsicHeight) = - when (backgroundImageLayer) { - is BackgroundImageLayer.GradientLayer -> { - urlImage = null - backgroundPositioningArea.width() to backgroundPositioningArea.height() + if (uri == null) { + urlImage = null + backgroundPositioningArea.width() to backgroundPositioningArea.height() + } else { + val image = backgroundImageURLLoader.imageForUri(uri) + if (image == null) { + continue } - is BackgroundImageLayer.URLImageLayer -> { - val image = backgroundImageURLLoader.imageForUri(backgroundImageLayer.uri) - if (image == null) { - continue - } - urlImage = image - // 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 - image.width.toFloat().dpToPx() to image.height.toFloat().dpToPx() - } + urlImage = image.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 natural pixels as DIPs and convert + image.width.toFloat().dpToPx() to image.height.toFloat().dpToPx() } } @@ -190,7 +198,7 @@ internal class BackgroundImageDrawable( backgroundPositioningArea.height(), intrinsicWidth, intrinsicHeight, - backgroundImageLayer is BackgroundImageLayer.URLImageLayer, + uri != null, size, repeat, ) @@ -200,7 +208,7 @@ internal class BackgroundImageDrawable( } // 3. Set paint shader for gradients (URL images don't use shaders) - if (backgroundImageLayer is BackgroundImageLayer.GradientLayer) { + if (uri == null) { backgroundPaint.setShader(backgroundImageLayer.getShader(tileWidth, tileHeight)) } else { backgroundPaint.setShader(null) @@ -296,6 +304,8 @@ internal class BackgroundImageDrawable( } // 5. draw the repeating tiles using translate + val srcRect = urlImage?.let { Rect(0, 0, it.width, it.height) } + val dstRect = RectF(0f, 0f, tileWidth, tileHeight) var translateX = initialX var translateY: Float repeat(xTilesCount) { @@ -304,8 +314,6 @@ internal class BackgroundImageDrawable( canvas.save() canvas.translate(translateX, translateY) if (urlImage != null) { - val srcRect = Rect(0, 0, urlImage.width, urlImage.height) - val dstRect = RectF(0f, 0f, tileWidth, tileHeight) canvas.drawBitmap(urlImage, srcRect, dstRect, backgroundPaint) } else { canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) @@ -500,6 +508,7 @@ internal class BackgroundImageDrawable( } private fun updateBackgroundImageURLRequests() { + needsBackgroundImageURLRequests = false backgroundImageURLLoader.loadBackgroundImages(backgroundImageLayers.orEmpty()) } } 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 index ebbde88f7c08..6253572a6042 100644 --- 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 @@ -16,6 +16,10 @@ import com.facebook.common.references.CloseableReference import com.facebook.datasource.BaseDataSubscriber import com.facebook.datasource.DataSource import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.fresco.middleware.HasExtraData +import com.facebook.imagepipeline.common.ImageDecodeOptions +import com.facebook.imagepipeline.common.ResizeOptions +import com.facebook.imagepipeline.core.DownsampleMode import com.facebook.imagepipeline.image.CloseableImage import com.facebook.imagepipeline.image.CloseableStaticBitmap import com.facebook.imagepipeline.request.ImageRequestBuilder @@ -34,19 +38,24 @@ internal class BackgroundImageURLLoader( private val onLoad: () -> Unit, ) { + /** A loaded image. [width] and [height] are its natural size in pixels, before downsampling. */ + class LoadedImage(val bitmap: Bitmap, val width: Int, val height: Int) + private var entries: List = emptyList() + private val resizeOptions = + context.resources.displayMetrics.let { + val maxSize = maxOf(it.widthPixels, it.heightPixels) + ResizeOptions(maxSize, maxSize) + } + /** * Requests every `url()` layer in [backgroundImageLayers]. Does nothing if the uris did not * change since the last call. Cancels all requests when there are no `url()` layers. */ fun loadBackgroundImages(backgroundImageLayers: List) { val distinctUris = - backgroundImageLayers - .filterIsInstance() - .map { it.uri } - .filter { it.isNotEmpty() } - .distinct() + backgroundImageLayers.mapNotNull { it.uri }.filter { it.isNotEmpty() }.distinct() if (distinctUris == entries.map { it.uri }) { return } @@ -74,10 +83,10 @@ internal class BackgroundImageURLLoader( } /** Returns null until the image for [uri] has loaded. */ - fun imageForUri(uri: String): Bitmap? = entries.find { it.uri == uri }?.image + fun imageForUri(uri: String): LoadedImage? = entries.find { it.uri == uri }?.image private inner class Entry(val uri: String) { - var image: Bitmap? = null + var image: LoadedImage? = null private set private var dataSource: DataSource>? = null @@ -85,7 +94,18 @@ internal class BackgroundImageURLLoader( fun start() { val imageRequest = - ImageRequestBuilder.newBuilderWithSource(ImageSource(context, uri).uri).build() + ImageRequestBuilder.newBuilderWithSource(ImageSource(context, uri).uri) + .setResizeOptions(resizeOptions) + // Downsample while decoding instead of resizing the encoded image, so the encoded + // size + // stays the natural size. + .setDownsampleOverride(DownsampleMode.ALWAYS) + .setResizingAllowedOverride(false) + // Like iOS, animated images show their first frame. + .setImageDecodeOptions( + ImageDecodeOptions.newBuilder().setForceStaticImage(true).build() + ) + .build() val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, null) this.dataSource = dataSource dataSource.subscribe( @@ -101,7 +121,7 @@ internal class BackgroundImageURLLoader( // copy. val uprightImage = try { - (imageReference.get() as? CloseableStaticBitmap)?.let { uprightImage(it) } + (imageReference.get() as? CloseableStaticBitmap)?.let { loadedImage(it) } } finally { CloseableReference.closeSafely(imageReference) } @@ -137,6 +157,25 @@ internal class BackgroundImageURLLoader( } private companion object { + fun loadedImage(image: CloseableStaticBitmap): LoadedImage? { + val bitmap = uprightImage(image) ?: return null + // The decode can be downsampled, so take the natural size from the encoded image. It is the + // size before EXIF rotation, so swap it when the upright bitmap is rotated. + val encodedWidth = image.extras[HasExtraData.KEY_ENCODED_WIDTH] as? Int ?: -1 + val encodedHeight = image.extras[HasExtraData.KEY_ENCODED_HEIGHT] as? Int ?: -1 + if (encodedWidth <= 0 || encodedHeight <= 0) { + return LoadedImage(bitmap, bitmap.width, bitmap.height) + } + val rotated = + bitmap.width != bitmap.height && + (bitmap.width > bitmap.height) != (encodedWidth > encodedHeight) + return if (rotated) { + LoadedImage(bitmap, encodedHeight, encodedWidth) + } else { + LoadedImage(bitmap, encodedWidth, encodedHeight) + } + } + /** * Copies the decoded bitmap with its EXIF orientation applied. The pipeline only reports the * orientation, and Fresco's drawee applies it when it draws, in the same way as below. 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 e264ed06561b..6758ebce8f6a 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,46 +13,28 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType /** - * Represents a single layer of a background image, either a gradient or an image loaded from a URL. + * Represents a single layer of a background image: a gradient, or an image loaded from a `url()`. * - * 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. + * This class encapsulates gradient definitions (linear or radial) and `url()` images that can be + * applied as background layers to React Native views. It provides parsing from React Native bridge + * data and shader generation for rendering gradients. * * @see LinearGradient * @see RadialGradient */ -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) - } +public class BackgroundImageLayer() { + private lateinit var 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 + /** The source uri of a `url()` layer, or null for a gradient layer. */ + internal var uri: String? = null + + /** Natural size in DIPs, supplied for bundled assets. Null for a plain url. */ + internal var intrinsicWidth: Float? = null + + internal var intrinsicHeight: Float? = null + + private constructor(gradient: Gradient) : this() { + this.gradient = gradient } public companion object { @@ -62,41 +44,49 @@ public sealed class BackgroundImageLayer { * The map should contain a "type" key specifying either "linear-gradient", "radial-gradient", * or "url". * - * @param backgroundImageMap The map containing the background image configuration + * @param gradientMap 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(backgroundImageMap: ReadableMap?, context: Context): BackgroundImageLayer? { - if (backgroundImageMap == null) { + public fun parse(gradientMap: ReadableMap?, context: Context): BackgroundImageLayer? { + if (gradientMap == null) { return null } + if ( + gradientMap.hasKey("type") && + gradientMap.getType("type") == ReadableType.String && + gradientMap.getString("type") == "url" + ) { + return parseURLImage(gradientMap) + } + val gradient = parseGradient(gradientMap, context) ?: return null + return BackgroundImageLayer(gradient) + } - if (!backgroundImageMap.hasKey("type") || - backgroundImageMap.getType("type") != ReadableType.String) { + private fun parseGradient(gradientMap: ReadableMap, context: Context): Gradient? { + if (!gradientMap.hasKey("type") || gradientMap.getType("type") != ReadableType.String) { return null } - 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"), - ) - } + return when (gradientMap.getString("type")) { + "linear-gradient" -> LinearGradient.parse(gradientMap, context) + "radial-gradient" -> RadialGradient.parse(gradientMap, context) else -> null } } + private fun parseURLImage(urlMap: ReadableMap): BackgroundImageLayer? { + if (!urlMap.hasKey("uri") || urlMap.getType("uri") != ReadableType.String) { + return null + } + val uri = urlMap.getString("uri") ?: return null + return BackgroundImageLayer().apply { + this.uri = uri + intrinsicWidth = readDimension(urlMap, "intrinsicWidth") + intrinsicHeight = readDimension(urlMap, "intrinsicHeight") + } + } + private fun readDimension(map: ReadableMap, key: String): Float? = if (map.hasKey(key) && map.getType(key) == ReadableType.Number) { map.getDouble(key).toFloat() @@ -104,4 +94,13 @@ public sealed class BackgroundImageLayer { 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) } From ac5807b6cd2753e198874f88f0d7af394fc23649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 13:00:20 +0530 Subject: [PATCH 4/7] Decode background url() images at full size on Android Remove the decode size limit, so background images load like network images. Decoding at the drawn size will follow in a separate change on both platforms. --- .../drawable/BackgroundImageDrawable.kt | 4 +- .../drawable/BackgroundImageURLLoader.kt | 43 ++----------------- 2 files changed, 5 insertions(+), 42 deletions(-) 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 c27d63b6946b..fe97affdcd27 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 @@ -177,7 +177,7 @@ internal class BackgroundImageDrawable( if (image == null) { continue } - urlImage = image.bitmap + urlImage = image // Asset (local require() assets) pass intrinsicWidth/Height in DIPs // So use those dimensions and convert from dp to physical pixels. val assetWidth = backgroundImageLayer.intrinsicWidth @@ -186,7 +186,7 @@ internal class BackgroundImageDrawable( assetWidth.dpToPx() to assetHeight.dpToPx() } else { // Plain url() images don't pass intrinsic width/height - // so take the natural pixels as DIPs and convert + // so take the decoded pixels as DIPs and convert image.width.toFloat().dpToPx() to image.height.toFloat().dpToPx() } } 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 index 6253572a6042..a644f60e5b00 100644 --- 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 @@ -16,10 +16,7 @@ import com.facebook.common.references.CloseableReference import com.facebook.datasource.BaseDataSubscriber import com.facebook.datasource.DataSource import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.fresco.middleware.HasExtraData import com.facebook.imagepipeline.common.ImageDecodeOptions -import com.facebook.imagepipeline.common.ResizeOptions -import com.facebook.imagepipeline.core.DownsampleMode import com.facebook.imagepipeline.image.CloseableImage import com.facebook.imagepipeline.image.CloseableStaticBitmap import com.facebook.imagepipeline.request.ImageRequestBuilder @@ -38,17 +35,8 @@ internal class BackgroundImageURLLoader( private val onLoad: () -> Unit, ) { - /** A loaded image. [width] and [height] are its natural size in pixels, before downsampling. */ - class LoadedImage(val bitmap: Bitmap, val width: Int, val height: Int) - private var entries: List = emptyList() - private val resizeOptions = - context.resources.displayMetrics.let { - val maxSize = maxOf(it.widthPixels, it.heightPixels) - ResizeOptions(maxSize, maxSize) - } - /** * Requests every `url()` layer in [backgroundImageLayers]. Does nothing if the uris did not * change since the last call. Cancels all requests when there are no `url()` layers. @@ -83,10 +71,10 @@ internal class BackgroundImageURLLoader( } /** Returns null until the image for [uri] has loaded. */ - fun imageForUri(uri: String): LoadedImage? = entries.find { it.uri == uri }?.image + fun imageForUri(uri: String): Bitmap? = entries.find { it.uri == uri }?.image private inner class Entry(val uri: String) { - var image: LoadedImage? = null + var image: Bitmap? = null private set private var dataSource: DataSource>? = null @@ -95,12 +83,6 @@ internal class BackgroundImageURLLoader( fun start() { val imageRequest = ImageRequestBuilder.newBuilderWithSource(ImageSource(context, uri).uri) - .setResizeOptions(resizeOptions) - // Downsample while decoding instead of resizing the encoded image, so the encoded - // size - // stays the natural size. - .setDownsampleOverride(DownsampleMode.ALWAYS) - .setResizingAllowedOverride(false) // Like iOS, animated images show their first frame. .setImageDecodeOptions( ImageDecodeOptions.newBuilder().setForceStaticImage(true).build() @@ -121,7 +103,7 @@ internal class BackgroundImageURLLoader( // copy. val uprightImage = try { - (imageReference.get() as? CloseableStaticBitmap)?.let { loadedImage(it) } + (imageReference.get() as? CloseableStaticBitmap)?.let { uprightImage(it) } } finally { CloseableReference.closeSafely(imageReference) } @@ -157,25 +139,6 @@ internal class BackgroundImageURLLoader( } private companion object { - fun loadedImage(image: CloseableStaticBitmap): LoadedImage? { - val bitmap = uprightImage(image) ?: return null - // The decode can be downsampled, so take the natural size from the encoded image. It is the - // size before EXIF rotation, so swap it when the upright bitmap is rotated. - val encodedWidth = image.extras[HasExtraData.KEY_ENCODED_WIDTH] as? Int ?: -1 - val encodedHeight = image.extras[HasExtraData.KEY_ENCODED_HEIGHT] as? Int ?: -1 - if (encodedWidth <= 0 || encodedHeight <= 0) { - return LoadedImage(bitmap, bitmap.width, bitmap.height) - } - val rotated = - bitmap.width != bitmap.height && - (bitmap.width > bitmap.height) != (encodedWidth > encodedHeight) - return if (rotated) { - LoadedImage(bitmap, encodedHeight, encodedWidth) - } else { - LoadedImage(bitmap, encodedWidth, encodedHeight) - } - } - /** * Copies the decoded bitmap with its EXIF orientation applied. The pipeline only reports the * orientation, and Fresco's drawee applies it when it draws, in the same way as below. From 2094042d0e46164df081aeaeaa05ff02edeea074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 16:06:22 +0530 Subject: [PATCH 5/7] Load background url() images on prop change and cancel on drop on Android - Start the requests when backgroundImage is set, and cancel them in ReactViewManager.onDropViewInstance, as iOS does in finalizeUpdates and prepareForRecycle. This removes the setVisible and draw() logic. - Copy the bitmap on a Fresco background thread. A cached image calls back at once, so the copy ran on the UI thread. - background-repeat: round uses at least one tile, as CSS requires. - Add BackgroundImageLayer parse tests. --- .../uimanager/BackgroundStyleApplicator.kt | 5 + .../drawable/BackgroundImageDrawable.kt | 49 ++------ .../drawable/BackgroundImageURLLoader.kt | 15 +-- .../react/views/view/ReactViewManager.kt | 2 + .../style/BackgroundImageLayerTest.kt | 110 ++++++++++++++++++ 5 files changed, 134 insertions(+), 47 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt 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 c3af3626ae6e..22cef27488b8 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 @@ -89,6 +89,11 @@ public object BackgroundStyleApplicator { ensureBackgroundImageDrawable(view).backgroundImageLayers = backgroundImageLayers } + @JvmStatic + internal fun cancelBackgroundImageURLRequests(view: View) { + getBackgroundImage(view)?.cancelBackgroundImageURLRequests() + } + @JvmStatic internal fun setBackgroundSize(view: View, backgroundSizes: List?) { ensureBackgroundImageDrawable(view).backgroundSize = backgroundSizes 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 fe97affdcd27..720d8cc87b99 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 @@ -47,17 +47,12 @@ internal class BackgroundImageDrawable( private var backgroundPositioningArea: RectF? = null private var backgroundPaintingArea: RectF? = null private val backgroundImageURLLoader = BackgroundImageURLLoader(context) { invalidateSelf() } - private var needsBackgroundImageURLRequests = false var backgroundImageLayers: List? = null set(value) { if (field != value) { field = value - if (isVisible) { - updateBackgroundImageURLRequests() - } else { - needsBackgroundImageURLRequests = true - } + backgroundImageURLLoader.loadBackgroundImages(value.orEmpty()) invalidateSelf() } } @@ -89,6 +84,11 @@ internal class BackgroundImageDrawable( private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { style = Paint.Style.FILL } + /** Called when the view is dropped. */ + fun cancelBackgroundImageURLRequests() { + backgroundImageURLLoader.cancelRequests() + } + override fun invalidateSelf() { needUpdatePath = true super.invalidateSelf() @@ -99,20 +99,6 @@ internal class BackgroundImageDrawable( needUpdatePath = true } - // The view calls this when it is detached from the window or hidden, and again when it is shown. - override fun setVisible(visible: Boolean, restart: Boolean): Boolean { - val changed = super.setVisible(visible, restart) - if (changed) { - if (visible) { - updateBackgroundImageURLRequests() - } else { - backgroundImageURLLoader.cancelPendingRequests() - needsBackgroundImageURLRequests = true - } - } - return changed - } - override fun setAlpha(alpha: Int) { backgroundPaint.alpha = alpha invalidateSelf() @@ -133,10 +119,6 @@ internal class BackgroundImageDrawable( } override fun draw(canvas: Canvas) { - if (needsBackgroundImageURLRequests) { - updateBackgroundImageURLRequests() - } - if (backgroundImageLayers.isNullOrEmpty()) { return } @@ -450,19 +432,17 @@ internal class BackgroundImageDrawable( 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 - } + // CSS rounds to the nearest whole number of tiles, but at least one. + val numRepeats = maxOf(1f, round(containerWidth / finalWidth)) + 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 - } + // CSS rounds to the nearest whole number of tiles, but at least one. + val numRepeats = maxOf(1f, round(containerHeight / finalHeight)) + finalHeight = containerHeight / numRepeats } } @@ -506,9 +486,4 @@ internal class BackgroundImageDrawable( return translateX to translateY } - - private fun updateBackgroundImageURLRequests() { - needsBackgroundImageURLRequests = false - backgroundImageURLLoader.loadBackgroundImages(backgroundImageLayers.orEmpty()) - } } 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 index a644f60e5b00..fd528178fed4 100644 --- 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 @@ -10,7 +10,6 @@ package com.facebook.react.uimanager.drawable import android.content.Context import android.graphics.Bitmap import android.graphics.Matrix -import com.facebook.common.executors.CallerThreadExecutor import com.facebook.common.logging.FLog import com.facebook.common.references.CloseableReference import com.facebook.datasource.BaseDataSubscriber @@ -60,14 +59,9 @@ internal class BackgroundImageURLLoader( newEntries.forEach { it.start() } } - /** - * Cancels the requests that are still loading. Loaded images stay. The next - * [loadBackgroundImages] call requests the cancelled uris again. - */ - fun cancelPendingRequests() { - val (loaded, pending) = entries.partition { it.image != null } - pending.forEach { it.cancel() } - entries = loaded + fun cancelRequests() { + entries.forEach { it.cancel() } + entries = emptyList() } /** Returns null until the image for [uri] has loaded. */ @@ -128,7 +122,8 @@ internal class BackgroundImageURLLoader( ) } }, - CallerThreadExecutor.getInstance(), + // Copying the bitmap is slow, and a cached image calls back at once. + Fresco.getImagePipeline().config.executorSupplier.forBackgroundTasks(), ) } 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 21ef11182da3..1ae08f279020 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 @@ -88,6 +88,8 @@ public open class ReactViewManager : ReactClippingViewManager() } override fun onDropViewInstance(view: ReactViewGroup) { + // Before `super`, which may recycle the view and drop its background. + BackgroundStyleApplicator.cancelBackgroundImageURLRequests(view) super.onDropViewInstance(view) view.cleanUpAxOrderListener() } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt new file mode 100644 index 000000000000..0dc44cf739fa --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/BackgroundImageLayerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.style + +import android.content.Context +import android.graphics.Color +import android.graphics.LinearGradient as AndroidLinearGradient +import android.util.DisplayMetrics +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.DisplayMetricsHolder +import org.assertj.core.api.Assertions.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Tests for [BackgroundImageLayer] */ +@RunWith(RobolectricTestRunner::class) +class BackgroundImageLayerTest { + private lateinit var context: Context + + @Before + fun setUp() { + val metrics = DisplayMetrics() + metrics.density = 1f + DisplayMetricsHolder.setScreenDisplayMetrics(metrics) + context = RuntimeEnvironment.getApplication() + } + + @Test + fun testParsesURL() { + val layer = + BackgroundImageLayer.parse( + JavaOnlyMap.of("type", "url", "uri", "https://example.com/a.png"), + context, + ) + + assertThat(layer).isNotNull + assertThat(layer?.uri).isEqualTo("https://example.com/a.png") + assertThat(layer?.intrinsicWidth).isNull() + assertThat(layer?.intrinsicHeight).isNull() + } + + @Test + fun testParsesURLIntrinsicSize() { + val layer = + BackgroundImageLayer.parse( + JavaOnlyMap.of( + "type", + "url", + "uri", + "asset.png", + "intrinsicWidth", + 100.0, + "intrinsicHeight", + 50.0, + ), + context, + ) + + assertThat(layer).isNotNull + assertThat(layer?.uri).isEqualTo("asset.png") + assertThat(layer?.intrinsicWidth).isEqualTo(100f) + assertThat(layer?.intrinsicHeight).isEqualTo(50f) + } + + @Test + fun testRejectsURLWithoutStringURI() { + assertThat(BackgroundImageLayer.parse(JavaOnlyMap.of("type", "url"), context)).isNull() + assertThat(BackgroundImageLayer.parse(JavaOnlyMap.of("type", "url", "uri", 1.0), context)) + .isNull() + } + + @Test + fun testParsesGradient() { + val layer = + BackgroundImageLayer.parse( + JavaOnlyMap.of( + "type", + "linear-gradient", + "direction", + JavaOnlyMap.of("type", "angle", "value", 180.0), + "colorStops", + JavaOnlyArray.of( + JavaOnlyMap.of("color", Color.RED), + JavaOnlyMap.of("color", Color.BLUE), + ), + ), + context, + ) + + assertThat(layer).isNotNull + assertThat(layer?.uri).isNull() + assertThat(layer?.getShader(100f, 100f)).isInstanceOf(AndroidLinearGradient::class.java) + } + + @Test + fun testRejectsUnknownType() { + assertThat(BackgroundImageLayer.parse(JavaOnlyMap.of("type", "conic-gradient"), context)) + .isNull() + assertThat(BackgroundImageLayer.parse(null, context)).isNull() + } +} From d58201787b50e6aa3e40ccf61f2e5b6b8471a3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 17:55:12 +0530 Subject: [PATCH 6/7] Share Fresco's bitmap for background url() images on Android Each view copied the decoded bitmap, so N views with the same url held N full-size copies. Keep Fresco's CloseableReference open instead and draw the shared bitmap, as Fresco's drawee does. EXIF orientation is applied with a matrix at draw time. The reference is released through DeferredReleaser on cancel. 20 views with the same 1800x1200 image: native heap +178 MB before, +9.3 MB after. --- .../drawable/BackgroundImageDrawable.kt | 12 +- .../drawable/BackgroundImageURLLoader.kt | 119 ++++++++++-------- .../drawable/BackgroundImageURLLoaderTest.kt | 63 ++++++++++ 3 files changed, 137 insertions(+), 57 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt 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 720d8cc87b99..15408935f0b0 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,7 +8,6 @@ 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 @@ -149,7 +148,7 @@ internal class BackgroundImageDrawable( backgroundPosition?.takeIf { it.isNotEmpty() }?.let { it.getOrNull(index % it.size) } val uri = backgroundImageLayer.uri - val urlImage: Bitmap? + val urlImage: BackgroundImageURLLoader.OrientedBitmap? val (intrinsicWidth, intrinsicHeight) = if (uri == null) { urlImage = null @@ -169,7 +168,7 @@ internal class BackgroundImageDrawable( } else { // Plain url() images don't pass intrinsic width/height // so take the decoded pixels as DIPs and convert - image.width.toFloat().dpToPx() to image.height.toFloat().dpToPx() + image.width.dpToPx() to image.height.dpToPx() } } @@ -286,8 +285,7 @@ internal class BackgroundImageDrawable( } // 5. draw the repeating tiles using translate - val srcRect = urlImage?.let { Rect(0, 0, it.width, it.height) } - val dstRect = RectF(0f, 0f, tileWidth, tileHeight) + val urlImageMatrix = urlImage?.drawMatrix(tileWidth, tileHeight) var translateX = initialX var translateY: Float repeat(xTilesCount) { @@ -295,8 +293,8 @@ internal class BackgroundImageDrawable( repeat(yTilesCount) { canvas.save() canvas.translate(translateX, translateY) - if (urlImage != null) { - canvas.drawBitmap(urlImage, srcRect, dstRect, backgroundPaint) + if (urlImage != null && urlImageMatrix != null) { + canvas.drawBitmap(urlImage.bitmap, urlImageMatrix, backgroundPaint) } else { canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) } 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 index fd528178fed4..1ca1b4223c6f 100644 --- 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 @@ -10,16 +10,18 @@ package com.facebook.react.uimanager.drawable import android.content.Context import android.graphics.Bitmap import android.graphics.Matrix +import android.graphics.RectF +import com.facebook.common.executors.UiThreadImmediateExecutorService import com.facebook.common.logging.FLog import com.facebook.common.references.CloseableReference import com.facebook.datasource.BaseDataSubscriber import com.facebook.datasource.DataSource import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.components.DeferredReleaser import com.facebook.imagepipeline.common.ImageDecodeOptions import com.facebook.imagepipeline.image.CloseableImage import com.facebook.imagepipeline.image.CloseableStaticBitmap import com.facebook.imagepipeline.request.ImageRequestBuilder -import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.style.BackgroundImageLayer import com.facebook.react.views.imagehelper.ImageSource @@ -65,13 +67,57 @@ internal class BackgroundImageURLLoader( } /** Returns null until the image for [uri] has loaded. */ - fun imageForUri(uri: String): Bitmap? = entries.find { it.uri == uri }?.image + fun imageForUri(uri: String): OrientedBitmap? = entries.find { it.uri == uri }?.image + + /** + * A decoded image from the pipeline's cache. Fresco's drawee also draws the shared bitmap and + * applies the EXIF orientation when it draws, in the same way as [drawMatrix]. + */ + class OrientedBitmap(val bitmap: Bitmap, exifOrientation: Int, rotationAngle: Int) { + private val orientation = + Matrix().apply { + when (exifOrientation) { + 2 -> setScale(-1f, 1f) + 4 -> setScale(1f, -1f) + 5 -> { + setRotate(270f) + postScale(1f, -1f) + } + 7 -> { + setRotate(270f) + postScale(-1f, 1f) + } + else -> if (rotationAngle > 0) setRotate(rotationAngle.toFloat()) + } + } + private val orientedBounds = + RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()).also { + orientation.mapRect(it) + } + + /** Width in pixels, after the EXIF orientation. */ + val width: Float + get() = orientedBounds.width() + + /** Height in pixels, after the EXIF orientation. */ + val height: Float + get() = orientedBounds.height() + + /** Returns a matrix that draws [bitmap] upright and scaled to [width] x [height]. */ + fun drawMatrix(width: Float, height: Float): Matrix = + Matrix(orientation).apply { + postTranslate(-orientedBounds.left, -orientedBounds.top) + postScale(width / orientedBounds.width(), height / orientedBounds.height()) + } + } private inner class Entry(val uri: String) { - var image: Bitmap? = null + var image: OrientedBitmap? = null private set private var dataSource: DataSource>? = null + // Keeps the bitmap in the pipeline's cache while this entry draws it. + private var imageReference: CloseableReference? = null private var cancelled = false fun start() { @@ -93,24 +139,19 @@ internal class BackgroundImageURLLoader( return } val imageReference = dataSource.result ?: return - // The pipeline reuses the decoded bitmap after the reference is closed, so keep a - // copy. - val uprightImage = - try { - (imageReference.get() as? CloseableStaticBitmap)?.let { uprightImage(it) } - } finally { - CloseableReference.closeSafely(imageReference) - } - if (uprightImage == null) { - FLog.w(ReactConstants.TAG, "Could not copy bitmap for background image: %s", uri) + // This is the final image. Closing the data source makes any later callback get + // no result, so only this reference is kept. + dataSource.close() + val staticBitmap = imageReference.get() as? CloseableStaticBitmap + val bitmap = staticBitmap?.underlyingBitmap + if (cancelled || staticBitmap == null || bitmap == null) { + CloseableReference.closeSafely(imageReference) return } - UiThreadUtil.runOnUiThread { - if (!cancelled) { - image = uprightImage - onLoad() - } - } + this@Entry.imageReference = imageReference + image = + OrientedBitmap(bitmap, staticBitmap.exifOrientation, staticBitmap.rotationAngle) + onLoad() } override fun onFailureImpl(dataSource: DataSource>) { @@ -122,44 +163,22 @@ internal class BackgroundImageURLLoader( ) } }, - // Copying the bitmap is slow, and a cached image calls back at once. - Fresco.getImagePipeline().config.executorSupplier.forBackgroundTasks(), + // Like Fresco's drawee. A cached image calls back at once. + UiThreadImmediateExecutorService.getInstance(), ) } fun cancel() { cancelled = true dataSource?.close() - } - } - - private companion object { - /** - * Copies the decoded bitmap with its EXIF orientation applied. The pipeline only reports the - * orientation, and Fresco's drawee applies it when it draws, in the same way as below. - */ - fun uprightImage(image: CloseableStaticBitmap): Bitmap? { - val source = image.underlyingBitmap ?: return null - val exifOrientation = image.exifOrientation - val rotationAngle = image.rotationAngle - if (rotationAngle <= 0 && exifOrientation <= 1) { - return source.copy(source.config ?: Bitmap.Config.ARGB_8888, false) - } - val matrix = Matrix() - when (exifOrientation) { - 2 -> matrix.setScale(-1f, 1f) - 4 -> matrix.setScale(1f, -1f) - 5 -> { - matrix.setRotate(270f) - matrix.postScale(1f, -1f) - } - 7 -> { - matrix.setRotate(270f) - matrix.postScale(-1f, 1f) - } - else -> matrix.setRotate(rotationAngle.toFloat()) + image = null + val imageReference = imageReference ?: return + this.imageReference = null + // The last frame drawn can still use the bitmap, so release it after that frame, like + // Fresco's drawee. + DeferredReleaser.getInstance().scheduleDeferredRelease { + CloseableReference.closeSafely(imageReference) } - return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) } } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt new file mode 100644 index 000000000000..bd13cbe7fae2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BackgroundImageURLLoaderTest.kt @@ -0,0 +1,63 @@ +/* + * 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.graphics.Bitmap +import org.assertj.core.api.Assertions.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.GraphicsMode + +/** Tests for [BackgroundImageURLLoader.OrientedBitmap] */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class BackgroundImageURLLoaderTest { + private val bitmap = Bitmap.createBitmap(60, 40, Bitmap.Config.ARGB_8888) + + /** + * Maps the first and the last pixel corner of the bitmap's first row into a 0..1 box of the + * upright image. + */ + private fun firstRowCorners(exifOrientation: Int, rotationAngle: Int = 0): List { + val image = BackgroundImageURLLoader.OrientedBitmap(bitmap, exifOrientation, rotationAngle) + val points = floatArrayOf(0f, 0f, bitmap.width.toFloat(), 0f) + image.drawMatrix(1f, 1f).mapPoints(points) + return points.map { Math.round(it * 100) / 100f } + } + + @Test + fun testKeepsSizeWithoutOrientation() { + val image = BackgroundImageURLLoader.OrientedBitmap(bitmap, 1, 0) + assertThat(image.width).isEqualTo(60f) + assertThat(image.height).isEqualTo(40f) + assertThat(firstRowCorners(1)).containsExactly(0f, 0f, 1f, 0f) + } + + @Test + fun testMirrorsAndRotatesWithoutSwappingAxes() { + assertThat(firstRowCorners(2)).containsExactly(1f, 0f, 0f, 0f) + assertThat(firstRowCorners(3, 180)).containsExactly(1f, 1f, 0f, 1f) + assertThat(firstRowCorners(4)).containsExactly(0f, 1f, 1f, 1f) + } + + @Test + fun testSwapsAxesForQuarterTurns() { + for (orientation in listOf(5 to 0, 6 to 90, 7 to 0, 8 to 270)) { + val image = + BackgroundImageURLLoader.OrientedBitmap(bitmap, orientation.first, orientation.second) + assertThat(image.width).isEqualTo(40f) + assertThat(image.height).isEqualTo(60f) + } + // The first row becomes the left, right, right, and left edge. + assertThat(firstRowCorners(5)).containsExactly(0f, 0f, 0f, 1f) + assertThat(firstRowCorners(6, 90)).containsExactly(1f, 0f, 1f, 1f) + assertThat(firstRowCorners(7)).containsExactly(1f, 1f, 1f, 0f) + assertThat(firstRowCorners(8, 270)).containsExactly(0f, 1f, 0f, 0f) + } +} From 6f67f1916d050ed0ac20cb33ef54f82882c4f1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 29 Oct 2026 18:55:04 +0530 Subject: [PATCH 7/7] Catch draw errors for background images on Android The canvas throws for a bitmap that is too large to draw. Catch the exception around the tile loop, restore the canvas, and log it, like ReactImageView does for . --- .../drawable/BackgroundImageDrawable.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) 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 15408935f0b0..bffad3775234 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 @@ -16,6 +16,8 @@ import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable +import com.facebook.common.logging.FLog +import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.FloatUtil import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType @@ -288,20 +290,27 @@ internal class BackgroundImageDrawable( val urlImageMatrix = urlImage?.drawMatrix(tileWidth, tileHeight) var translateX = initialX var translateY: Float - repeat(xTilesCount) { - translateY = initialY - repeat(yTilesCount) { - canvas.save() - canvas.translate(translateX, translateY) - if (urlImage != null && urlImageMatrix != null) { - canvas.drawBitmap(urlImage.bitmap, urlImageMatrix, backgroundPaint) - } else { - canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) + val saveCount = canvas.saveCount + try { + repeat(xTilesCount) { + translateY = initialY + repeat(yTilesCount) { + canvas.save() + canvas.translate(translateX, translateY) + if (urlImage != null && urlImageMatrix != null) { + canvas.drawBitmap(urlImage.bitmap, urlImageMatrix, backgroundPaint) + } else { + canvas.drawRect(0f, 0f, tileWidth, tileHeight, backgroundPaint) + } + canvas.restore() + translateY += tileHeight + ySpacing } - canvas.restore() - translateY += tileHeight + ySpacing + translateX += tileWidth + xSpacing } - translateX += tileWidth + xSpacing + } catch (e: RuntimeException) { + // Like `ReactImageView`: the canvas throws for a bitmap that is too large to draw. + canvas.restoreToCount(saveCount) + FLog.w(ReactConstants.TAG, e, "Could not draw background image: %s", uri) } } }