diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts b/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts
index e1f8bfefbd8e..00d131f4aa11 100644
--- a/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts
+++ b/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts
@@ -30,6 +30,7 @@ export interface PressableAndroidRippleConfig {
borderless?: null | boolean | undefined;
radius?: null | number | undefined;
foreground?: null | boolean | undefined;
+ alpha?: null | number | undefined;
}
export interface PressableProps
diff --git a/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-test.js b/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-test.js
index ca332e05ec2d..57178b4297ce 100644
--- a/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-test.js
+++ b/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-test.js
@@ -9,6 +9,8 @@
*/
import {expectRendersMatchingSnapshot} from '../../../Utilities/ReactNativeTestTools';
+import Platform from '../../../Utilities/Platform';
+import {PlatformColor} from '../../../StyleSheet/PlatformColorValueTypes';
import View from '../../View/View';
import Pressable from '../Pressable';
import * as React from 'react';
@@ -92,3 +94,65 @@ describe('',
);
});
});
+
+describe(' on Android', () => {
+ let originalOS: string;
+
+ beforeEach(() => {
+ originalOS = Platform.OS;
+ /* $FlowFixMe[incompatible-type] */
+ Platform.OS = 'android';
+ });
+
+ afterEach(() => {
+ /* $FlowFixMe[incompatible-type] */
+ Platform.OS = originalOS;
+ });
+
+ it('should set nativeBackgroundAndroid with numeric color and alpha', async () => {
+ await expectRendersMatchingSnapshot(
+ 'Pressable',
+ () => (
+
+
+
+ ),
+ () => {
+ jest.dontMock('../Pressable');
+ },
+ );
+ });
+
+ it('should set nativeBackgroundAndroid with PlatformColor and alpha', async () => {
+ await expectRendersMatchingSnapshot(
+ 'Pressable',
+ () => (
+
+
+
+ ),
+ () => {
+ jest.dontMock('../Pressable');
+ },
+ );
+ });
+
+ it('should not crash with an unresolvable PlatformColor', async () => {
+ await expectRendersMatchingSnapshot(
+ 'Pressable',
+ () => (
+
+
+
+ ),
+ () => {
+ jest.dontMock('../Pressable');
+ },
+ );
+ });
+});
diff --git a/packages/react-native/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap b/packages/react-native/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap
index 307aaba80c6a..6f406d8766c8 100644
--- a/packages/react-native/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap
+++ b/packages/react-native/Libraries/Components/Pressable/__tests__/__snapshots__/Pressable-test.js.snap
@@ -72,6 +72,292 @@ exports[` should render as expected: should deep render when not mo
`;
+exports[` on Android should not crash with an unresolvable PlatformColor: should deep render when mocked (please verify output manually) 1`] = `
+
+
+
+`;
+
+exports[` on Android should not crash with an unresolvable PlatformColor: should deep render when not mocked (please verify output manually) 1`] = `
+
+
+
+`;
+
+exports[` on Android should set nativeBackgroundAndroid with PlatformColor and alpha: should deep render when mocked (please verify output manually) 1`] = `
+
+
+
+`;
+
+exports[` on Android should set nativeBackgroundAndroid with PlatformColor and alpha: should deep render when not mocked (please verify output manually) 1`] = `
+
+
+
+`;
+
+exports[` on Android should set nativeBackgroundAndroid with numeric color and alpha: should deep render when mocked (please verify output manually) 1`] = `
+
+
+
+`;
+
+exports[` on Android should set nativeBackgroundAndroid with numeric color and alpha: should deep render when not mocked (please verify output manually) 1`] = `
+
+
+
+`;
+
exports[` should be disabled when disabled is true: should deep render when mocked (please verify output manually) 1`] = `
;
export type PressableAndroidRippleConfig = {
@@ -31,6 +32,7 @@ export type PressableAndroidRippleConfig = {
borderless?: boolean,
radius?: number,
foreground?: boolean,
+ alpha?: number,
};
/**
@@ -48,7 +50,7 @@ export default function useAndroidRippleForView(
| Readonly<{nativeBackgroundAndroid: NativeBackgroundProp}>
| Readonly<{nativeForegroundAndroid: NativeBackgroundProp}>,
}> {
- const {color, borderless, radius, foreground} = rippleConfig ?? {};
+ const {color, borderless, radius, foreground, alpha} = rippleConfig ?? {};
return useMemo(() => {
if (
@@ -56,16 +58,13 @@ export default function useAndroidRippleForView(
(color != null || borderless != null || radius != null)
) {
const processedColor = processColor(color);
- invariant(
- processedColor == null || typeof processedColor === 'number',
- 'Unexpected color given for Ripple color',
- );
const nativeRippleValue = {
type: 'RippleAndroid',
color: processedColor,
borderless: borderless === true,
rippleRadius: radius,
+ alpha: alpha ?? null,
};
return {
@@ -105,5 +104,5 @@ export default function useAndroidRippleForView(
};
}
return null;
- }, [borderless, color, foreground, radius, viewRef]);
+ }, [alpha, borderless, color, foreground, radius, viewRef]);
}
diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js
index 6a0b5e12ee6b..9a0806e4983c 100644
--- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js
+++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js
@@ -9,6 +9,8 @@
*/
import type {GestureResponderEvent} from '../../Types/CoreEventTypes';
+import type {ColorValue} from '../../StyleSheet/StyleSheet';
+import type {ProcessedColorValue} from '../../StyleSheet/processColor';
import type {TouchableWithoutFeedbackProps} from './TouchableWithoutFeedback';
import View from '../../Components/View/View';
@@ -20,7 +22,6 @@ import {findHostInstance_DEPRECATED} from '../../ReactNative/RendererProxy';
import processColor from '../../StyleSheet/processColor';
import Platform from '../../Utilities/Platform';
import {Commands} from '../View/ViewNativeComponent';
-import invariant from 'invariant';
import * as React from 'react';
import {cloneElement} from 'react';
@@ -177,23 +178,18 @@ class TouchableNativeFeedback extends React.Component<
* @param rippleRadius The radius of ripple effect
*/
static Ripple: (
- color: string,
+ color: ColorValue,
borderless: boolean,
rippleRadius?: ?number,
) => Readonly<{
borderless: boolean,
- color: ?number,
+ color: ?ProcessedColorValue,
rippleRadius: ?number,
type: 'RippleAndroid',
- }> = (color: string, borderless: boolean, rippleRadius?: ?number) => {
+ }> = (color: ColorValue, borderless: boolean, rippleRadius?: ?number) => {
const processedColor = processColor(color);
- invariant(
- processedColor == null || typeof processedColor === 'number',
- 'Unexpected color given for Ripple color',
- );
return {
type: 'RippleAndroid',
- // $FlowFixMe[incompatible-type]
color: processedColor,
borderless,
rippleRadius,
diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js
index a20d8f246ea5..45a6a4791203 100644
--- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js
+++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js
@@ -267,6 +267,7 @@ type AndroidDrawableRipple = Readonly<{
color?: ?number,
borderless?: ?boolean,
rippleRadius?: ?number,
+ alpha?: ?number,
}>;
type AndroidDrawable = AndroidDrawableThemeAttr | AndroidDrawableRipple;
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 3b0cb0d8f828..d40d7259e3c5 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
@@ -15,10 +15,16 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.RippleDrawable
import android.util.TypedValue
+import com.facebook.common.logging.FLog
+import com.facebook.react.bridge.ColorPropConverter
+import com.facebook.react.bridge.JSApplicationCausedNativeException
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.bridge.ReadableMap
+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 kotlin.math.roundToInt
/**
* Utility class that helps with converting android drawable description used in JS to an actual
@@ -73,11 +79,19 @@ public object ReactDrawableHelper {
context: Context,
drawableDescriptionDict: ReadableMap,
): RippleDrawable {
- val color = getColor(context, drawableDescriptionDict)
- val mask = getMask(drawableDescriptionDict)
- val colorStateList = ColorStateList(arrayOf(intArrayOf()), intArrayOf(color))
+ val resolvedColor = getColor(context, drawableDescriptionDict)
+ var color = resolvedColor ?: getFallbackColor(context)
+
+ if (resolvedColor != null &&
+ drawableDescriptionDict.hasKey("alpha") &&
+ !drawableDescriptionDict.isNull("alpha")) {
+ val alphaFactor = drawableDescriptionDict.getDouble("alpha").coerceIn(0.0, 1.0)
+ val newAlpha = (Color.alpha(color) * alphaFactor).roundToInt()
+ color = Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color))
+ }
- return RippleDrawable(colorStateList, null, mask)
+ val mask = getMask(drawableDescriptionDict)
+ return RippleDrawable(ColorStateList(arrayOf(intArrayOf()), intArrayOf(color)), null, mask)
}
private fun setRadius(drawableDescriptionDict: ReadableMap, drawable: Drawable?): Drawable? {
@@ -88,26 +102,35 @@ public object ReactDrawableHelper {
return drawable
}
- private fun getColor(context: Context, drawableDescriptionDict: ReadableMap): Int =
- if (
- drawableDescriptionDict.hasKey(ViewProps.COLOR) &&
- !drawableDescriptionDict.isNull(ViewProps.COLOR)
- ) {
- drawableDescriptionDict.getInt(ViewProps.COLOR)
+ /**
+ * Returns the resolved ripple color, or null if none was provided or the
+ * PlatformColor resource couldn't be found.
+ */
+ private fun getColor(context: Context, drawableDescriptionDict: ReadableMap): Int? {
+ val rawColor: Any? =
+ if (drawableDescriptionDict.hasKey(ViewProps.COLOR) &&
+ !drawableDescriptionDict.isNull(ViewProps.COLOR)) {
+ when (drawableDescriptionDict.getType(ViewProps.COLOR)) {
+ ReadableType.Number -> drawableDescriptionDict.getDouble(ViewProps.COLOR)
+ ReadableType.Map -> drawableDescriptionDict.getMap(ViewProps.COLOR)
+ else -> null
+ }
+ } else null
+ return try {
+ ColorPropConverter.getColor(rawColor, context)
+ } catch (e: JSApplicationCausedNativeException) {
+ FLog.w(ReactConstants.TAG, e, "android_ripple: color resource not found, using colorControlHighlight")
+ null
+ }
+ }
+
+ private fun getFallbackColor(context: Context): Int =
+ if (context.theme.resolveAttribute(
+ android.R.attr.colorControlHighlight, resolveOutValue, true)) {
+ context.resources.getColor(resolveOutValue.resourceId, context.theme)
} else {
- if (
- context.theme.resolveAttribute(
- android.R.attr.colorControlHighlight,
- resolveOutValue,
- true,
- )
- ) {
- context.resources.getColor(resolveOutValue.resourceId, context.theme)
- } else {
- throw JSApplicationIllegalArgumentException(
- "Attribute colorControlHighlight couldn't be resolved into a drawable"
- )
- }
+ throw JSApplicationIllegalArgumentException(
+ "Attribute colorControlHighlight couldn't be resolved into a drawable")
}
private fun getMask(drawableDescriptionDict: ReadableMap): Drawable? {
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 880c15362a4b..f7def407ef22 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
@@ -12,6 +12,7 @@ package com.facebook.react.views.view
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.Context
+import android.content.res.Configuration
import android.graphics.BlendMode
import android.graphics.Canvas
import android.graphics.Paint
@@ -25,6 +26,7 @@ import android.view.ViewStructure
import android.view.accessibility.AccessibilityManager
import com.facebook.common.logging.FLog
import com.facebook.react.R
+import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReactNoCrashSoftException
import com.facebook.react.bridge.ReactSoftExceptionLogger
import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException
@@ -87,6 +89,25 @@ public open class ReactViewGroup public constructor(context: Context?) :
public override val overflowInset: Rect = Rect()
+ internal var nativeBackgroundMap: ReadableMap? = null
+ internal var nativeForegroundMap: ReadableMap? = null
+
+ internal fun applyNativeBackground(map: ReadableMap?) {
+ nativeBackgroundMap = map
+ setFeedbackUnderlay(this, map?.let { ReactDrawableHelper.createDrawableFromJSDescription(context, it) })
+ }
+
+ internal fun applyNativeForeground(map: ReadableMap?) {
+ nativeForegroundMap = map
+ foreground = map?.let { ReactDrawableHelper.createDrawableFromJSDescription(context, it) }
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ applyNativeBackground(nativeBackgroundMap)
+ applyNativeForeground(nativeForegroundMap)
+ }
+
/**
* This listener will be set for child views when `removeClippedSubview` property is enabled. When
* children layout is updated, it will call [updateSubviewClipStatus] to notify parent view about
@@ -185,6 +206,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
backfaceOpacity = 1f
backfaceVisible = true
childrenRemovedWhileTransitioning = null
+
+ nativeBackgroundMap = null
+ nativeForegroundMap = null
}
internal open fun recycleView() {
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt
index 0c8568f74f7a..b981f31a2ecf 100644
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt
@@ -326,17 +326,12 @@ public open class ReactViewManager : ReactClippingViewManager()
@ReactProp(name = "nativeBackgroundAndroid")
public open fun setNativeBackground(view: ReactViewGroup, background: ReadableMap?) {
- val bg = background?.let {
- ReactDrawableHelper.createDrawableFromJSDescription(view.context, it)
- }
- BackgroundStyleApplicator.setFeedbackUnderlay(view, bg)
+ view.applyNativeBackground(background)
}
@ReactProp(name = "nativeForegroundAndroid")
public open fun setNativeForeground(view: ReactViewGroup, foreground: ReadableMap?) {
- view.foreground = foreground?.let {
- ReactDrawableHelper.createDrawableFromJSDescription(view.context, it)
- }
+ view.applyNativeForeground(foreground)
}
@ReactProp(name = ViewProps.NEEDS_OFFSCREEN_ALPHA_COMPOSITING)
diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp
index f430a9570978..37dabb48032e 100644
--- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp
+++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp
@@ -450,8 +450,22 @@ inline static void updateNativeDrawableProp(
nativeDrawableResult["rippleRadius"] =
nativeDrawableValue.ripple.rippleRadius.value();
}
- if (nativeDrawableValue.ripple.color.has_value()) {
- nativeDrawableResult["color"] = nativeDrawableValue.ripple.color.value();
+ if (nativeDrawableValue.ripple.color.has_value() || nativeDrawableValue.ripple.colorResourcePaths.has_value()) {
+ if (nativeDrawableValue.ripple.colorResourcePaths.has_value()) {
+ folly::dynamic resourcePaths = folly::dynamic::array();
+ for (const auto& path : nativeDrawableValue.ripple.colorResourcePaths.value()) {
+ resourcePaths.push_back(path);
+ }
+ folly::dynamic platformColorMap = folly::dynamic::object();
+ platformColorMap["resource_paths"] = resourcePaths;
+ nativeDrawableResult["color"] = platformColorMap;
+ } else {
+ nativeDrawableResult["color"] =
+ toAndroidRepr(nativeDrawableValue.ripple.color.value());
+ }
+ if (nativeDrawableValue.ripple.alpha.has_value()) {
+ nativeDrawableResult["alpha"] = nativeDrawableValue.ripple.alpha.value();
+ }
}
nativeDrawableResult["borderless"] = nativeDrawableValue.ripple.borderless;
} else {
diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h
index f02bb82205a1..ba7e53dde7a9 100644
--- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h
+++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/NativeDrawable.h
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
#include
#include
@@ -22,14 +23,16 @@ struct NativeDrawable {
};
struct Ripple {
- std::optional color{};
+ std::optional color{};
+ std::optional> colorResourcePaths{};
std::optional rippleRadius{};
bool borderless{false};
+ std::optional alpha{};
bool operator==(const Ripple &rhs) const
{
- return std::tie(this->color, this->borderless, this->rippleRadius) ==
- std::tie(rhs.color, rhs.borderless, rhs.rippleRadius);
+ return std::tie(this->color, this->colorResourcePaths, this->borderless, this->rippleRadius, this->alpha) ==
+ std::tie(rhs.color, rhs.colorResourcePaths, rhs.borderless, rhs.rippleRadius, rhs.alpha);
}
};
@@ -60,7 +63,7 @@ struct NativeDrawable {
};
static inline void
-fromRawValue(const PropsParserContext & /*context*/, const RawValue &rawValue, NativeDrawable &result)
+fromRawValue(const PropsParserContext &context, const RawValue &rawValue, NativeDrawable &result)
{
auto map = (std::unordered_map)rawValue;
@@ -81,14 +84,43 @@ fromRawValue(const PropsParserContext & /*context*/, const RawValue &rawValue, N
auto color = map.find("color");
auto borderless = map.find("borderless");
auto rippleRadius = map.find("rippleRadius");
+ auto alpha = map.find("alpha");
+
+ std::optional parsedColor{};
+ std::optional> parsedColorResourcePaths{};
+ if (color != map.end()) {
+ if (color->second.hasType>>()) {
+ auto colorMap = (std::unordered_map>)color->second;
+ auto pathsIt = colorMap.find("resource_paths");
+ if (pathsIt != colorMap.end()) {
+ parsedColorResourcePaths = pathsIt->second;
+ }
+ } else {
+ SharedColor resolved;
+ fromRawValue(context, color->second, resolved);
+ if (resolved) {
+ parsedColor = resolved;
+ }
+ }
+ }
+
+ std::optional parsedAlpha{};
+ if (alpha != map.end() && alpha->second.hasType()) {
+ parsedAlpha = (Float)alpha->second;
+ }
result = NativeDrawable{
std::string{},
NativeDrawable::Ripple{
- color != map.end() && color->second.hasType() ? (int32_t)color->second : std::optional{},
- rippleRadius != map.end() && rippleRadius->second.hasType() ? (Float)rippleRadius->second
- : std::optional{},
- borderless != map.end() && borderless->second.hasType() ? (bool)borderless->second : false,
+ parsedColor,
+ parsedColorResourcePaths,
+ rippleRadius != map.end() && rippleRadius->second.hasType()
+ ? (Float)rippleRadius->second
+ : std::optional{},
+ borderless != map.end() && borderless->second.hasType()
+ ? (bool)borderless->second
+ : false,
+ parsedAlpha,
},
NativeDrawable::Kind::Ripple,
};
diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts
index 407700bfb780..499ce6a2fba7 100644
--- a/packages/react-native/ReactNativeApi.d.ts
+++ b/packages/react-native/ReactNativeApi.d.ts
@@ -3809,6 +3809,7 @@ declare type PressableAndroidRippleConfig = {
color?: ColorValue
foreground?: boolean
radius?: number
+ alpha?: number
}
declare type PressableBaseProps = {
readonly android_disableSound?: boolean
diff --git a/packages/rn-tester/js/examples/Pressable/PressableExample.js b/packages/rn-tester/js/examples/Pressable/PressableExample.js
index 0e2f6c035a43..c34089121268 100644
--- a/packages/rn-tester/js/examples/Pressable/PressableExample.js
+++ b/packages/rn-tester/js/examples/Pressable/PressableExample.js
@@ -17,6 +17,7 @@ import {
Animated,
Image,
Platform,
+ PlatformColor,
Pressable,
StyleSheet,
Text,
@@ -512,6 +513,57 @@ const examples = [
);
},
},
+ {
+ title: 'Pressable with PlatformColor ripple and alpha',
+ description:
+ 'android_ripple accepts PlatformColor and a separate alpha (0–1) parameter' as string,
+ platform: 'android',
+ render: function (): React.Node {
+ const buttonStyle = {textAlign: 'center', margin: 10};
+ return (
+
+
+
+ PlatformColor('?attr/colorAccent'), no alpha
+
+
+
+
+ PlatformColor('?attr/colorAccent'), alpha=0.3
+
+
+
+ Red (#FF0000), no alpha
+
+
+ Red (#FF0000), alpha=0.5
+
+
+ );
+ },
+ },
{
title: ' with highlight',
name: 'text-press',