Skip to content

Commit 479266c

Browse files
jasozhmeta-codesync[bot]
authored andcommitted
Fix borderless ripple effect on Android (#58902)
Summary: Pull Request resolved: #58902 Fixes the issue described in [#48552](#48552), where a Pressable component can lose both its background color and ripple effect when borderless is true. ### Root Cause Android sets `isProjected() = true` for a borderless RippleDrawable so the ripple can extend beyond the view's bounds. React Native adds the background and ripple as layers in a CompositeBackgroundDrawable, where `isProjected() = true` if any layer is projected. With projection, both the background and ripple are drawn on the nearest native ancestor with a background. Given "root view -> parent view -> Pressable", the ripple and background should now be drawn on the parent. However, since the parent doesn't form a stacking context, Fabric flattens its children. The Pressable and parent become siblings, and the Pressable's background and ripple are now drawn on a further ancestor and consequently become hidden. Workarounds like opacity, `overflow: hidden`, and `collapsable={false}` work by establishing a stacking context so the parent remains the Pressable's ancestor. ### This PR Modify CompositeBackgroundDrawable to wrap `feedbackUnderlay` in a DrawableWrapper where `isProjected() = false`. The background and ripple are drawn in-place, so they remain visible without a stacking context. The ripple still extends beyond the view's bounds since `clipChildren = false` in React Native. This is gated behind the `fixBorderlessRippleAndroid` flag which defaults to false. ### Limitations With projection, the background and borderless ripple are drawn underneath all siblings. Without projection, they are now drawn over previous siblings and underneath following siblings. This matches behavior already shown by React Native foreground ripples, and Jetpack Compose also disables projection in `RippleHostView.UnprojectedRipple` and exhibits the same behavior. Disabling ripple projection also causes some behavioral changes with clipping. The ripple is no longer clipped to the outline of the ancestor it was projected on, and it can now be clipped by `overflow: hidden` or ScrollView ancestors it used to draw beyond. These changes also align with foreground ripples and Jetpack Compose. One alternative which more closely preserves projected behavior is to modify Fabric's core renderer logic to maintain a parent stacking context if a borderless ripple is present. However, this can potentially change stacking behavior in non-obvious ways depending on where a ripple is defined, where the nearest ancestor with a background exists, and siblings in between. This impacts zIndex behavior and makes Android's stacking diverge from iOS, where ripples don't exist. Changelog: [Android][Fixed] - Fix Pressable and TouchableNativeFeedback losing their background color and ripple when using a borderless ripple Reviewed By: cortinico Differential Revision: D123471013
1 parent 0605e8e commit 479266c

23 files changed

Lines changed: 204 additions & 56 deletions

‎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<<81b1343c027b377be3bdea9167e16a5d>>
7+
* @generated SignedSource<<8ea15f9bb12ee59c1ed54881b7ab2799>>
88
*/
99

1010
/**
@@ -372,6 +372,12 @@ public object ReactNativeFeatureFlags {
372372
@JvmStatic
373373
public fun enableVirtualViewContainerStateExperimental(): Boolean = accessor.enableVirtualViewContainerStateExperimental()
374374

375+
/**
376+
* Prevents a borderless ripple feedback underlay from projecting the entire view background on Android, which could hide the background and ripple.
377+
*/
378+
@JvmStatic
379+
public fun fixBorderlessRippleAndroid(): Boolean = accessor.fixBorderlessRippleAndroid()
380+
375381
/**
376382
* Uses the default event priority instead of the discreet event priority by default when dispatching events from Fabric to React.
377383
*/

‎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<<3e939eebe846d12cab15905db8efba18>>
7+
* @generated SignedSource<<423f851be2b501d3fb7211859c731e74>>
88
*/
99

1010
/**
@@ -77,6 +77,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
7777
private var enableViewRecyclingForTextCache: Boolean? = null
7878
private var enableViewRecyclingForViewCache: Boolean? = null
7979
private var enableVirtualViewContainerStateExperimentalCache: Boolean? = null
80+
private var fixBorderlessRippleAndroidCache: Boolean? = null
8081
private var fixMappingOfEventPrioritiesBetweenFabricAndReactCache: Boolean? = null
8182
private var fixYogaFlexBasisFitContentInMainAxisCache: Boolean? = null
8283
private var fuseboxAssertSingleHostStateCache: Boolean? = null
@@ -624,6 +625,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
624625
return cached
625626
}
626627

628+
override fun fixBorderlessRippleAndroid(): Boolean {
629+
var cached = fixBorderlessRippleAndroidCache
630+
if (cached == null) {
631+
cached = ReactNativeFeatureFlagsCxxInterop.fixBorderlessRippleAndroid()
632+
fixBorderlessRippleAndroidCache = cached
633+
}
634+
return cached
635+
}
636+
627637
override fun fixMappingOfEventPrioritiesBetweenFabricAndReact(): Boolean {
628638
var cached = fixMappingOfEventPrioritiesBetweenFabricAndReactCache
629639
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<<da34d40665437b2901de2ca5f0bee7c1>>
7+
* @generated SignedSource<<15959c4c8592c5132ebcf822f776f2e8>>
88
*/
99

1010
/**
@@ -142,6 +142,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
142142

143143
@DoNotStrip @JvmStatic public external fun enableVirtualViewContainerStateExperimental(): Boolean
144144

145+
@DoNotStrip @JvmStatic public external fun fixBorderlessRippleAndroid(): Boolean
146+
145147
@DoNotStrip @JvmStatic public external fun fixMappingOfEventPrioritiesBetweenFabricAndReact(): Boolean
146148

147149
@DoNotStrip @JvmStatic public external fun fixYogaFlexBasisFitContentInMainAxis(): 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<<0f4c074c11c564f05950eab73a8fd452>>
7+
* @generated SignedSource<<29635538bf32dd2455977f40eb9b1ee4>>
88
*/
99

1010
/**
@@ -137,6 +137,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
137137

138138
override fun enableVirtualViewContainerStateExperimental(): Boolean = false
139139

140+
override fun fixBorderlessRippleAndroid(): Boolean = false
141+
140142
override fun fixMappingOfEventPrioritiesBetweenFabricAndReact(): Boolean = false
141143

142144
override fun fixYogaFlexBasisFitContentInMainAxis(): 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<<013790bd2c3fa8b9ff03e8c5e924bf91>>
7+
* @generated SignedSource<<2730b5fcf6901e962f6e2efb9ebe30da>>
88
*/
99

1010
/**
@@ -81,6 +81,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
8181
private var enableViewRecyclingForTextCache: Boolean? = null
8282
private var enableViewRecyclingForViewCache: Boolean? = null
8383
private var enableVirtualViewContainerStateExperimentalCache: Boolean? = null
84+
private var fixBorderlessRippleAndroidCache: Boolean? = null
8485
private var fixMappingOfEventPrioritiesBetweenFabricAndReactCache: Boolean? = null
8586
private var fixYogaFlexBasisFitContentInMainAxisCache: Boolean? = null
8687
private var fuseboxAssertSingleHostStateCache: Boolean? = null
@@ -685,6 +686,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
685686
return cached
686687
}
687688

689+
override fun fixBorderlessRippleAndroid(): Boolean {
690+
var cached = fixBorderlessRippleAndroidCache
691+
if (cached == null) {
692+
cached = currentProvider.fixBorderlessRippleAndroid()
693+
accessedFeatureFlags.add("fixBorderlessRippleAndroid")
694+
fixBorderlessRippleAndroidCache = cached
695+
}
696+
return cached
697+
}
698+
688699
override fun fixMappingOfEventPrioritiesBetweenFabricAndReact(): Boolean {
689700
var cached = fixMappingOfEventPrioritiesBetweenFabricAndReactCache
690701
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.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<<843b2fa7e358dd73c9db083e42b93be6>>
7+
* @generated SignedSource<<c82895ef7e5cee6ba387620a43aa88b8>>
88
*/
99

1010
/**
@@ -33,6 +33,8 @@ public open class ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android : ReactN
3333

3434
override fun enableSwiftUIBasedFilters(): Boolean = true
3535

36+
override fun fixBorderlessRippleAndroid(): Boolean = true
37+
3638
override fun fuseboxFrameRecordingEnabled(): Boolean = true
3739

3840
override fun fuseboxWebSocketEventsEnabled(): Boolean = true

‎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<<6607c8678df2693c361f6d8091483662>>
7+
* @generated SignedSource<<361dd8199f01e13cca4b5508371fcc76>>
88
*/
99

1010
/**
@@ -137,6 +137,8 @@ public interface ReactNativeFeatureFlagsProvider {
137137

138138
@DoNotStrip public fun enableVirtualViewContainerStateExperimental(): Boolean
139139

140+
@DoNotStrip public fun fixBorderlessRippleAndroid(): Boolean
141+
140142
@DoNotStrip public fun fixMappingOfEventPrioritiesBetweenFabricAndReact(): Boolean
141143

142144
@DoNotStrip public fun fixYogaFlexBasisFitContentInMainAxis(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ package com.facebook.react.uimanager.drawable
1010
import android.content.Context
1111
import android.graphics.Outline
1212
import android.graphics.Path
13+
import android.graphics.Rect
1314
import android.graphics.RectF
1415
import android.graphics.drawable.Drawable
16+
import android.graphics.drawable.DrawableWrapper
1517
import android.graphics.drawable.LayerDrawable
1618
import android.os.Build
1719
import com.facebook.react.common.annotations.UnstableReactNativeAPI
20+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
1821
import com.facebook.react.uimanager.PixelUtil.dpToPx
1922
import com.facebook.react.uimanager.style.BorderInsets
2023
import com.facebook.react.uimanager.style.BorderRadiusStyle
@@ -240,10 +243,32 @@ internal class CompositeBackgroundDrawable(
240243
background?.let { layers.add(it) }
241244
backgroundImage?.let { layers.add(it) }
242245
border?.let { layers.add(it) }
243-
feedbackUnderlay?.let { layers.add(it) }
246+
feedbackUnderlay?.let {
247+
layers.add(
248+
if (ReactNativeFeatureFlags.fixBorderlessRippleAndroid()) {
249+
UnprojectedRippleWrapper(it)
250+
} else {
251+
it
252+
}
253+
)
254+
}
244255
layers.addAll(innerShadows.asReversed())
245256
outline?.let { layers.add(it) }
246257
return layers.toTypedArray()
247258
}
248259
}
249260
}
261+
262+
/**
263+
* Wraps the feedback underlay so it is never reported as projected. LayerDrawable reports itself as
264+
* projected if any layer is, so a borderless ripple in the feedback underlay would project the
265+
* entire background, including the background color and borders, onto the nearest native ancestor's
266+
* background. With view flattening, that ancestor may be painted under the view's flattened React
267+
* parent, hiding the background and ripple. Ignore the underlay's projection; borderless ripples
268+
* can still draw outside the view's bounds since React Native views don't clip their children.
269+
*/
270+
private class UnprojectedRippleWrapper(drawable: Drawable) : DrawableWrapper(drawable) {
271+
override fun isProjected(): Boolean = false
272+
273+
override fun getDirtyBounds(): Rect = drawable?.dirtyBounds ?: super.getDirtyBounds()
274+
}

‎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<<dad8d48d93f01fc5ec68f70dbd9a9e73>>
7+
* @generated SignedSource<<6238811c274064fbbcd2839230f00b93>>
88
*/
99

1010
/**
@@ -381,6 +381,12 @@ class ReactNativeFeatureFlagsJavaProvider
381381
return method(javaProvider_);
382382
}
383383

384+
bool fixBorderlessRippleAndroid() override {
385+
static const auto method =
386+
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("fixBorderlessRippleAndroid");
387+
return method(javaProvider_);
388+
}
389+
384390
bool fixMappingOfEventPrioritiesBetweenFabricAndReact() override {
385391
static const auto method =
386392
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("fixMappingOfEventPrioritiesBetweenFabricAndReact");
@@ -868,6 +874,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableVirtualViewContainerStateExperime
868874
return ReactNativeFeatureFlags::enableVirtualViewContainerStateExperimental();
869875
}
870876

877+
bool JReactNativeFeatureFlagsCxxInterop::fixBorderlessRippleAndroid(
878+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
879+
return ReactNativeFeatureFlags::fixBorderlessRippleAndroid();
880+
}
881+
871882
bool JReactNativeFeatureFlagsCxxInterop::fixMappingOfEventPrioritiesBetweenFabricAndReact(
872883
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
873884
return ReactNativeFeatureFlags::fixMappingOfEventPrioritiesBetweenFabricAndReact();
@@ -1235,6 +1246,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
12351246
makeNativeMethod(
12361247
"enableVirtualViewContainerStateExperimental",
12371248
JReactNativeFeatureFlagsCxxInterop::enableVirtualViewContainerStateExperimental),
1249+
makeNativeMethod(
1250+
"fixBorderlessRippleAndroid",
1251+
JReactNativeFeatureFlagsCxxInterop::fixBorderlessRippleAndroid),
12381252
makeNativeMethod(
12391253
"fixMappingOfEventPrioritiesBetweenFabricAndReact",
12401254
JReactNativeFeatureFlagsCxxInterop::fixMappingOfEventPrioritiesBetweenFabricAndReact),

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

Lines changed: 4 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<<7a9a9c1938c3e01f589e05fffe349ad3>>
7+
* @generated SignedSource<<cd0ad8bc1af104e98e722a94d6ddaabd>>
88
*/
99

1010
/**
@@ -201,6 +201,9 @@ class JReactNativeFeatureFlagsCxxInterop
201201
static bool enableVirtualViewContainerStateExperimental(
202202
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
203203

204+
static bool fixBorderlessRippleAndroid(
205+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
206+
204207
static bool fixMappingOfEventPrioritiesBetweenFabricAndReact(
205208
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
206209

0 commit comments

Comments
 (0)