diff --git a/example/src/Examples/TouchableRippleExample.tsx b/example/src/Examples/TouchableRippleExample.tsx index 37d8c6c8c1..9bee5f569d 100644 --- a/example/src/Examples/TouchableRippleExample.tsx +++ b/example/src/Examples/TouchableRippleExample.tsx @@ -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(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 ( - - {}} - rippleColor="rgba(0, 0, 0, .32)" - > - - Press anywhere + + + + + Basic TouchableRipple + + {}} + rippleColor="rgba(0, 0, 0, .32)" + > + + Press anywhere + + - + + + + + + Debounce Test + + + + Normal Presses: {pressCount} + Debounced Presses: {debouncedPressCount} + {lastPressTime && ( + + Last Press: {new Date(lastPressTime).toLocaleTimeString()} + + )} + + + + Try clicking rapidly on both buttons to see the difference: + + + + + + Normal Button + + + No debounce - all clicks counted + + + + + + + + Debounced Button (500ms) + + + Rapid clicks ignored within 500ms + + + + + + + ); }; @@ -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, }, }); diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index 1cb17b4068..306d4062e2 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -36,6 +36,11 @@ export type Props = PressableProps & { children: React.ReactNode; style?: StyleProp; 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 = ( @@ -48,6 +53,7 @@ const TouchableRipple = ( underlayColor, children, theme: themeOverrides, + debounce, ...rest }: Props, ref: React.ForwardedRef @@ -56,6 +62,24 @@ const TouchableRipple = ( const { rippleEffectEnabled } = React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; + const lastPressTime = React.useRef(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, @@ -96,6 +120,7 @@ const TouchableRipple = ( disabled={disabled} style={[borderless && styles.overflowHidden, style]} android_ripple={androidRipple} + onPress={debouncedOnPress} > {React.Children.only(children)} @@ -108,8 +133,9 @@ const TouchableRipple = ( ref={ref} disabled={disabled} style={[borderless && styles.overflowHidden, style]} + onPress={debouncedOnPress} > - {({ pressed }) => ( + {({ pressed }: { pressed: boolean }) => ( <> {pressed && rippleEffectEnabled && ( 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`. */ @@ -93,6 +98,7 @@ export type Props = PressableProps & { * console.log('Pressed')} * rippleColor="rgba(0, 0, 0, .32)" + * debounce={300} // Prevent double-clicks within 300ms * > * Press anywhere * @@ -113,6 +119,7 @@ const TouchableRipple = ( underlayColor: _underlayColor, children, theme: themeOverrides, + debounce, ...rest }: Props, ref: React.ForwardedRef @@ -126,6 +133,24 @@ const TouchableRipple = ( const { rippleEffectEnabled } = React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; + const lastPressTime = React.useRef(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) => { @@ -273,10 +298,11 @@ const TouchableRipple = ( [ + style={(state: PressableStateCallbackType) => [ styles.touchable, borderless && styles.borderless, // focused state is not ready yet: https://github.com/necolas/react-native-web/issues/1849 @@ -286,7 +312,7 @@ const TouchableRipple = ( typeof style === 'function' ? style(state) : style, ]} > - {(state) => + {(state: PressableStateCallbackType) => React.Children.only( typeof children === 'function' ? children(state) : children ) diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index c578605b3a..9da3dbb9bf 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -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( + + Button + + ); + + 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); + + // Now pressing should work again + fireEvent.press(button); + expect(onPress).toHaveBeenCalledTimes(2); + + jest.useRealTimers(); + }); + + it('does not debounce when debounce is not provided', () => { + const onPress = jest.fn(); + const { getByText } = render( + + Button + + ); + + 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(