Skip to content

Commit e08abbc

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Fix jagged edge rendering on API 28 (#54525)
Summary: Pull Request resolved: #54525 Older versions of Android's clipPath APIs do not properly support antialiasing and BackgroundStyleApplicator relies on this API to clip to border radii that are smaller than provided images or views. As a result, this often leaves jagged edges on the outer edges of these images. Android even calls this out in their documentation under the "Canvas.clipPath" section of https://developer.android.com/topic/performance/vitals/render#common-jank. This change introduces a new API in BackgroundStyleApplicator called `clipToPaddingBoxWithAntiAliasing` which takes an additional argument over the existing `clipToPaddingBox`: a draw call. If non-null and the Android API is 28 or lower, a new drawPath operation is used to mark the pixels outside of the drawn contents as transparent. Changelog: [Android][Fixed] - Fixed antialiasing issues from border radius usage in API <= 28 Reviewed By: haixia-meta, lenaic Differential Revision: D86263195 fbshipit-source-id: 5dba744da08a3c9297f921baf7d4f1a354404f74
1 parent d9842fd commit e08abbc

23 files changed

+308
-105
lines changed

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3228,6 +3228,7 @@ public abstract interface class com/facebook/react/turbomodule/core/interfaces/T
32283228
public final class com/facebook/react/uimanager/BackgroundStyleApplicator {
32293229
public static final field INSTANCE Lcom/facebook/react/uimanager/BackgroundStyleApplicator;
32303230
public static final fun clipToPaddingBox (Landroid/view/View;Landroid/graphics/Canvas;)V
3231+
public static final fun clipToPaddingBoxWithAntiAliasing (Landroid/view/View;Landroid/graphics/Canvas;Lkotlin/jvm/functions/Function0;)V
32313232
public static final fun getBackgroundColor (Landroid/view/View;)Ljava/lang/Integer;
32323233
public static final fun getBorderColor (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;)Ljava/lang/Integer;
32333234
public static final fun getBorderRadius (Landroid/view/View;Lcom/facebook/react/uimanager/style/BorderRadiusProp;)Lcom/facebook/react/uimanager/LengthPercentage;

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<10999fe360a25451c316eed2d27b6d92>>
7+
* @generated SignedSource<<b1469e448ca6f773a3095ec2cbf4bc00>>
88
*/
99

1010
/**
@@ -114,6 +114,12 @@ public object ReactNativeFeatureFlags {
114114
@JvmStatic
115115
public fun enableAccumulatedUpdatesInRawPropsAndroid(): Boolean = accessor.enableAccumulatedUpdatesInRawPropsAndroid()
116116

117+
/**
118+
* Enable antialiased border radius clipping for Android API 28 and below using manual masking with Porter-Duff compositing
119+
*/
120+
@JvmStatic
121+
public fun enableAndroidAntialiasedBorderRadiusClipping(): Boolean = accessor.enableAndroidAntialiasedBorderRadiusClipping()
122+
117123
/**
118124
* Enables linear text rendering on Android wherever subpixel text rendering is enabled
119125
*/

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<b935cd2546fdba877e317aea30fceaf9>>
7+
* @generated SignedSource<<b2c2e874b05283e0ebd62899f7c417d8>>
88
*/
99

1010
/**
@@ -34,6 +34,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
3434
private var disableViewPreallocationAndroidCache: Boolean? = null
3535
private var enableAccessibilityOrderCache: Boolean? = null
3636
private var enableAccumulatedUpdatesInRawPropsAndroidCache: Boolean? = null
37+
private var enableAndroidAntialiasedBorderRadiusClippingCache: Boolean? = null
3738
private var enableAndroidLinearTextCache: Boolean? = null
3839
private var enableAndroidTextMeasurementOptimizationsCache: Boolean? = null
3940
private var enableBridgelessArchitectureCache: Boolean? = null
@@ -235,6 +236,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
235236
return cached
236237
}
237238

239+
override fun enableAndroidAntialiasedBorderRadiusClipping(): Boolean {
240+
var cached = enableAndroidAntialiasedBorderRadiusClippingCache
241+
if (cached == null) {
242+
cached = ReactNativeFeatureFlagsCxxInterop.enableAndroidAntialiasedBorderRadiusClipping()
243+
enableAndroidAntialiasedBorderRadiusClippingCache = cached
244+
}
245+
return cached
246+
}
247+
238248
override fun enableAndroidLinearText(): Boolean {
239249
var cached = enableAndroidLinearTextCache
240250
if (cached == null) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<b7a9d14c50bae9afa15b3ead8308fc9b>>
7+
* @generated SignedSource<<ccb22ddcd1a76b7c52cf0f1b23e6152b>>
88
*/
99

1010
/**
@@ -56,6 +56,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
5656

5757
@DoNotStrip @JvmStatic public external fun enableAccumulatedUpdatesInRawPropsAndroid(): Boolean
5858

59+
@DoNotStrip @JvmStatic public external fun enableAndroidAntialiasedBorderRadiusClipping(): Boolean
60+
5961
@DoNotStrip @JvmStatic public external fun enableAndroidLinearText(): Boolean
6062

6163
@DoNotStrip @JvmStatic public external fun enableAndroidTextMeasurementOptimizations(): Boolean

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<4b24bc3165b6ab1583efc8e1a22444ed>>
7+
* @generated SignedSource<<30ca2685ceb6f2733531f5e7fce4416d>>
88
*/
99

1010
/**
@@ -51,6 +51,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
5151

5252
override fun enableAccumulatedUpdatesInRawPropsAndroid(): Boolean = false
5353

54+
override fun enableAndroidAntialiasedBorderRadiusClipping(): Boolean = false
55+
5456
override fun enableAndroidLinearText(): Boolean = false
5557

5658
override fun enableAndroidTextMeasurementOptimizations(): Boolean = false

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<bd86cec4dcf659b9586aeee1c141963c>>
7+
* @generated SignedSource<<6d1a15e64f42cc7d8869300720276215>>
88
*/
99

1010
/**
@@ -38,6 +38,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
3838
private var disableViewPreallocationAndroidCache: Boolean? = null
3939
private var enableAccessibilityOrderCache: Boolean? = null
4040
private var enableAccumulatedUpdatesInRawPropsAndroidCache: Boolean? = null
41+
private var enableAndroidAntialiasedBorderRadiusClippingCache: Boolean? = null
4142
private var enableAndroidLinearTextCache: Boolean? = null
4243
private var enableAndroidTextMeasurementOptimizationsCache: Boolean? = null
4344
private var enableBridgelessArchitectureCache: Boolean? = null
@@ -253,6 +254,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
253254
return cached
254255
}
255256

257+
override fun enableAndroidAntialiasedBorderRadiusClipping(): Boolean {
258+
var cached = enableAndroidAntialiasedBorderRadiusClippingCache
259+
if (cached == null) {
260+
cached = currentProvider.enableAndroidAntialiasedBorderRadiusClipping()
261+
accessedFeatureFlags.add("enableAndroidAntialiasedBorderRadiusClipping")
262+
enableAndroidAntialiasedBorderRadiusClippingCache = cached
263+
}
264+
return cached
265+
}
266+
256267
override fun enableAndroidLinearText(): Boolean {
257268
var cached = enableAndroidLinearTextCache
258269
if (cached == null) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<90fd5f8d9c5b6833c6fdd10167577bb9>>
7+
* @generated SignedSource<<48d0d5486793b60914cfd595f0fc78d1>>
88
*/
99

1010
/**
@@ -51,6 +51,8 @@ public interface ReactNativeFeatureFlagsProvider {
5151

5252
@DoNotStrip public fun enableAccumulatedUpdatesInRawPropsAndroid(): Boolean
5353

54+
@DoNotStrip public fun enableAndroidAntialiasedBorderRadiusClipping(): Boolean
55+
5456
@DoNotStrip public fun enableAndroidLinearText(): Boolean
5557

5658
@DoNotStrip public fun enableAndroidTextMeasurementOptimizations(): Boolean

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ package com.facebook.react.uimanager
99

1010
import android.graphics.Canvas
1111
import android.graphics.Color
12+
import android.graphics.Paint
1213
import android.graphics.Path
14+
import android.graphics.PorterDuff
15+
import android.graphics.PorterDuffXfermode
1316
import android.graphics.Rect
1417
import android.graphics.RectF
1518
import android.graphics.drawable.Drawable
@@ -19,6 +22,7 @@ import android.widget.ImageView
1922
import androidx.annotation.ColorInt
2023
import com.facebook.react.bridge.ReadableArray
2124
import com.facebook.react.common.annotations.UnstableReactNativeAPI
25+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
2226
import com.facebook.react.uimanager.PixelUtil.dpToPx
2327
import com.facebook.react.uimanager.PixelUtil.pxToDp
2428
import com.facebook.react.uimanager.common.UIManagerType
@@ -481,12 +485,36 @@ public object BackgroundStyleApplicator {
481485
*/
482486
@JvmStatic
483487
public fun clipToPaddingBox(view: View, canvas: Canvas) {
488+
clipToPaddingBoxWithAntiAliasing(view, canvas, null)
489+
}
490+
491+
/**
492+
* Clips the canvas to the padding box of the view.
493+
*
494+
* The padding box is the area within the borders of the view, accounting for border radius if
495+
* present.
496+
*
497+
* On Android 28 and below, when border radius is present, this uses an antialiased clipping
498+
* approach with Porter-Duff compositing to avoid jagged edges. The drawContent lambda is invoked
499+
* to draw the actual content after setting up the layer but before applying the mask.
500+
*
501+
* @param view The view whose padding box defines the clipping region
502+
* @param canvas The canvas to clip
503+
* @param drawContent Lambda that draws the content after clipping is set up
504+
*/
505+
@JvmStatic
506+
public fun clipToPaddingBoxWithAntiAliasing(
507+
view: View,
508+
canvas: Canvas,
509+
drawContent: (() -> Unit)?,
510+
) {
484511
val drawingRect = Rect()
485512
view.getDrawingRect(drawingRect)
486513

487514
val composite = getCompositeBackgroundDrawable(view)
488515
if (composite == null) {
489516
canvas.clipRect(drawingRect)
517+
drawContent?.invoke()
490518
return
491519
}
492520

@@ -508,15 +536,69 @@ public object BackgroundStyleApplicator {
508536
paddingBoxRect,
509537
computedBorderInsets,
510538
)
511-
512539
paddingBoxPath.offset(drawingRect.left.toFloat(), drawingRect.top.toFloat())
513-
canvas.clipPath(paddingBoxPath)
540+
541+
// On Android 28 and below, use antialiased clipping with Porter-Duff compositing. On newer
542+
// Android versions, use the standard clipPath.
543+
if (
544+
ReactNativeFeatureFlags.enableAndroidAntialiasedBorderRadiusClipping() &&
545+
Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
546+
view.width > 0 &&
547+
view.height > 0 &&
548+
drawContent != null
549+
) {
550+
clipWithAntiAliasing(
551+
view,
552+
canvas,
553+
paddingBoxPath,
554+
drawContent,
555+
)
556+
} else {
557+
canvas.clipPath(paddingBoxPath)
558+
drawContent?.invoke()
559+
}
514560
} else {
515561
paddingBoxRect.offset(drawingRect.left.toFloat(), drawingRect.top.toFloat())
516562
canvas.clipRect(paddingBoxRect)
563+
drawContent?.invoke()
517564
}
518565
}
519566

567+
/**
568+
* Applies antialiased clipping using Porter-Duff compositing for Android 28 and below. This draws
569+
* content to a layer, then applies an antialiased mask to clip it.
570+
*/
571+
private fun clipWithAntiAliasing(
572+
view: View,
573+
canvas: Canvas,
574+
paddingBoxPath: Path,
575+
drawContent: () -> Unit,
576+
) {
577+
// Save the layer for Porter-Duff compositing
578+
val saveCount = canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), null)
579+
580+
// Draw the content first
581+
drawContent()
582+
583+
// Create the antialiased mask path with Porter-Duff DST_IN to clip
584+
val maskPaint = Paint(Paint.ANTI_ALIAS_FLAG)
585+
maskPaint.style = Paint.Style.FILL
586+
maskPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
587+
588+
// Transparent pixels with INVERSE_WINDING only works on API 28
589+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
590+
maskPaint.color = Color.TRANSPARENT
591+
paddingBoxPath.setFillType(Path.FillType.INVERSE_WINDING)
592+
} else {
593+
maskPaint.color = Color.BLACK
594+
}
595+
596+
canvas.drawPath(paddingBoxPath, maskPaint)
597+
598+
// Restore the layer
599+
canvas.restoreToCount(saveCount)
600+
}
601+
520602
/**
521603
* Resets the background styling of the view to its original state.
522604
*

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -372,15 +372,18 @@ public class ReactImageView(
372372
public override fun hasOverlappingRendering(): Boolean = false
373373

374374
public override fun onDraw(canvas: Canvas) {
375-
BackgroundStyleApplicator.clipToPaddingBox(this, canvas)
376-
try {
377-
super.onDraw(canvas)
378-
} catch (e: RuntimeException) {
379-
// Only provide updates if downloadListener is set (shouldNotify is true)
380-
if (downloadListener != null) {
381-
val eventDispatcher =
382-
UIManagerHelper.getEventDispatcherForReactTag(context as ReactContext, id)
383-
eventDispatcher?.dispatchEvent(createErrorEvent(UIManagerHelper.getSurfaceId(this), id, e))
375+
BackgroundStyleApplicator.clipToPaddingBoxWithAntiAliasing(this, canvas) {
376+
try {
377+
super.onDraw(canvas)
378+
} catch (e: RuntimeException) {
379+
// Only provide updates if downloadListener is set (shouldNotify is true)
380+
if (downloadListener != null) {
381+
val eventDispatcher =
382+
UIManagerHelper.getEventDispatcherForReactTag(context as ReactContext, id)
383+
eventDispatcher?.dispatchEvent(
384+
createErrorEvent(UIManagerHelper.getSurfaceId(this), id, e)
385+
)
386+
}
384387
}
385388
}
386389
}

packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<a0ef5d4a761067631023f6b5ec797cc6>>
7+
* @generated SignedSource<<5e4e22e976ce1724191c7b7e381ea5a1>>
88
*/
99

1010
/**
@@ -123,6 +123,12 @@ class ReactNativeFeatureFlagsJavaProvider
123123
return method(javaProvider_);
124124
}
125125

126+
bool enableAndroidAntialiasedBorderRadiusClipping() override {
127+
static const auto method =
128+
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("enableAndroidAntialiasedBorderRadiusClipping");
129+
return method(javaProvider_);
130+
}
131+
126132
bool enableAndroidLinearText() override {
127133
static const auto method =
128134
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("enableAndroidLinearText");
@@ -641,6 +647,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableAccumulatedUpdatesInRawPropsAndro
641647
return ReactNativeFeatureFlags::enableAccumulatedUpdatesInRawPropsAndroid();
642648
}
643649

650+
bool JReactNativeFeatureFlagsCxxInterop::enableAndroidAntialiasedBorderRadiusClipping(
651+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
652+
return ReactNativeFeatureFlags::enableAndroidAntialiasedBorderRadiusClipping();
653+
}
654+
644655
bool JReactNativeFeatureFlagsCxxInterop::enableAndroidLinearText(
645656
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
646657
return ReactNativeFeatureFlags::enableAndroidLinearText();
@@ -1084,6 +1095,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
10841095
makeNativeMethod(
10851096
"enableAccumulatedUpdatesInRawPropsAndroid",
10861097
JReactNativeFeatureFlagsCxxInterop::enableAccumulatedUpdatesInRawPropsAndroid),
1098+
makeNativeMethod(
1099+
"enableAndroidAntialiasedBorderRadiusClipping",
1100+
JReactNativeFeatureFlagsCxxInterop::enableAndroidAntialiasedBorderRadiusClipping),
10871101
makeNativeMethod(
10881102
"enableAndroidLinearText",
10891103
JReactNativeFeatureFlagsCxxInterop::enableAndroidLinearText),

0 commit comments

Comments
 (0)