diff --git a/Libraries/Components/Touchable/TouchableNativeFeedback.js b/Libraries/Components/Touchable/TouchableNativeFeedback.js index a2903b3f849a4d..052fe5b4a77be1 100644 --- a/Libraries/Components/Touchable/TouchableNativeFeedback.js +++ b/Libraries/Components/Touchable/TouchableNativeFeedback.js @@ -40,11 +40,13 @@ type Props = $ReadOnly<{| attribute: | 'selectableItemBackground' | 'selectableItemBackgroundBorderless', + rippleRadius: ?number, |}> | $ReadOnly<{| type: 'RippleAndroid', color: ?number, borderless: boolean, + rippleRadius: ?number, |}> ), @@ -100,24 +102,32 @@ class TouchableNativeFeedback extends React.Component { * Creates a value for the `background` prop that uses the Android theme's * default background for selectable elements. */ - static SelectableBackground: () => $ReadOnly<{| + static SelectableBackground: ( + rippleRadius: ?number, + ) => $ReadOnly<{| attribute: 'selectableItemBackground', type: 'ThemeAttrAndroid', - |}> = () => ({ + rippleRadius: ?number, + |}> = (rippleRadius: ?number) => ({ type: 'ThemeAttrAndroid', attribute: 'selectableItemBackground', + rippleRadius, }); /** * Creates a value for the `background` prop that uses the Android theme's * default background for borderless selectable elements. Requires API 21+. */ - static SelectableBackgroundBorderless: () => $ReadOnly<{| + static SelectableBackgroundBorderless: ( + rippleRadius: ?number, + ) => $ReadOnly<{| attribute: 'selectableItemBackgroundBorderless', type: 'ThemeAttrAndroid', - |}> = () => ({ + rippleRadius: ?number, + |}> = (rippleRadius: ?number) => ({ type: 'ThemeAttrAndroid', attribute: 'selectableItemBackgroundBorderless', + rippleRadius, }); /** @@ -128,11 +138,13 @@ class TouchableNativeFeedback extends React.Component { static Ripple: ( color: string, borderless: boolean, + rippleRadius: ?number, ) => $ReadOnly<{| borderless: boolean, color: ?number, + rippleRadius: ?number, type: 'RippleAndroid', - |}> = (color: string, borderless: boolean) => { + |}> = (color: string, borderless: boolean, rippleRadius: ?number) => { const processedColor = processColor(color); invariant( processedColor == null || typeof processedColor === 'number', @@ -142,6 +154,7 @@ class TouchableNativeFeedback extends React.Component { type: 'RippleAndroid', color: processedColor, borderless, + rippleRadius, }; }; diff --git a/RNTester/js/examples/Touchable/TouchableExample.js b/RNTester/js/examples/Touchable/TouchableExample.js index e0c98091d9f5b2..8389c580543a31 100644 --- a/RNTester/js/examples/Touchable/TouchableExample.js +++ b/RNTester/js/examples/Touchable/TouchableExample.js @@ -401,35 +401,78 @@ class TouchableDisabled extends React.Component<{...}> { {Platform.OS === 'android' && ( - console.log('custom TNF has been clicked')} - background={TouchableNativeFeedback.SelectableBackground()}> - - - Enabled TouchableNativeFeedback - - - - )} + <> + console.log('custom TNF has been clicked')} + background={TouchableNativeFeedback.SelectableBackground()}> + + + Enabled TouchableNativeFeedback + + + - {Platform.OS === 'android' && ( - console.log('custom TNF has been clicked')} - background={TouchableNativeFeedback.SelectableBackground()}> - - - Disabled TouchableNativeFeedback - - - + console.log('custom TNF has been clicked')} + background={TouchableNativeFeedback.SelectableBackground()}> + + + Disabled TouchableNativeFeedback + + + + )} ); } } +function CustomRippleRadius() { + if (Platform.OS !== 'android') { + return null; + } + return ( + + console.log('custom TNF has been clicked')} + background={TouchableNativeFeedback.Ripple('orange', true, 30)}> + + + radius 30 + + + + + console.log('custom TNF has been clicked')} + background={TouchableNativeFeedback.SelectableBackgroundBorderless(50)}> + + + radius 50 + + + + + console.log('custom TNF has been clicked')} + background={TouchableNativeFeedback.SelectableBackground(70)}> + + + radius 70, with border + + + + + ); +} + const remoteImage = { uri: 'https://www.facebook.com/favicon.ico', }; @@ -611,4 +654,11 @@ exports.examples = [ return ; }, }, + { + title: 'Custom Ripple Radius (Android-only)', + description: ('Ripple radius on TouchableNativeFeedback can be controlled': string), + render: function(): React.Element { + return ; + }, + }, ]; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.java index 8537422c348390..aae101bd19766c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.java @@ -16,9 +16,13 @@ import android.graphics.drawable.RippleDrawable; import android.os.Build; import android.util.TypedValue; + +import androidx.annotation.Nullable; + import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.SoftAssertions; +import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ViewProps; /** @@ -41,48 +45,76 @@ public static Drawable createDrawableFromJSDescription( throw new JSApplicationIllegalArgumentException( "Attribute " + attr + " couldn't be found in the resource list"); } - if (context.getTheme().resolveAttribute(attrID, sResolveOutValue, true)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - return context - .getResources() - .getDrawable(sResolveOutValue.resourceId, context.getTheme()); - } else { - return context.getResources().getDrawable(sResolveOutValue.resourceId); - } - } else { + if (!context.getTheme().resolveAttribute(attrID, sResolveOutValue, true)) { throw new JSApplicationIllegalArgumentException( - "Attribute " + attr + " couldn't be resolved into a drawable"); + "Attribute " + attr + " couldn't be resolved into a drawable"); } + Drawable drawable = getDefaultThemeDrawable(context); + return setRadius(drawableDescriptionDict, drawable); } else if ("RippleAndroid".equals(type)) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - throw new JSApplicationIllegalArgumentException( - "Ripple drawable is not available on " + "android API <21"); - } - int color; - if (drawableDescriptionDict.hasKey(ViewProps.COLOR) - && !drawableDescriptionDict.isNull(ViewProps.COLOR)) { - color = drawableDescriptionDict.getInt(ViewProps.COLOR); - } else { - if (context - .getTheme() - .resolveAttribute(android.R.attr.colorControlHighlight, sResolveOutValue, true)) { - color = context.getResources().getColor(sResolveOutValue.resourceId); - } else { - throw new JSApplicationIllegalArgumentException( - "Attribute colorControlHighlight " + "couldn't be resolved into a drawable"); - } - } - Drawable mask = null; - if (!drawableDescriptionDict.hasKey("borderless") - || drawableDescriptionDict.isNull("borderless") - || !drawableDescriptionDict.getBoolean("borderless")) { - mask = new ColorDrawable(Color.WHITE); - } - ColorStateList colorStateList = - new ColorStateList(new int[][] {new int[] {}}, new int[] {color}); - return new RippleDrawable(colorStateList, null, mask); + RippleDrawable rd = getRippleDrawable(context, drawableDescriptionDict); + return setRadius(drawableDescriptionDict, rd); } else { throw new JSApplicationIllegalArgumentException("Invalid type for android drawable: " + type); } } + + private static Drawable getDefaultThemeDrawable(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return context + .getResources() + .getDrawable(sResolveOutValue.resourceId, context.getTheme()); + } else { + return context.getResources().getDrawable(sResolveOutValue.resourceId); + } + } + + private static RippleDrawable getRippleDrawable(Context context, ReadableMap drawableDescriptionDict) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + throw new JSApplicationIllegalArgumentException( + "Ripple drawable is not available on " + "android API <21"); + } + int color = getColor(context, drawableDescriptionDict); + Drawable mask = getMask(drawableDescriptionDict); + ColorStateList colorStateList = + new ColorStateList(new int[][] {new int[] {}}, new int[] {color}); + + return new RippleDrawable(colorStateList, null, mask); + } + + private static Drawable setRadius(ReadableMap drawableDescriptionDict, Drawable drawable) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && drawableDescriptionDict.hasKey("rippleRadius") + && drawable instanceof RippleDrawable) { + RippleDrawable rippleDrawable = (RippleDrawable) drawable; + double rippleRadius = drawableDescriptionDict.getDouble("rippleRadius"); + rippleDrawable.setRadius((int) PixelUtil.toPixelFromDIP(rippleRadius)); + } + return drawable; + } + + private static int getColor(Context context, ReadableMap drawableDescriptionDict) { + if (drawableDescriptionDict.hasKey(ViewProps.COLOR) + && !drawableDescriptionDict.isNull(ViewProps.COLOR)) { + return drawableDescriptionDict.getInt(ViewProps.COLOR); + } else { + if (context + .getTheme() + .resolveAttribute(android.R.attr.colorControlHighlight, sResolveOutValue, true)) { + return context.getResources().getColor(sResolveOutValue.resourceId); + } else { + throw new JSApplicationIllegalArgumentException( + "Attribute colorControlHighlight " + "couldn't be resolved into a drawable"); + } + } + } + + private static @Nullable Drawable getMask(ReadableMap drawableDescriptionDict) { + if (!drawableDescriptionDict.hasKey("borderless") + || drawableDescriptionDict.isNull("borderless") + || !drawableDescriptionDict.getBoolean("borderless")) { + return new ColorDrawable(Color.WHITE); + } + return null; + } }