Skip to content

Commit 7f2a79f

Browse files
vonovakfacebook-github-bot
authored andcommitted
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
1 parent de8fcfb commit 7f2a79f

File tree

3 files changed

+159
-64
lines changed

3 files changed

+159
-64
lines changed

Libraries/Components/Touchable/TouchableNativeFeedback.js

+18-5
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ type Props = $ReadOnly<{|
4040
attribute:
4141
| 'selectableItemBackground'
4242
| 'selectableItemBackgroundBorderless',
43+
rippleRadius: ?number,
4344
|}>
4445
| $ReadOnly<{|
4546
type: 'RippleAndroid',
4647
color: ?number,
4748
borderless: boolean,
49+
rippleRadius: ?number,
4850
|}>
4951
),
5052

@@ -100,24 +102,32 @@ class TouchableNativeFeedback extends React.Component<Props, State> {
100102
* Creates a value for the `background` prop that uses the Android theme's
101103
* default background for selectable elements.
102104
*/
103-
static SelectableBackground: () => $ReadOnly<{|
105+
static SelectableBackground: (
106+
rippleRadius: ?number,
107+
) => $ReadOnly<{|
104108
attribute: 'selectableItemBackground',
105109
type: 'ThemeAttrAndroid',
106-
|}> = () => ({
110+
rippleRadius: ?number,
111+
|}> = (rippleRadius: ?number) => ({
107112
type: 'ThemeAttrAndroid',
108113
attribute: 'selectableItemBackground',
114+
rippleRadius,
109115
});
110116

111117
/**
112118
* Creates a value for the `background` prop that uses the Android theme's
113119
* default background for borderless selectable elements. Requires API 21+.
114120
*/
115-
static SelectableBackgroundBorderless: () => $ReadOnly<{|
121+
static SelectableBackgroundBorderless: (
122+
rippleRadius: ?number,
123+
) => $ReadOnly<{|
116124
attribute: 'selectableItemBackgroundBorderless',
117125
type: 'ThemeAttrAndroid',
118-
|}> = () => ({
126+
rippleRadius: ?number,
127+
|}> = (rippleRadius: ?number) => ({
119128
type: 'ThemeAttrAndroid',
120129
attribute: 'selectableItemBackgroundBorderless',
130+
rippleRadius,
121131
});
122132

123133
/**
@@ -128,11 +138,13 @@ class TouchableNativeFeedback extends React.Component<Props, State> {
128138
static Ripple: (
129139
color: string,
130140
borderless: boolean,
141+
rippleRadius: ?number,
131142
) => $ReadOnly<{|
132143
borderless: boolean,
133144
color: ?number,
145+
rippleRadius: ?number,
134146
type: 'RippleAndroid',
135-
|}> = (color: string, borderless: boolean) => {
147+
|}> = (color: string, borderless: boolean, rippleRadius: ?number) => {
136148
const processedColor = processColor(color);
137149
invariant(
138150
processedColor == null || typeof processedColor === 'number',
@@ -142,6 +154,7 @@ class TouchableNativeFeedback extends React.Component<Props, State> {
142154
type: 'RippleAndroid',
143155
color: processedColor,
144156
borderless,
157+
rippleRadius,
145158
};
146159
};
147160

RNTester/js/examples/Touchable/TouchableExample.js

+72-22
Original file line numberDiff line numberDiff line change
@@ -401,35 +401,78 @@ class TouchableDisabled extends React.Component<{...}> {
401401
</TouchableWithoutFeedback>
402402

403403
{Platform.OS === 'android' && (
404-
<TouchableNativeFeedback
405-
onPress={() => console.log('custom TNF has been clicked')}
406-
background={TouchableNativeFeedback.SelectableBackground()}>
407-
<View style={[styles.row, styles.block]}>
408-
<Text style={[styles.button, styles.nativeFeedbackButton]}>
409-
Enabled TouchableNativeFeedback
410-
</Text>
411-
</View>
412-
</TouchableNativeFeedback>
413-
)}
404+
<>
405+
<TouchableNativeFeedback
406+
onPress={() => console.log('custom TNF has been clicked')}
407+
background={TouchableNativeFeedback.SelectableBackground()}>
408+
<View style={[styles.row, styles.block]}>
409+
<Text style={[styles.button, styles.nativeFeedbackButton]}>
410+
Enabled TouchableNativeFeedback
411+
</Text>
412+
</View>
413+
</TouchableNativeFeedback>
414414

415-
{Platform.OS === 'android' && (
416-
<TouchableNativeFeedback
417-
disabled={true}
418-
onPress={() => console.log('custom TNF has been clicked')}
419-
background={TouchableNativeFeedback.SelectableBackground()}>
420-
<View style={[styles.row, styles.block]}>
421-
<Text
422-
style={[styles.disabledButton, styles.nativeFeedbackButton]}>
423-
Disabled TouchableNativeFeedback
424-
</Text>
425-
</View>
426-
</TouchableNativeFeedback>
415+
<TouchableNativeFeedback
416+
disabled={true}
417+
onPress={() => console.log('custom TNF has been clicked')}
418+
background={TouchableNativeFeedback.SelectableBackground()}>
419+
<View style={[styles.row, styles.block]}>
420+
<Text
421+
style={[styles.disabledButton, styles.nativeFeedbackButton]}>
422+
Disabled TouchableNativeFeedback
423+
</Text>
424+
</View>
425+
</TouchableNativeFeedback>
426+
</>
427427
)}
428428
</View>
429429
);
430430
}
431431
}
432432

433+
function CustomRippleRadius() {
434+
if (Platform.OS !== 'android') {
435+
return null;
436+
}
437+
return (
438+
<View
439+
style={[
440+
styles.row,
441+
{justifyContent: 'space-around', alignItems: 'center'},
442+
]}>
443+
<TouchableNativeFeedback
444+
onPress={() => console.log('custom TNF has been clicked')}
445+
background={TouchableNativeFeedback.Ripple('orange', true, 30)}>
446+
<View>
447+
<Text style={[styles.button, styles.nativeFeedbackButton]}>
448+
radius 30
449+
</Text>
450+
</View>
451+
</TouchableNativeFeedback>
452+
453+
<TouchableNativeFeedback
454+
onPress={() => console.log('custom TNF has been clicked')}
455+
background={TouchableNativeFeedback.SelectableBackgroundBorderless(50)}>
456+
<View>
457+
<Text style={[styles.button, styles.nativeFeedbackButton]}>
458+
radius 50
459+
</Text>
460+
</View>
461+
</TouchableNativeFeedback>
462+
463+
<TouchableNativeFeedback
464+
onPress={() => console.log('custom TNF has been clicked')}
465+
background={TouchableNativeFeedback.SelectableBackground(70)}>
466+
<View style={styles.block}>
467+
<Text style={[styles.button, styles.nativeFeedbackButton]}>
468+
radius 70, with border
469+
</Text>
470+
</View>
471+
</TouchableNativeFeedback>
472+
</View>
473+
);
474+
}
475+
433476
const remoteImage = {
434477
uri: 'https://www.facebook.com/favicon.ico',
435478
};
@@ -611,4 +654,11 @@ exports.examples = [
611654
return <TouchableDisabled />;
612655
},
613656
},
657+
{
658+
title: 'Custom Ripple Radius (Android-only)',
659+
description: ('Ripple radius on TouchableNativeFeedback can be controlled': string),
660+
render: function(): React.Element<any> {
661+
return <CustomRippleRadius />;
662+
},
663+
},
614664
];

ReactAndroid/src/main/java/com/facebook/react/views/view/ReactDrawableHelper.java

+69-37
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616
import android.graphics.drawable.RippleDrawable;
1717
import android.os.Build;
1818
import android.util.TypedValue;
19+
20+
import androidx.annotation.Nullable;
21+
1922
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
2023
import com.facebook.react.bridge.ReadableMap;
2124
import com.facebook.react.bridge.SoftAssertions;
25+
import com.facebook.react.uimanager.PixelUtil;
2226
import com.facebook.react.uimanager.ViewProps;
2327

2428
/**
@@ -41,48 +45,76 @@ public static Drawable createDrawableFromJSDescription(
4145
throw new JSApplicationIllegalArgumentException(
4246
"Attribute " + attr + " couldn't be found in the resource list");
4347
}
44-
if (context.getTheme().resolveAttribute(attrID, sResolveOutValue, true)) {
45-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
46-
return context
47-
.getResources()
48-
.getDrawable(sResolveOutValue.resourceId, context.getTheme());
49-
} else {
50-
return context.getResources().getDrawable(sResolveOutValue.resourceId);
51-
}
52-
} else {
48+
if (!context.getTheme().resolveAttribute(attrID, sResolveOutValue, true)) {
5349
throw new JSApplicationIllegalArgumentException(
54-
"Attribute " + attr + " couldn't be resolved into a drawable");
50+
"Attribute " + attr + " couldn't be resolved into a drawable");
5551
}
52+
Drawable drawable = getDefaultThemeDrawable(context);
53+
return setRadius(drawableDescriptionDict, drawable);
5654
} else if ("RippleAndroid".equals(type)) {
57-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
58-
throw new JSApplicationIllegalArgumentException(
59-
"Ripple drawable is not available on " + "android API <21");
60-
}
61-
int color;
62-
if (drawableDescriptionDict.hasKey(ViewProps.COLOR)
63-
&& !drawableDescriptionDict.isNull(ViewProps.COLOR)) {
64-
color = drawableDescriptionDict.getInt(ViewProps.COLOR);
65-
} else {
66-
if (context
67-
.getTheme()
68-
.resolveAttribute(android.R.attr.colorControlHighlight, sResolveOutValue, true)) {
69-
color = context.getResources().getColor(sResolveOutValue.resourceId);
70-
} else {
71-
throw new JSApplicationIllegalArgumentException(
72-
"Attribute colorControlHighlight " + "couldn't be resolved into a drawable");
73-
}
74-
}
75-
Drawable mask = null;
76-
if (!drawableDescriptionDict.hasKey("borderless")
77-
|| drawableDescriptionDict.isNull("borderless")
78-
|| !drawableDescriptionDict.getBoolean("borderless")) {
79-
mask = new ColorDrawable(Color.WHITE);
80-
}
81-
ColorStateList colorStateList =
82-
new ColorStateList(new int[][] {new int[] {}}, new int[] {color});
83-
return new RippleDrawable(colorStateList, null, mask);
55+
RippleDrawable rd = getRippleDrawable(context, drawableDescriptionDict);
56+
return setRadius(drawableDescriptionDict, rd);
8457
} else {
8558
throw new JSApplicationIllegalArgumentException("Invalid type for android drawable: " + type);
8659
}
8760
}
61+
62+
private static Drawable getDefaultThemeDrawable(Context context) {
63+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
64+
return context
65+
.getResources()
66+
.getDrawable(sResolveOutValue.resourceId, context.getTheme());
67+
} else {
68+
return context.getResources().getDrawable(sResolveOutValue.resourceId);
69+
}
70+
}
71+
72+
private static RippleDrawable getRippleDrawable(Context context, ReadableMap drawableDescriptionDict) {
73+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
74+
throw new JSApplicationIllegalArgumentException(
75+
"Ripple drawable is not available on " + "android API <21");
76+
}
77+
int color = getColor(context, drawableDescriptionDict);
78+
Drawable mask = getMask(drawableDescriptionDict);
79+
ColorStateList colorStateList =
80+
new ColorStateList(new int[][] {new int[] {}}, new int[] {color});
81+
82+
return new RippleDrawable(colorStateList, null, mask);
83+
}
84+
85+
private static Drawable setRadius(ReadableMap drawableDescriptionDict, Drawable drawable) {
86+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
87+
&& drawableDescriptionDict.hasKey("rippleRadius")
88+
&& drawable instanceof RippleDrawable) {
89+
RippleDrawable rippleDrawable = (RippleDrawable) drawable;
90+
double rippleRadius = drawableDescriptionDict.getDouble("rippleRadius");
91+
rippleDrawable.setRadius((int) PixelUtil.toPixelFromDIP(rippleRadius));
92+
}
93+
return drawable;
94+
}
95+
96+
private static int getColor(Context context, ReadableMap drawableDescriptionDict) {
97+
if (drawableDescriptionDict.hasKey(ViewProps.COLOR)
98+
&& !drawableDescriptionDict.isNull(ViewProps.COLOR)) {
99+
return drawableDescriptionDict.getInt(ViewProps.COLOR);
100+
} else {
101+
if (context
102+
.getTheme()
103+
.resolveAttribute(android.R.attr.colorControlHighlight, sResolveOutValue, true)) {
104+
return context.getResources().getColor(sResolveOutValue.resourceId);
105+
} else {
106+
throw new JSApplicationIllegalArgumentException(
107+
"Attribute colorControlHighlight " + "couldn't be resolved into a drawable");
108+
}
109+
}
110+
}
111+
112+
private static @Nullable Drawable getMask(ReadableMap drawableDescriptionDict) {
113+
if (!drawableDescriptionDict.hasKey("borderless")
114+
|| drawableDescriptionDict.isNull("borderless")
115+
|| !drawableDescriptionDict.getBoolean("borderless")) {
116+
return new ColorDrawable(Color.WHITE);
117+
}
118+
return null;
119+
}
88120
}

0 commit comments

Comments
 (0)