Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 160 additions & 14 deletions example/src/Examples/TouchableRippleExample.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,108 @@
import * as React from 'react';
import { View, StyleSheet } from 'react-native';
import { View, StyleSheet, ScrollView } from 'react-native';

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

import ScreenWrapper from '../ScreenWrapper';

const RippleExample = () => {
const [pressCount, setPressCount] = React.useState(0);
const [debouncedPressCount, setDebouncedPressCount] = React.useState(0);
const [lastPressTime, setLastPressTime] = React.useState<number | null>(null);

const handleNormalPress = () => {
const now = Date.now();
setPressCount((prev) => prev + 1);
setLastPressTime(now);
};

const handleDebouncedPress = () => {
const now = Date.now();
setDebouncedPressCount((prev) => prev + 1);
setLastPressTime(now);
};

const resetCounters = () => {
setPressCount(0);
setDebouncedPressCount(0);
setLastPressTime(null);
};

return (
<ScreenWrapper contentContainerStyle={styles.container}>
<TouchableRipple
style={styles.ripple}
onPress={() => {}}
rippleColor="rgba(0, 0, 0, .32)"
>
<View pointerEvents="none">
<Text variant="bodyMedium">Press anywhere</Text>
<ScreenWrapper>
<ScrollView contentContainerStyle={styles.container}>
<View style={styles.section}>
<Text variant="titleMedium" style={styles.sectionTitle}>
Basic TouchableRipple
</Text>
<TouchableRipple
style={styles.basicRipple}
onPress={() => {}}
rippleColor="rgba(0, 0, 0, .32)"
>
<View pointerEvents="none">
<Text variant="bodyMedium">Press anywhere</Text>
</View>
</TouchableRipple>
</View>
</TouchableRipple>

<Divider style={styles.divider} />

<View style={styles.section}>
<Text variant="titleMedium" style={styles.sectionTitle}>
Debounce Test
</Text>

<View style={styles.statsContainer}>
<Text variant="bodyLarge">Normal Presses: {pressCount}</Text>
<Text variant="bodyLarge">Debounced Presses: {debouncedPressCount}</Text>
{lastPressTime && (
<Text variant="bodyMedium" style={styles.timeText}>
Last Press: {new Date(lastPressTime).toLocaleTimeString()}
</Text>
)}
</View>

<Text variant="bodySmall" style={styles.instructionText}>
Try clicking rapidly on both buttons to see the difference:
</Text>

<TouchableRipple
onPress={handleNormalPress}
style={[styles.testButton, styles.normalButton]}
rippleColor="rgba(33, 150, 243, 0.32)"
>
<View style={styles.buttonContent}>
<Text variant="titleSmall" style={styles.normalButtonText}>
Normal Button
</Text>
<Text variant="bodySmall" style={styles.buttonSubtext}>
No debounce - all clicks counted
</Text>
</View>
</TouchableRipple>

<TouchableRipple
onPress={handleDebouncedPress}
debounce={500} // 500ms debounce
style={[styles.testButton, styles.debouncedButton]}
rippleColor="rgba(76, 175, 80, 0.32)"
>
<View style={styles.buttonContent}>
<Text variant="titleSmall" style={styles.debouncedButtonText}>
Debounced Button (500ms)
</Text>
<Text variant="bodySmall" style={styles.buttonSubtext}>
Rapid clicks ignored within 500ms
</Text>
</View>
</TouchableRipple>

<Button mode="outlined" onPress={resetCounters} style={styles.resetButton}>
Reset Counters
</Button>
</View>
</ScrollView>
</ScreenWrapper>
);
};
Expand All @@ -25,12 +111,72 @@ RippleExample.title = 'TouchableRipple';

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
section: {
marginBottom: 24,
},
sectionTitle: {
marginBottom: 16,
fontWeight: 'bold',
},
ripple: {
flex: 1,
basicRipple: {
height: 150,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 8,
},
divider: {
marginVertical: 16,
},
statsContainer: {
padding: 16,
backgroundColor: '#f0f0f0',
borderRadius: 8,
marginBottom: 16,
},
timeText: {
marginTop: 8,
color: '#666',
},
instructionText: {
marginBottom: 16,
color: '#666',
textAlign: 'center',
},
testButton: {
padding: 20,
borderRadius: 8,
marginBottom: 12,
borderWidth: 1,
},
normalButton: {
backgroundColor: '#e3f2fd',
borderColor: '#2196f3',
},
debouncedButton: {
backgroundColor: '#e8f5e8',
borderColor: '#4caf50',
},
buttonContent: {
alignItems: 'center',
},
normalButtonText: {
color: '#1976d2',
fontWeight: 'bold',
},
debouncedButtonText: {
color: '#388e3c',
fontWeight: 'bold',
},
buttonSubtext: {
color: '#666',
marginTop: 4,
textAlign: 'center',
},
resetButton: {
marginTop: 8,
},
});

Expand Down
28 changes: 27 additions & 1 deletion src/components/TouchableRipple/TouchableRipple.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export type Props = PressableProps & {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
theme?: ThemeProp;
/**
* Debounce time in milliseconds to prevent rapid successive presses.
* When set, subsequent onPress calls within this time window will be ignored.
*/
debounce?: number;
};

const TouchableRipple = (
Expand All @@ -48,6 +53,7 @@ const TouchableRipple = (
underlayColor,
children,
theme: themeOverrides,
debounce,
...rest
}: Props,
ref: React.ForwardedRef<View>
Expand All @@ -56,6 +62,24 @@ const TouchableRipple = (
const { rippleEffectEnabled } = React.useContext<Settings>(SettingsContext);

const { onPress, onLongPress, onPressIn, onPressOut } = rest;
const lastPressTime = React.useRef<number>(0);

const debouncedOnPress = React.useCallback(
(e: GestureResponderEvent) => {
if (!onPress) return;

if (debounce && debounce > 0) {
const now = Date.now();
if (now - lastPressTime.current < debounce) {
return; // Ignore this press as it's within the debounce window
}
lastPressTime.current = now;
}

onPress(e);
},
[onPress, debounce]
);

const hasPassedTouchHandler = hasTouchHandler({
onPress,
Expand Down Expand Up @@ -96,6 +120,7 @@ const TouchableRipple = (
disabled={disabled}
style={[borderless && styles.overflowHidden, style]}
android_ripple={androidRipple}
onPress={debouncedOnPress}
>
{React.Children.only(children)}
</Pressable>
Expand All @@ -108,8 +133,9 @@ const TouchableRipple = (
ref={ref}
disabled={disabled}
style={[borderless && styles.overflowHidden, style]}
onPress={debouncedOnPress}
>
{({ pressed }) => (
{({ pressed }: { pressed: boolean }) => (
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Adding inline type annotation for a destructured parameter is unnecessarily verbose. TypeScript can infer this type from the Pressable component's children render prop. Consider removing the type annotation unless there's a specific type inference issue.

Suggested change
{({ pressed }: { pressed: boolean }) => (
{({ pressed }) => (

Copilot uses AI. Check for mistakes.
<>
{pressed && rippleEffectEnabled && (
<View
Expand Down
30 changes: 28 additions & 2 deletions src/components/TouchableRipple/TouchableRipple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export type Props = PressableProps & {
* Function to execute on long press.
*/
onLongPress?: (e: GestureResponderEvent) => void;
/**
* Debounce time in milliseconds to prevent rapid successive presses.
* When set, subsequent onPress calls within this time window will be ignored.
*/
debounce?: number;
/**
* Function to execute immediately when a touch is engaged, before `onPressOut` and `onPress`.
*/
Expand Down Expand Up @@ -93,6 +98,7 @@ export type Props = PressableProps & {
* <TouchableRipple
* onPress={() => console.log('Pressed')}
* rippleColor="rgba(0, 0, 0, .32)"
* debounce={300} // Prevent double-clicks within 300ms
* >
* <Text>Press anywhere</Text>
* </TouchableRipple>
Expand All @@ -113,6 +119,7 @@ const TouchableRipple = (
underlayColor: _underlayColor,
children,
theme: themeOverrides,
debounce,
...rest
}: Props,
ref: React.ForwardedRef<View>
Expand All @@ -126,6 +133,24 @@ const TouchableRipple = (
const { rippleEffectEnabled } = React.useContext<Settings>(SettingsContext);

const { onPress, onLongPress, onPressIn, onPressOut } = rest;
const lastPressTime = React.useRef<number>(0);

const debouncedOnPress = React.useCallback(
(e: GestureResponderEvent) => {
if (!onPress) return;

if (debounce && debounce > 0) {
const now = Date.now();
if (now - lastPressTime.current < debounce) {
return; // Ignore this press as it's within the debounce window
}
lastPressTime.current = now;
}

onPress(e);
},
[onPress, debounce]
);

const handlePressIn = React.useCallback(
(e: any) => {
Expand Down Expand Up @@ -273,10 +298,11 @@ const TouchableRipple = (
<Pressable
{...rest}
ref={ref}
onPress={debouncedOnPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={(state) => [
style={(state: PressableStateCallbackType) => [
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Adding explicit type annotation here is unnecessary since TypeScript can infer the type from the Pressable component's props. This change adds no value and increases verbosity. Consider removing the type annotation unless there's a specific type inference issue.

Copilot uses AI. Check for mistakes.
styles.touchable,
borderless && styles.borderless,
// focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849
Expand All @@ -286,7 +312,7 @@ const TouchableRipple = (
typeof style === 'function' ? style(state) : style,
]}
>
{(state) =>
{(state: PressableStateCallbackType) =>
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Adding explicit type annotation here is unnecessary since TypeScript can infer the type from the Pressable component's children render prop. This change adds no value and increases verbosity. Consider removing the type annotation unless there's a specific type inference issue.

Suggested change
{(state: PressableStateCallbackType) =>
{state =>

Copilot uses AI. Check for mistakes.
React.Children.only(
typeof children === 'function' ? children(state) : children
)
Expand Down
48 changes: 48 additions & 0 deletions src/components/__tests__/TouchableRipple.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,54 @@ describe('TouchableRipple', () => {
expect(onPress).toHaveBeenCalledTimes(1);
});

it('debounces onPress when debounce prop is provided', () => {
jest.useFakeTimers();
const onPress = jest.fn();
const { getByText } = render(
<TouchableRipple onPress={onPress} debounce={300}>
<Text>Button</Text>
</TouchableRipple>
);

const button = getByText('Button');

// Press multiple times rapidly
fireEvent.press(button);
fireEvent.press(button);
fireEvent.press(button);

// Should only be called once due to debouncing
expect(onPress).toHaveBeenCalledTimes(1);

// Fast forward time past debounce window
jest.advanceTimersByTime(400);
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test advances timers by 400ms, but the debounce implementation uses Date.now() which is not affected by jest.advanceTimersByTime(). This test will likely fail because Date.now() returns real time, not fake timer time. Mock Date.now() using jest.spyOn(Date, 'now') and control its return value, or use jest.setSystemTime() if using modern fake timers.

Copilot uses AI. Check for mistakes.

// Now pressing should work again
fireEvent.press(button);
expect(onPress).toHaveBeenCalledTimes(2);

jest.useRealTimers();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Unmocked Time Improves Debounce Testing Reliability

The debounce test uses jest.useFakeTimers() and jest.advanceTimersByTime(), but the debounce implementation relies on Date.now(). Since Date.now() isn't mocked by Jest's fake timers by default, advanceTimersByTime() doesn't affect the debounce logic's internal clock. This makes the test unreliable and unable to accurately verify the debounce behavior.

Fix in Cursor Fix in Web


it('does not debounce when debounce is not provided', () => {
const onPress = jest.fn();
const { getByText } = render(
<TouchableRipple onPress={onPress}>
<Text>Button</Text>
</TouchableRipple>
);

const button = getByText('Button');

// Press multiple times rapidly
fireEvent.press(button);
fireEvent.press(button);
fireEvent.press(button);

// Should be called for each press
expect(onPress).toHaveBeenCalledTimes(3);
});

it('disables the button when disabled prop is true', () => {
const onPress = jest.fn();
const { getByText } = render(
Expand Down