Repository navigation
fix(button): prevent false ripples during scroll deceleration (#4911) #5133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
44e1129
459e4a5
70af55f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,6 +110,12 @@ export type Props = Omit<ViewProps, 'style'> & { | |
| * The number of milliseconds a user must touch the element before executing `onLongPress`. | ||
| */ | ||
| delayLongPress?: number; | ||
| /** | ||
| * Delay in ms, from the start of the touch, before the press feedback (ripple) is shown (Native-only). | ||
| * Quick taps shorter than this delay will still fire `onPress` but might not show the ripple effect. | ||
| * Recommended values for buttons in scrollable lists are between 50ms and 100ms. | ||
| */ | ||
| unstable_pressDelay?: number; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we check how this lands against #5097 ? that PR is actively rewriting this file & already covers all 3 places this PR edits, so it'll definitely conflict.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocker] another thing to flag: API leak guard in
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| /** | ||
| * Style of button's inner content. | ||
| * Use this prop to apply custom height and width, to set a custom padding or to set the icon on the right with `flexDirection: 'row-reverse'`. | ||
|
|
@@ -179,6 +185,7 @@ const Button = ({ | |
| onPressOut, | ||
| onLongPress, | ||
| delayLongPress, | ||
| unstable_pressDelay, | ||
| style, | ||
| theme: themeOverrides, | ||
| uppercase: uppercaseProp, | ||
|
|
@@ -327,6 +334,7 @@ const Button = ({ | |
| onPressIn={hasPassedTouchHandler ? handlePressIn : undefined} | ||
| onPressOut={hasPassedTouchHandler ? handlePressOut : undefined} | ||
| delayLongPress={delayLongPress} | ||
| unstable_pressDelay={unstable_pressDelay} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what do you think about fast-tap case?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, |
||
| aria-label={ariaLabel} | ||
| accessibilityHint={accessibilityHint} | ||
| role={role} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ import { StyleSheet } from 'react-native'; | |
| import { describe, expect, it, jest } from '@jest/globals'; | ||
| import { userEvent } from '@testing-library/react-native'; | ||
|
|
||
| import { render, screen } from '../../test-utils'; | ||
| import { act, fireEvent, render, screen } from '../../test-utils'; | ||
| import { pink500, white } from '../../theme/colors'; | ||
| import { DarkTheme, LightTheme } from '../../theme/schemes'; | ||
| import { tokens } from '../../theme/tokens'; | ||
|
|
@@ -98,6 +98,104 @@ it('renders active button if only onLongPress handler is passed', async () => { | |
| expect(screen.getByTestId('active-button')).toBeEnabled(); | ||
| }); | ||
|
|
||
| const createResponderEvent = () => ({ | ||
| persist: () => {}, | ||
| nativeEvent: { timestamp: Date.now() }, | ||
| currentTarget: { | ||
| measure: (callback: any) => { | ||
| callback(0, 0, 100, 100, 0, 0); | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| it('suppresses onPressIn when touch is terminated before unstable_pressDelay', async () => { | ||
| const onPressIn = jest.fn(); | ||
| const onPress = jest.fn(); | ||
|
|
||
| await render( | ||
| <Button | ||
| testID="delay-button" | ||
| unstable_pressDelay={100} | ||
| onPressIn={onPressIn} | ||
| onPress={onPress} | ||
| > | ||
| Delay Button | ||
| </Button> | ||
| ); | ||
|
|
||
| const button = screen.getByTestId('delay-button'); | ||
|
|
||
| await fireEvent(button, 'responderGrant', createResponderEvent()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we add one more test for the most common case - quick tap that actually completes? right now 2 tests cover:
what's missing is in-between: user taps for less than the delay & lifts finger normally ( That's the everyday fast tap, and it's the case new docblock promises works: |
||
| await act(() => { | ||
| jest.advanceTimersByTime(50); | ||
| }); | ||
| await fireEvent(button, 'responderTerminate', createResponderEvent()); | ||
| await act(() => { | ||
| jest.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(onPressIn).not.toHaveBeenCalled(); | ||
| expect(onPress).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('triggers onPressIn after unstable_pressDelay when touch is not terminated', async () => { | ||
| const onPressIn = jest.fn(); | ||
| const onPress = jest.fn(); | ||
|
|
||
| await render( | ||
| <Button | ||
| testID="delay-button" | ||
| unstable_pressDelay={100} | ||
| onPressIn={onPressIn} | ||
| onPress={onPress} | ||
| > | ||
| Delay Button | ||
| </Button> | ||
| ); | ||
|
|
||
| const button = screen.getByTestId('delay-button'); | ||
|
|
||
| await fireEvent(button, 'responderGrant', createResponderEvent()); | ||
| expect(onPressIn).not.toHaveBeenCalled(); | ||
|
|
||
| await act(() => { | ||
| jest.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| expect(onPressIn).toHaveBeenCalledTimes(1); | ||
|
|
||
| await fireEvent(button, 'responderRelease', createResponderEvent()); | ||
| expect(onPress).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('triggers onPress for fast taps shorter than unstable_pressDelay', async () => { | ||
| const onPressIn = jest.fn(); | ||
| const onPress = jest.fn(); | ||
|
|
||
| await render( | ||
| <Button | ||
| testID="delay-button" | ||
| unstable_pressDelay={100} | ||
| onPressIn={onPressIn} | ||
| onPress={onPress} | ||
| > | ||
| Delay Button | ||
| </Button> | ||
| ); | ||
|
|
||
| const button = screen.getByTestId('delay-button'); | ||
|
|
||
| await fireEvent(button, 'responderGrant', createResponderEvent()); | ||
| await act(() => { | ||
| jest.advanceTimersByTime(60); | ||
| }); | ||
| expect(onPressIn).not.toHaveBeenCalled(); | ||
|
|
||
| await fireEvent(button, 'responderRelease', createResponderEvent()); | ||
| expect(onPress).toHaveBeenCalledTimes(1); | ||
| expect(onPressIn).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('renders button with color', async () => { | ||
| const tree = ( | ||
| await render(<Button textColor={pink500}>Custom Button</Button>) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] could we tag this native-only, like
backgroundis marked "(Android)" above?react-native-webdoesn't implementunstable_pressDelay, so does nothing on web