Skip to content

Commit c77ad85

Browse files
committed
feat: add debounce functionality to TouchableRipple
- Add optional debounce prop to prevent rapid successive presses - Implement debounce logic in both web and native TouchableRipple components - Add comprehensive test interface in TouchableRipple example - Update documentation with usage examples - Maintain backward compatibility with existing code
1 parent ff0df54 commit c77ad85

File tree

4 files changed

+263
-17
lines changed

4 files changed

+263
-17
lines changed

example/src/Examples/TouchableRippleExample.tsx

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,108 @@
11
import * as React from 'react';
2-
import { View, StyleSheet } from 'react-native';
2+
import { View, StyleSheet, ScrollView } from 'react-native';
33

4-
import { Text, TouchableRipple } from 'react-native-paper';
4+
import { Text, TouchableRipple, Button, Divider } from 'react-native-paper';
55

66
import ScreenWrapper from '../ScreenWrapper';
77

88
const RippleExample = () => {
9+
const [pressCount, setPressCount] = React.useState(0);
10+
const [debouncedPressCount, setDebouncedPressCount] = React.useState(0);
11+
const [lastPressTime, setLastPressTime] = React.useState<number | null>(null);
12+
13+
const handleNormalPress = () => {
14+
const now = Date.now();
15+
setPressCount((prev) => prev + 1);
16+
setLastPressTime(now);
17+
};
18+
19+
const handleDebouncedPress = () => {
20+
const now = Date.now();
21+
setDebouncedPressCount((prev) => prev + 1);
22+
setLastPressTime(now);
23+
};
24+
25+
const resetCounters = () => {
26+
setPressCount(0);
27+
setDebouncedPressCount(0);
28+
setLastPressTime(null);
29+
};
30+
931
return (
10-
<ScreenWrapper contentContainerStyle={styles.container}>
11-
<TouchableRipple
12-
style={styles.ripple}
13-
onPress={() => {}}
14-
rippleColor="rgba(0, 0, 0, .32)"
15-
>
16-
<View pointerEvents="none">
17-
<Text variant="bodyMedium">Press anywhere</Text>
32+
<ScreenWrapper>
33+
<ScrollView contentContainerStyle={styles.container}>
34+
<View style={styles.section}>
35+
<Text variant="titleMedium" style={styles.sectionTitle}>
36+
Basic TouchableRipple
37+
</Text>
38+
<TouchableRipple
39+
style={styles.basicRipple}
40+
onPress={() => {}}
41+
rippleColor="rgba(0, 0, 0, .32)"
42+
>
43+
<View pointerEvents="none">
44+
<Text variant="bodyMedium">Press anywhere</Text>
45+
</View>
46+
</TouchableRipple>
1847
</View>
19-
</TouchableRipple>
48+
49+
<Divider style={styles.divider} />
50+
51+
<View style={styles.section}>
52+
<Text variant="titleMedium" style={styles.sectionTitle}>
53+
Debounce Test
54+
</Text>
55+
56+
<View style={styles.statsContainer}>
57+
<Text variant="bodyLarge">Normal Presses: {pressCount}</Text>
58+
<Text variant="bodyLarge">Debounced Presses: {debouncedPressCount}</Text>
59+
{lastPressTime && (
60+
<Text variant="bodyMedium" style={styles.timeText}>
61+
Last Press: {new Date(lastPressTime).toLocaleTimeString()}
62+
</Text>
63+
)}
64+
</View>
65+
66+
<Text variant="bodySmall" style={styles.instructionText}>
67+
Try clicking rapidly on both buttons to see the difference:
68+
</Text>
69+
70+
<TouchableRipple
71+
onPress={handleNormalPress}
72+
style={[styles.testButton, styles.normalButton]}
73+
rippleColor="rgba(33, 150, 243, 0.32)"
74+
>
75+
<View style={styles.buttonContent}>
76+
<Text variant="titleSmall" style={styles.normalButtonText}>
77+
Normal Button
78+
</Text>
79+
<Text variant="bodySmall" style={styles.buttonSubtext}>
80+
No debounce - all clicks counted
81+
</Text>
82+
</View>
83+
</TouchableRipple>
84+
85+
<TouchableRipple
86+
onPress={handleDebouncedPress}
87+
debounce={500} // 500ms debounce
88+
style={[styles.testButton, styles.debouncedButton]}
89+
rippleColor="rgba(76, 175, 80, 0.32)"
90+
>
91+
<View style={styles.buttonContent}>
92+
<Text variant="titleSmall" style={styles.debouncedButtonText}>
93+
Debounced Button (500ms)
94+
</Text>
95+
<Text variant="bodySmall" style={styles.buttonSubtext}>
96+
Rapid clicks ignored within 500ms
97+
</Text>
98+
</View>
99+
</TouchableRipple>
100+
101+
<Button mode="outlined" onPress={resetCounters} style={styles.resetButton}>
102+
Reset Counters
103+
</Button>
104+
</View>
105+
</ScrollView>
20106
</ScreenWrapper>
21107
);
22108
};
@@ -25,12 +111,72 @@ RippleExample.title = 'TouchableRipple';
25111

26112
const styles = StyleSheet.create({
27113
container: {
28-
flex: 1,
114+
padding: 16,
115+
},
116+
section: {
117+
marginBottom: 24,
118+
},
119+
sectionTitle: {
120+
marginBottom: 16,
121+
fontWeight: 'bold',
29122
},
30-
ripple: {
31-
flex: 1,
123+
basicRipple: {
124+
height: 150,
32125
alignItems: 'center',
33126
justifyContent: 'center',
127+
backgroundColor: '#f5f5f5',
128+
borderRadius: 8,
129+
},
130+
divider: {
131+
marginVertical: 16,
132+
},
133+
statsContainer: {
134+
padding: 16,
135+
backgroundColor: '#f0f0f0',
136+
borderRadius: 8,
137+
marginBottom: 16,
138+
},
139+
timeText: {
140+
marginTop: 8,
141+
color: '#666',
142+
},
143+
instructionText: {
144+
marginBottom: 16,
145+
color: '#666',
146+
textAlign: 'center',
147+
},
148+
testButton: {
149+
padding: 20,
150+
borderRadius: 8,
151+
marginBottom: 12,
152+
borderWidth: 1,
153+
},
154+
normalButton: {
155+
backgroundColor: '#e3f2fd',
156+
borderColor: '#2196f3',
157+
},
158+
debouncedButton: {
159+
backgroundColor: '#e8f5e8',
160+
borderColor: '#4caf50',
161+
},
162+
buttonContent: {
163+
alignItems: 'center',
164+
},
165+
normalButtonText: {
166+
color: '#1976d2',
167+
fontWeight: 'bold',
168+
},
169+
debouncedButtonText: {
170+
color: '#388e3c',
171+
fontWeight: 'bold',
172+
},
173+
buttonSubtext: {
174+
color: '#666',
175+
marginTop: 4,
176+
textAlign: 'center',
177+
},
178+
resetButton: {
179+
marginTop: 8,
34180
},
35181
});
36182

src/components/TouchableRipple/TouchableRipple.native.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export type Props = PressableProps & {
3636
children: React.ReactNode;
3737
style?: StyleProp<ViewStyle>;
3838
theme?: ThemeProp;
39+
/**
40+
* Debounce time in milliseconds to prevent rapid successive presses.
41+
* When set, subsequent onPress calls within this time window will be ignored.
42+
*/
43+
debounce?: number;
3944
};
4045

4146
const TouchableRipple = (
@@ -48,6 +53,7 @@ const TouchableRipple = (
4853
underlayColor,
4954
children,
5055
theme: themeOverrides,
56+
debounce,
5157
...rest
5258
}: Props,
5359
ref: React.ForwardedRef<View>
@@ -56,6 +62,24 @@ const TouchableRipple = (
5662
const { rippleEffectEnabled } = React.useContext<Settings>(SettingsContext);
5763

5864
const { onPress, onLongPress, onPressIn, onPressOut } = rest;
65+
const lastPressTime = React.useRef<number>(0);
66+
67+
const debouncedOnPress = React.useCallback(
68+
(e: GestureResponderEvent) => {
69+
if (!onPress) return;
70+
71+
if (debounce && debounce > 0) {
72+
const now = Date.now();
73+
if (now - lastPressTime.current < debounce) {
74+
return; // Ignore this press as it's within the debounce window
75+
}
76+
lastPressTime.current = now;
77+
}
78+
79+
onPress(e);
80+
},
81+
[onPress, debounce]
82+
);
5983

6084
const hasPassedTouchHandler = hasTouchHandler({
6185
onPress,
@@ -96,6 +120,7 @@ const TouchableRipple = (
96120
disabled={disabled}
97121
style={[borderless && styles.overflowHidden, style]}
98122
android_ripple={androidRipple}
123+
onPress={debouncedOnPress}
99124
>
100125
{React.Children.only(children)}
101126
</Pressable>
@@ -108,8 +133,9 @@ const TouchableRipple = (
108133
ref={ref}
109134
disabled={disabled}
110135
style={[borderless && styles.overflowHidden, style]}
136+
onPress={debouncedOnPress}
111137
>
112-
{({ pressed }) => (
138+
{({ pressed }: { pressed: boolean }) => (
113139
<>
114140
{pressed && rippleEffectEnabled && (
115141
<View

src/components/TouchableRipple/TouchableRipple.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export type Props = PressableProps & {
4646
* Function to execute on long press.
4747
*/
4848
onLongPress?: (e: GestureResponderEvent) => void;
49+
/**
50+
* Debounce time in milliseconds to prevent rapid successive presses.
51+
* When set, subsequent onPress calls within this time window will be ignored.
52+
*/
53+
debounce?: number;
4954
/**
5055
* Function to execute immediately when a touch is engaged, before `onPressOut` and `onPress`.
5156
*/
@@ -93,6 +98,7 @@ export type Props = PressableProps & {
9398
* <TouchableRipple
9499
* onPress={() => console.log('Pressed')}
95100
* rippleColor="rgba(0, 0, 0, .32)"
101+
* debounce={300} // Prevent double-clicks within 300ms
96102
* >
97103
* <Text>Press anywhere</Text>
98104
* </TouchableRipple>
@@ -113,6 +119,7 @@ const TouchableRipple = (
113119
underlayColor: _underlayColor,
114120
children,
115121
theme: themeOverrides,
122+
debounce,
116123
...rest
117124
}: Props,
118125
ref: React.ForwardedRef<View>
@@ -126,6 +133,24 @@ const TouchableRipple = (
126133
const { rippleEffectEnabled } = React.useContext<Settings>(SettingsContext);
127134

128135
const { onPress, onLongPress, onPressIn, onPressOut } = rest;
136+
const lastPressTime = React.useRef<number>(0);
137+
138+
const debouncedOnPress = React.useCallback(
139+
(e: GestureResponderEvent) => {
140+
if (!onPress) return;
141+
142+
if (debounce && debounce > 0) {
143+
const now = Date.now();
144+
if (now - lastPressTime.current < debounce) {
145+
return; // Ignore this press as it's within the debounce window
146+
}
147+
lastPressTime.current = now;
148+
}
149+
150+
onPress(e);
151+
},
152+
[onPress, debounce]
153+
);
129154

130155
const handlePressIn = React.useCallback(
131156
(e: any) => {
@@ -273,10 +298,11 @@ const TouchableRipple = (
273298
<Pressable
274299
{...rest}
275300
ref={ref}
301+
onPress={debouncedOnPress}
276302
onPressIn={handlePressIn}
277303
onPressOut={handlePressOut}
278304
disabled={disabled}
279-
style={(state) => [
305+
style={(state: PressableStateCallbackType) => [
280306
styles.touchable,
281307
borderless && styles.borderless,
282308
// focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849
@@ -286,7 +312,7 @@ const TouchableRipple = (
286312
typeof style === 'function' ? style(state) : style,
287313
]}
288314
>
289-
{(state) =>
315+
{(state: PressableStateCallbackType) =>
290316
React.Children.only(
291317
typeof children === 'function' ? children(state) : children
292318
)

src/components/__tests__/TouchableRipple.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,54 @@ describe('TouchableRipple', () => {
2929
expect(onPress).toHaveBeenCalledTimes(1);
3030
});
3131

32+
it('debounces onPress when debounce prop is provided', () => {
33+
jest.useFakeTimers();
34+
const onPress = jest.fn();
35+
const { getByText } = render(
36+
<TouchableRipple onPress={onPress} debounce={300}>
37+
<Text>Button</Text>
38+
</TouchableRipple>
39+
);
40+
41+
const button = getByText('Button');
42+
43+
// Press multiple times rapidly
44+
fireEvent.press(button);
45+
fireEvent.press(button);
46+
fireEvent.press(button);
47+
48+
// Should only be called once due to debouncing
49+
expect(onPress).toHaveBeenCalledTimes(1);
50+
51+
// Fast forward time past debounce window
52+
jest.advanceTimersByTime(400);
53+
54+
// Now pressing should work again
55+
fireEvent.press(button);
56+
expect(onPress).toHaveBeenCalledTimes(2);
57+
58+
jest.useRealTimers();
59+
});
60+
61+
it('does not debounce when debounce is not provided', () => {
62+
const onPress = jest.fn();
63+
const { getByText } = render(
64+
<TouchableRipple onPress={onPress}>
65+
<Text>Button</Text>
66+
</TouchableRipple>
67+
);
68+
69+
const button = getByText('Button');
70+
71+
// Press multiple times rapidly
72+
fireEvent.press(button);
73+
fireEvent.press(button);
74+
fireEvent.press(button);
75+
76+
// Should be called for each press
77+
expect(onPress).toHaveBeenCalledTimes(3);
78+
});
79+
3280
it('disables the button when disabled prop is true', () => {
3381
const onPress = jest.fn();
3482
const { getByText } = render(

0 commit comments

Comments
 (0)