From 9b4bdacdc31916f26617c350af78066ab1f933bd Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Wed, 8 Apr 2026 16:06:57 +0300 Subject: [PATCH] feat(Pressable): add support for PlatformColor and alpha --- .../Components/Pressable/Pressable.d.ts | 1 + .../Pressable/__tests__/Pressable-test.js | 64 ++++ .../__snapshots__/Pressable-test.js.snap | 286 ++++++++++++++++++ .../Pressable/useAndroidRippleForView.js | 15 +- .../Touchable/TouchableNativeFeedback.js | 14 +- .../Components/View/ViewPropTypes.js | 1 + .../react/views/view/ReactDrawableHelper.kt | 69 +++-- .../react/views/view/ReactViewGroup.kt | 24 ++ .../react/views/view/ReactViewManager.kt | 9 +- .../components/view/HostPlatformViewProps.cpp | 18 +- .../renderer/components/view/NativeDrawable.h | 48 ++- packages/react-native/ReactNativeApi.d.ts | 1 + .../js/examples/Pressable/PressableExample.js | 52 ++++ 13 files changed, 545 insertions(+), 57 deletions(-) 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',