From 3e306f695ec0763a09bfc1e3899aba7c3e0dd445 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 26 Feb 2026 09:53:04 -0800 Subject: [PATCH] Fix bug where offscreen layers would erroneously draw black pixels (#55762) Summary: On API <= 28, `clipWithAntiAliasing` uses `saveLayer` for anti-aliased border radius clipping. When a view is partially off-screen, the GPU only renders into the visible portion of the `saveLayer` buffer, leaving off-screen pixels as opaque black. These black pixels survive Porter-Duff compositing and appear as visible artifacts. Adding `canvas.clipRect(0, 0, view.width, view.height)` before `saveLayer` forces HWUI to properly initialize the offscreen buffer relative to the GPU scissor. The clip is in local coordinates, so it stays correct across parent transform animations and scrolling offsets. Changelog: [Internal] Reviewed By: NickGerleman Differential Revision: D94447724 --- .../uimanager/BackgroundStyleApplicator.kt | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) 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 a0862a961902..c3ce6a458757 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 @@ -20,6 +20,7 @@ import android.os.Build import android.view.View import android.widget.ImageView import androidx.annotation.ColorInt +import androidx.core.graphics.withClip import com.facebook.react.bridge.ReadableArray import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags @@ -574,41 +575,49 @@ public object BackgroundStyleApplicator { paddingBoxPath: Path, drawContent: () -> Unit, ) { - // Save the layer for Porter-Duff compositing - val saveCount = canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), null) - - // Draw the content first - drawContent() - - val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG) - maskPaint.style = Paint.Style.FILL + // Clip to the view's own bounds before saveLayer. On API <= 28 hardware-accelerated canvases, + // the window boundary is tracked by the GPU scissor but not reflected in the canvas clip stack. + // Without an explicit software clip, saveLayer may allocate a buffer with uninitialized pixels + // beyond the GPU scissor. Adding clipRect in the view's local coordinate space forces HWUI to + // include it in the clip stack, ensuring saveLayer properly constrains its buffer. This clip is + // stable across parent transform animations since it's in the view's own coordinate space. + canvas.withClip(0, 0, view.width, view.height) { + // Save the layer for Porter-Duff compositing + val saveCount = canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), null) + + // Draw the content first + drawContent() + + val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG) + maskPaint.style = Paint.Style.FILL + + // Transparent pixels with INVERSE_WINDING only works on API 28 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN) + maskPaint.color = Color.TRANSPARENT + paddingBoxPath.setFillType(Path.FillType.INVERSE_WINDING) + canvas.drawPath(paddingBoxPath, maskPaint) + } else { + // API < 28: Use a nested saveLayer with DST_IN compositing to mask content to the + // padding box path. EVEN_ODD fill + DST_OUT has rendering bugs on API 24's hardware + // renderer, so we avoid that technique. Instead, draw the mask shape into a separate + // layer; when restored with DST_IN, content is preserved only where the mask is opaque. + val dstInPaint = Paint() + dstInPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN) + val maskSave = + canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), dstInPaint) + // Clear the layer to ensure it starts fully transparent. On API 24, saveLayer may not + // initialize the buffer to transparent, causing DST_IN to see non-zero alpha everywhere. + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + maskPaint.xfermode = null + maskPaint.color = Color.BLACK + canvas.drawPath(paddingBoxPath, maskPaint) + canvas.restoreToCount(maskSave) + } - // Transparent pixels with INVERSE_WINDING only works on API 28 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN) - maskPaint.color = Color.TRANSPARENT - paddingBoxPath.setFillType(Path.FillType.INVERSE_WINDING) - canvas.drawPath(paddingBoxPath, maskPaint) - } else { - // API < 28: Use a nested saveLayer with DST_IN compositing to mask content to the - // padding box path. EVEN_ODD fill + DST_OUT has rendering bugs on API 24's hardware - // renderer, so we avoid that technique. Instead, draw the mask shape into a separate - // layer; when restored with DST_IN, content is preserved only where the mask is opaque. - val dstInPaint = Paint() - dstInPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN) - val maskSave = - canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), dstInPaint) - // Clear the layer to ensure it starts fully transparent. On API 24, saveLayer may not - // initialize the buffer to transparent, causing DST_IN to see non-zero alpha everywhere. - canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) - maskPaint.xfermode = null - maskPaint.color = Color.BLACK - canvas.drawPath(paddingBoxPath, maskPaint) - canvas.restoreToCount(maskSave) + // Restore the layer + canvas.restoreToCount(saveCount) } - - // Restore the layer - canvas.restoreToCount(saveCount) } /**