From 7f2a79f40b4a4c41344ca90cefe318af607675e0 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Thu, 20 Feb 2020 15:26:46 -0800 Subject: [PATCH] allow custom ripple radius on TouchableNativeFeedback (#28009) Summary: motivation: there are cases where one'd like to control the radius of the ripple effect that's present on TouchableNativeFeedback - in my case, I want to make sure that both icons and text have the same ripple appearance, but that's currently not possible as far as I can tell. Currently (afaik) the only way to set (upper) ripple limits is by specifying width, height and border radius ( + `overflow: hidden`), and this works well for icons which can usually be bounded by a square, but not for text which can have rectangular shape. This PR adds `rippleRadius` parameter to `SelectableBackground()`, `SelectableBackgroundBorderless()` and `Ripple()` static functions present on `TouchableNativeFeedback`. It can make the ripple smaller but also larger. The result looks like this: added to RNTester: ![SVID_20200219_182027_1](https://user-images.githubusercontent.com/1566403/74858131-147ff380-5345-11ea-8a9e-2730b79eec38.gif) difference from the other ripples: ![SVID_20200209_110918_1](https://user-images.githubusercontent.com/1566403/74109152-4513a080-4b81-11ea-8ec3-bb5862c57244.gif) I'm ofc open to changing the api if needed, but I'm not sure there's much space for manoeuvring. While I was at it, I did a slight refactor of the class into several smaller, more focused methods. It's possible that in some cases, this might help to work around this issue https://github.com/facebook/react-native/issues/6480. ## Changelog [Android] [Added] - allow setting custom ripple radius on TouchableNativeFeedback Pull Request resolved: https://github.com/facebook/react-native/pull/28009 Test Plan: I tested this locally using RNTester Reviewed By: TheSavior Differential Revision: D20004509 Pulled By: mdvacca fbshipit-source-id: 10de1754d54c17878f36a3859705c1188f15c2a2 --- .../Touchable/TouchableNativeFeedback.js | 23 +++- .../js/examples/Touchable/TouchableExample.js | 94 ++++++++++++---- .../react/views/view/ReactDrawableHelper.java | 106 ++++++++++++------ 3 files changed, 159 insertions(+), 64 deletions(-) diff --git a/Libraries/Components/Touchable/TouchableNativeFeedback.js b/Libraries/Components/Touchable/TouchableNativeFeedback.js index a2903b3f84..052fe5b4a7 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 e0c98091d9..8389c58054 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 8537422c34..aae101bd19 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.Drawable; 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 class ReactDrawableHelper { 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; + } }