Skip to content
Permalink
Browse files
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 #6480.

## Changelog

[Android] [Added] - allow setting custom ripple radius on TouchableNativeFeedback
Pull Request resolved: #28009

Test Plan: I tested this locally using RNTester

Reviewed By: TheSavior

Differential Revision: D20004509

Pulled By: mdvacca

fbshipit-source-id: 10de1754d54c17878f36a3859705c1188f15c2a2
  • Loading branch information
vonovak authored and facebook-github-bot committed Feb 20, 2020
1 parent de8fcfb commit 7f2a79f40b4a4c41344ca90cefe318af607675e0
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 64 deletions.
@@ -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<Props, State> {
* 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<Props, State> {
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<Props, State> {
type: 'RippleAndroid',
color: processedColor,
borderless,
rippleRadius,
};
};

@@ -401,35 +401,78 @@ class TouchableDisabled extends React.Component<{...}> {
</TouchableWithoutFeedback>

{Platform.OS === 'android' && (
<TouchableNativeFeedback
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.SelectableBackground()}>
<View style={[styles.row, styles.block]}>
<Text style={[styles.button, styles.nativeFeedbackButton]}>
Enabled TouchableNativeFeedback
</Text>
</View>
</TouchableNativeFeedback>
)}
<>
<TouchableNativeFeedback
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.SelectableBackground()}>
<View style={[styles.row, styles.block]}>
<Text style={[styles.button, styles.nativeFeedbackButton]}>
Enabled TouchableNativeFeedback
</Text>
</View>
</TouchableNativeFeedback>

{Platform.OS === 'android' && (
<TouchableNativeFeedback
disabled={true}
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.SelectableBackground()}>
<View style={[styles.row, styles.block]}>
<Text
style={[styles.disabledButton, styles.nativeFeedbackButton]}>
Disabled TouchableNativeFeedback
</Text>
</View>
</TouchableNativeFeedback>
<TouchableNativeFeedback
disabled={true}
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.SelectableBackground()}>
<View style={[styles.row, styles.block]}>
<Text
style={[styles.disabledButton, styles.nativeFeedbackButton]}>
Disabled TouchableNativeFeedback
</Text>
</View>
</TouchableNativeFeedback>
</>
)}
</View>
);
}
}

function CustomRippleRadius() {
if (Platform.OS !== 'android') {
return null;
}
return (
<View
style={[
styles.row,
{justifyContent: 'space-around', alignItems: 'center'},
]}>
<TouchableNativeFeedback
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.Ripple('orange', true, 30)}>
<View>
<Text style={[styles.button, styles.nativeFeedbackButton]}>
radius 30
</Text>
</View>
</TouchableNativeFeedback>

<TouchableNativeFeedback
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.SelectableBackgroundBorderless(50)}>
<View>
<Text style={[styles.button, styles.nativeFeedbackButton]}>
radius 50
</Text>
</View>
</TouchableNativeFeedback>

<TouchableNativeFeedback
onPress={() => console.log('custom TNF has been clicked')}
background={TouchableNativeFeedback.SelectableBackground(70)}>
<View style={styles.block}>
<Text style={[styles.button, styles.nativeFeedbackButton]}>
radius 70, with border
</Text>
</View>
</TouchableNativeFeedback>
</View>
);
}

const remoteImage = {
uri: 'https://www.facebook.com/favicon.ico',
};
@@ -611,4 +654,11 @@ exports.examples = [
return <TouchableDisabled />;
},
},
{
title: 'Custom Ripple Radius (Android-only)',
description: ('Ripple radius on TouchableNativeFeedback can be controlled': string),
render: function(): React.Element<any> {
return <CustomRippleRadius />;
},
},
];
@@ -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;
}
}

0 comments on commit 7f2a79f

Please sign in to comment.