From 223ff81c01ba3369fda0e89e9f76f0a56ea66e5d Mon Sep 17 00:00:00 2001 From: Jason Zheng Date: Thu, 22 Oct 2026 17:09:06 -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] Reviewed By: vzaidman 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 e875940c9c8..7707bc26bbb 100644 --- a/packages/rn-tester/js/examples/Pressable/PressableExample.js +++ b/packages/rn-tester/js/examples/Pressable/PressableExample.js @@ -316,6 +316,23 @@ function PressableDisabled() { ); } +function RoundedPressableExample() { + return ( + + + Background Ripple + + + Foreground Ripple + + + ); +} + const styles = StyleSheet.create({ row: { justifyContent: 'center', @@ -401,6 +418,12 @@ const styles = StyleSheet.create({ height: 100, width: 100, }, + roundedPressable: { + backgroundColor: '#eee', + borderRadius: 100, + padding: 16, + marginTop: 10, + }, }); const examples = [ @@ -718,6 +741,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 4c4b18f37e19e1c802b820d680c79d3ed0b6e19b Mon Sep 17 00:00:00 2001 From: Jason Zheng Date: Thu, 22 Oct 2026 17:09:06 -0700 Subject: [PATCH 2/2] Clip ripple masks to match border radius on Android (#58945) 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 | 34 +++++++++++++++++- .../uimanager/drawable/RippleMaskDrawable.kt | 36 +++++++++++++++++++ .../react/views/view/ReactDrawableHelper.kt | 8 ++--- .../react/views/view/ReactViewGroup.kt | 6 +++- 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/RippleMaskDrawable.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 c7d94a770c3..d14749b85ea 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 @@ -33,6 +34,7 @@ import com.facebook.react.uimanager.drawable.MIN_INSET_BOX_SHADOW_SDK_VERSION import com.facebook.react.uimanager.drawable.MIN_OUTSET_BOX_SHADOW_SDK_VERSION import com.facebook.react.uimanager.drawable.OutlineDrawable import com.facebook.react.uimanager.drawable.OutsetBoxShadowDrawable +import com.facebook.react.uimanager.drawable.RippleMaskDrawable import com.facebook.react.uimanager.style.BackgroundImageLayer import com.facebook.react.uimanager.style.BackgroundPosition import com.facebook.react.uimanager.style.BackgroundRepeat @@ -223,6 +225,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 +457,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 +675,16 @@ 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? RippleMaskDrawable + mask?.borderRadius = borderRadius + } + 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/RippleMaskDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/RippleMaskDrawable.kt new file mode 100644 index 00000000000..98e353b267a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/RippleMaskDrawable.kt @@ -0,0 +1,36 @@ +/* + * 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.Color +import android.graphics.PixelFormat +import android.graphics.drawable.DrawableWrapper +import com.facebook.react.uimanager.style.BorderRadiusStyle + +/** Mask for a bounded [android.graphics.drawable.RippleDrawable] that follows the border radius. */ +internal class RippleMaskDrawable private constructor(private val background: BackgroundDrawable) : + DrawableWrapper(background) { + + constructor( + context: Context + ) : this(BackgroundDrawable(context).apply { backgroundColor = Color.WHITE }) + + var borderRadius: BorderRadiusStyle? + get() = background.borderRadius + set(value) { + background.borderRadius = value + background.invalidateSelf() + } + + @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/ReactDrawableHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.kt index b603d3d469e..e7ed82e2620 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,6 @@ 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.drawable.Drawable import android.graphics.drawable.RippleDrawable import android.util.TypedValue @@ -24,6 +23,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.RippleMaskDrawable import kotlin.math.roundToInt /** @@ -92,7 +92,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,13 +149,13 @@ 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 } 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 cccf2b546b7..e7331986394 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 @@ -697,7 +698,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) {