From f09ed4694a514b9f744bc861c2ce1e8b2a976f89 Mon Sep 17 00:00:00 2001 From: Jason Zheng Date: Thu, 8 Oct 2026 03:14:15 -0700 Subject: [PATCH 1/2] Add rounded Pressable example in PressableExample (#58938) Summary: Add a new RNTester example in PressableExample that reproduces the issue where ripple effects don't clip to the Pressable's border radius. Changelog: [Internal] Differential Revision: D123816852 --- .../js/examples/Pressable/PressableExample.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/rn-tester/js/examples/Pressable/PressableExample.js b/packages/rn-tester/js/examples/Pressable/PressableExample.js index fc7ba7bb8005..46867959fd8c 100644 --- a/packages/rn-tester/js/examples/Pressable/PressableExample.js +++ b/packages/rn-tester/js/examples/Pressable/PressableExample.js @@ -312,6 +312,23 @@ function PressableDisabled() { ); } +function RoundedPressableExample() { + return ( + + + Background Ripple + + + Foreground Ripple + + + ); +} + const styles = StyleSheet.create({ row: { justifyContent: 'center', @@ -397,6 +414,12 @@ const styles = StyleSheet.create({ height: 100, width: 100, }, + roundedPressable: { + backgroundColor: '#eee', + borderRadius: 100, + padding: 16, + marginTop: 10, + }, }); const examples = [ @@ -714,6 +737,15 @@ const examples = [ ); }, }, + { + title: 'Rounded Pressable with Ripple', + description: + 'Ripple should clip to the border radius of the Pressable' as string, + platform: 'android', + render: function (): React.Node { + return ; + }, + }, ...PressableExampleFbInternal.examples, ]; From 01dd796d784d38e497fef436a5768fe8c0dedd1e Mon Sep 17 00:00:00 2001 From: Jason Zheng Date: Thu, 8 Oct 2026 03:14:15 -0700 Subject: [PATCH 2/2] Clip ripple masks to match border radius on Android Summary: Fixes [#34553](https://github.com/react/react-native/issues/34553), where the ripple effect is a square even if the underlying Pressable is rounded. ### Root Cause For bounded ripples, ReactDrawableHelper creates a RippleDrawable with a ColorDrawable mask. This mask is rectangular and remains that way when added to CompositeBackgroundDrawable as a feedback layer, even if a border radius is configured. ### This PR Modify ReactDrawableHelper to replace the RippleDrawable's rectangular ColorDrawable mask with a BackgroundDrawable. If border radius is set, `getOpacity()` returns `PixelFormat.TRANSLUCENT` which makes RippleDrawable draw the mask into an alpha-only bitmap so the ripple respects the border radius. Otherwise, it returns `PixelFormat.OPAQUE` to avoid the cost of creating the bitmap and match the original ColorDrawable behavior. Modify BackgroundStyleApplicator to update the ripple mask's border radius during `setFeedbackUnderlay()`, `setFeedbackOverlay()`, and `setBorderRadius()`. Also make `ReactViewGroup.applyNativeForeground()` call `setFeedbackOverlay()` so that foreground ripples are properly clipped. Changelog: [Android][Fixed] - Fix Pressable ripple leaking past rounded corners Differential Revision: D123816883 --- .../uimanager/BackgroundStyleApplicator.kt | 36 ++++++++++++++++++- .../uimanager/drawable/BackgroundDrawable.kt | 2 +- .../react/views/view/ReactDrawableHelper.kt | 21 ++++++++--- .../react/views/view/ReactViewGroup.kt | 6 +++- 4 files changed, 58 insertions(+), 7 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 c7d94a770c39..2ac90a6f304b 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 @@ -16,6 +16,7 @@ import android.graphics.PorterDuffXfermode import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable import android.os.Build import android.view.View import android.widget.ImageView @@ -223,6 +224,13 @@ public object BackgroundStyleApplicator { compositeBackgroundDrawable.backgroundImage?.invalidateSelf() compositeBackgroundDrawable.border?.invalidateSelf() + // Update feedback underlay and overlay + updateRippleMaskBorderRadius( + compositeBackgroundDrawable.feedbackUnderlay, + compositeBackgroundDrawable.borderRadius, + ) + updateRippleMaskBorderRadius(view.foreground, compositeBackgroundDrawable.borderRadius) + if (Build.VERSION.SDK_INT >= MIN_OUTSET_BOX_SHADOW_SDK_VERSION) { for (shadow in compositeBackgroundDrawable.outerShadows.filterIsInstance()) { @@ -448,7 +456,20 @@ public object BackgroundStyleApplicator { */ @JvmStatic public fun setFeedbackUnderlay(view: View, drawable: Drawable?) { - view.background = ensureCompositeBackgroundDrawable(view).withNewFeedbackUnderlay(drawable) + val compositeBackgroundDrawable = ensureCompositeBackgroundDrawable(view) + updateRippleMaskBorderRadius(drawable, compositeBackgroundDrawable.borderRadius) + view.background = compositeBackgroundDrawable.withNewFeedbackUnderlay(drawable) + } + + /** + * Sets a feedback overlay drawable for the view. + * + * @param view The view to apply the feedback overlay to + * @param drawable The drawable to use as feedback overlay, or null to remove + */ + internal fun setFeedbackOverlay(view: View, drawable: Drawable?) { + updateRippleMaskBorderRadius(drawable, getCompositeBackgroundDrawable(view)?.borderRadius) + view.foreground = drawable } /** @@ -653,6 +674,19 @@ public object BackgroundStyleApplicator { private fun getCompositeBackgroundDrawable(view: View): CompositeBackgroundDrawable? = view.background as? CompositeBackgroundDrawable + private fun updateRippleMaskBorderRadius( + drawable: Drawable?, + borderRadius: BorderRadiusStyle?, + ) { + val mask = + (drawable as? RippleDrawable)?.findDrawableByLayerId(android.R.id.mask) + as? BackgroundDrawable + if (mask != null) { + mask.borderRadius = borderRadius + mask.invalidateSelf() + } + } + private fun ensureBackgroundDrawable(view: View): BackgroundDrawable { val compositeBackgroundDrawable = ensureCompositeBackgroundDrawable(view) var background = compositeBackgroundDrawable.background diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt index d5a2c2d342aa..eaa20b05b563 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt @@ -24,7 +24,7 @@ import com.facebook.react.uimanager.style.BorderRadiusStyle import com.facebook.react.uimanager.style.ComputedBorderRadius import kotlin.math.roundToInt -internal class BackgroundDrawable( +internal open class BackgroundDrawable( private val context: Context, /* * We assume borderRadius & borderInsets to be shared across multiple drawables diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.kt index b603d3d469e9..8e1710c12366 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.kt @@ -11,7 +11,7 @@ import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.drawable.ColorDrawable +import android.graphics.PixelFormat import android.graphics.drawable.Drawable import android.graphics.drawable.RippleDrawable import android.util.TypedValue @@ -24,6 +24,7 @@ import com.facebook.react.bridge.ReadableType import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.ViewProps +import com.facebook.react.uimanager.drawable.BackgroundDrawable import kotlin.math.roundToInt /** @@ -92,7 +93,7 @@ public object ReactDrawableHelper { color = Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color)) } - val mask = getMask(drawableDescriptionDict) + val mask = getMask(context, drawableDescriptionDict) return RippleDrawable(ColorStateList(arrayOf(intArrayOf()), intArrayOf(color)), null, mask) } @@ -149,14 +150,26 @@ public object ReactDrawableHelper { ) } - private fun getMask(drawableDescriptionDict: ReadableMap): Drawable? { + private fun getMask(context: Context, drawableDescriptionDict: ReadableMap): Drawable? { if ( !drawableDescriptionDict.hasKey("borderless") || drawableDescriptionDict.isNull("borderless") || !drawableDescriptionDict.getBoolean("borderless") ) { - return ColorDrawable(Color.WHITE) + return RippleMaskDrawable(context) } return null } } + +private class RippleMaskDrawable(context: Context) : BackgroundDrawable(context) { + init { + backgroundColor = Color.WHITE + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = + // RippleDrawable clips to an opaque mask's bounds, but renders a translucent mask into a + // bitmap to clip to its shape. Only pay for the bitmap when there are corners to round. + if (borderRadius?.hasRoundedBorders() == true) PixelFormat.TRANSLUCENT else PixelFormat.OPAQUE +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 363f1336d000..0330ed628097 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -45,6 +45,7 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderColor import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderRadius import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderStyle import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth +import com.facebook.react.uimanager.BackgroundStyleApplicator.setFeedbackOverlay import com.facebook.react.uimanager.BackgroundStyleApplicator.setFeedbackUnderlay import com.facebook.react.uimanager.BlendModeHelper.needsIsolatedLayer import com.facebook.react.uimanager.HasElevatedDescendantCache @@ -696,7 +697,10 @@ public open class ReactViewGroup public constructor(context: Context?) : internal fun applyNativeForeground(map: ReadableMap?) { nativeForegroundMap = map - foreground = map?.let { ReactDrawableHelper.createDrawableFromJSDescription(context, it) } + setFeedbackOverlay( + this, + map?.let { ReactDrawableHelper.createDrawableFromJSDescription(context, it) }, + ) } override fun onViewAdded(child: View) {