Skip to content

fix: Button and IconButton TouchableRipple borderRadius #4278

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

Merged
merged 9 commits into from
Apr 22, 2025
14 changes: 14 additions & 0 deletions example/src/Examples/ButtonExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ const ButtonExample = () => {
<Button mode="contained" onPress={() => {}} style={styles.noRadius}>
Without radius
</Button>
<Button
mode="contained-tonal"
onPress={() => {}}
style={{ borderRadius: styles.customRadiusAndPadding.borderRadius }}
contentStyle={styles.customRadiusAndPadding}
>
Custom radius and padding
</Button>
</View>

<View style={styles.row}>
Expand Down Expand Up @@ -355,6 +363,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
paddingHorizontal: 12,
alignItems: 'center',
gap: 12,
},
button: {
margin: 4,
Expand Down Expand Up @@ -386,6 +395,11 @@ const styles = StyleSheet.create({
noRadius: {
borderRadius: 0,
},
customRadiusAndPadding: {
borderRadius: 4,
paddingHorizontal: 12,
paddingVertical: 6,
},
});

export default ButtonExample;
36 changes: 36 additions & 0 deletions example/src/Examples/IconButtonExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,31 @@ const ButtonExample = () => {
iconColor={MD3Colors.tertiary50}
onPress={() => {}}
/>
<IconButton
icon="eye"
mode="contained"
style={styles.slightlyRounded}
size={24}
contentStyle={{ padding: 8 }}
iconColor={MD3Colors.tertiary50}
onPress={() => {}}
/>
<IconButton
icon="heart"
mode="contained-tonal"
style={styles.differentBorderRadius}
size={24}
iconColor={MD3Colors.tertiary50}
onPress={() => {}}
/>
<IconButton
icon="heart"
mode="outlined"
style={styles.differentBorderRadius}
size={24}
iconColor={MD3Colors.tertiary50}
onPress={() => {}}
/>
<IconButton icon="camera" size={36} onPress={() => {}} />
<IconButton
icon="lock"
Expand Down Expand Up @@ -189,6 +214,17 @@ const styles = StyleSheet.create({
square: {
borderRadius: 0,
},
slightlyRounded: {
borderRadius: 4,
width: 48,
height: 48,
},
differentBorderRadius: {
borderTopLeftRadius: 2,
borderTopRightRadius: 4,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 6,
},
});

export default ButtonExample;
10 changes: 7 additions & 3 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {

import color from 'color';

import { ButtonMode, getButtonColors } from './utils';
import {
ButtonMode,
getButtonColors,
getButtonTouchableRippleStyle,
} from './utils';
import { useInternalTheme } from '../../core/theming';
import type { $Omit, ThemeProp } from '../../types';
import { forwardRef } from '../../utils/forwardRef';
Expand Down Expand Up @@ -123,7 +127,7 @@ export type Props = $Omit<React.ComponentProps<typeof Surface>, 'mode'> & {
delayLongPress?: number;
/**
* Style of button's inner content.
* Use this prop to apply custom height and width and to set the icon on the right with `flexDirection: 'row-reverse'`.
* 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'`.
*/
contentStyle?: StyleProp<ViewStyle>;
/**
Expand Down Expand Up @@ -350,7 +354,7 @@ const Button = (
accessible={accessible}
disabled={disabled}
rippleColor={rippleColor}
style={touchableStyle}
style={getButtonTouchableRippleStyle(touchableStyle, borderWidth)}
testID={testID}
theme={theme}
ref={touchableRef}
Expand Down
42 changes: 41 additions & 1 deletion src/components/Button/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { StyleSheet } from 'react-native';
import { StyleSheet, type ViewStyle } from 'react-native';

import color from 'color';

import { black, white } from '../../styles/themes/v2/colors';
import type { InternalTheme } from '../../types';
import { splitStyles } from '../../utils/splitStyles';

export type ButtonMode =
| 'text'
Expand Down Expand Up @@ -230,3 +231,42 @@ export const getButtonColors = ({
borderWidth,
};
};

type ViewStyleBorderRadiusStyles = Partial<
Pick<
ViewStyle,
| 'borderBottomEndRadius'
| 'borderBottomLeftRadius'
| 'borderBottomRightRadius'
| 'borderBottomStartRadius'
| 'borderTopEndRadius'
| 'borderTopLeftRadius'
| 'borderTopRightRadius'
| 'borderTopStartRadius'
| 'borderRadius'
>
>;
export const getButtonTouchableRippleStyle = (
Copy link
Member

Choose a reason for hiding this comment

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

Could you please add a unit test covering that functionality?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@lukewalczak for this specific function? or for the Button component? Because the previous tests were still passing, meaning the functionality did not affect their styles. The only test that changed was for the outlined button, where the result inner border radius is 19 (20 - 1 (border))

Copy link
Member

Choose a reason for hiding this comment

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

@jahirfiquitiva Perfectly for both cases

style?: ViewStyle,
borderWidth: number = 0
): ViewStyleBorderRadiusStyles => {
if (!style) return {};
const touchableRippleStyle: ViewStyleBorderRadiusStyles = {};

const [, borderRadiusStyles] = splitStyles(
style,
(style) => style.startsWith('border') && style.endsWith('Radius')
);

(
Object.keys(borderRadiusStyles) as Array<keyof ViewStyleBorderRadiusStyles>
).forEach((key) => {
const value = style[key as keyof ViewStyleBorderRadiusStyles];
if (typeof value === 'number') {
// Only subtract borderWidth if value is greater than 0
const radius = value > 0 ? value - borderWidth : 0;
touchableRippleStyle[key as keyof ViewStyleBorderRadiusStyles] = radius;
}
});
return touchableRippleStyle;
};
8 changes: 7 additions & 1 deletion src/components/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
* Accessibility label for the button. This is read by the screen reader when the user taps the button.
*/
accessibilityLabel?: string;
/**
* Style of button's inner content.
* Use this prop to apply custom height and width or to set a custom padding`.
*/
contentStyle?: StyleProp<ViewStyle>;
/**
* Function to execute on press.
*/
Expand Down Expand Up @@ -127,6 +132,7 @@ const IconButton = forwardRef<View, Props>(
theme: themeOverrides,
testID = 'icon-button',
loading = false,
contentStyle,
...rest
}: Props,
ref
Expand Down Expand Up @@ -183,7 +189,7 @@ const IconButton = forwardRef<View, Props>(
onPress={onPress}
rippleColor={rippleColor}
accessibilityLabel={accessibilityLabel}
style={[styles.touchable, { borderRadius }]}
style={[styles.touchable, contentStyle]}
// @ts-expect-error We keep old a11y props for backwards compat with old RN versions
accessibilityTraits={disabled ? ['button', 'disabled'] : 'button'}
accessibilityComponentType="button"
Expand Down
2 changes: 1 addition & 1 deletion src/components/TouchableRipple/TouchableRipple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const TouchableRipple = (
// Get the size of the button to determine how big the ripple should be
const size = centered
? // If ripple is always centered, we don't need to make it too big
Math.min(dimensions.width, dimensions.height) * 1.25
Math.min(dimensions.width, dimensions.height) * 1.5
: // Otherwise make it twice as big so clicking on one end spreads ripple to other
Math.max(dimensions.width, dimensions.height) * 2;

Expand Down
16 changes: 4 additions & 12 deletions src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,7 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
{
"borderRadius": 20,
},
undefined,
],
]
}
Expand Down Expand Up @@ -368,9 +366,7 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = `
"flexGrow": 1,
"justifyContent": "center",
},
{
"borderRadius": 20,
},
undefined,
],
]
}
Expand Down Expand Up @@ -560,9 +556,7 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A
"flexGrow": 1,
"justifyContent": "center",
},
{
"borderRadius": 20,
},
undefined,
],
]
}
Expand Down Expand Up @@ -803,9 +797,7 @@ exports[`Appbar passes additional props to AppbarBackAction, AppbarContent and A
"flexGrow": 1,
"justifyContent": "center",
},
{
"borderRadius": 20,
},
undefined,
],
]
}
Expand Down
22 changes: 22 additions & 0 deletions src/components/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,28 @@ it('renders button with custom border radius', () => {
expect(getByTestId('custom-radius')).toHaveStyle(styles.customRadius);
});

it('renders outlined button with custom border radius', () => {
const { getByTestId } = render(
<Button
mode={'outlined'}
testID="custom-radius"
style={styles.customRadius}
>
Custom radius
</Button>
);

expect(getByTestId('custom-radius-container')).toHaveStyle(
styles.customRadius
);
expect(getByTestId('custom-radius')).toHaveStyle({
borderTopLeftRadius: 15, // styles.customRadius - 1px outline
borderTopRightRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 15, // styles.customRadius - 1px outline
});
});

it('renders button without border radius', () => {
const { getByTestId } = render(
<Button testID="custom-radius" style={styles.noRadius}>
Expand Down
19 changes: 18 additions & 1 deletion src/components/__tests__/IconButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const styles = StyleSheet.create({
square: {
borderRadius: 0,
},
slightlyRounded: {
borderRadius: 4,
},
});

it('renders icon button by default', () => {
Expand Down Expand Up @@ -58,7 +61,21 @@ it('renders icon button with custom border radius', () => {
/>
);

expect(getByTestId('icon-button')).toHaveStyle({ borderRadius: 0 });
expect(getByTestId('icon-button-container')).toHaveStyle({ borderRadius: 0 });
});

it('renders icon button with small border radius', () => {
const { getByTestId } = render(
<IconButton
icon="camera"
testID="icon-button"
size={36}
onPress={() => {}}
style={styles.slightlyRounded}
/>
);

expect(getByTestId('icon-button-container')).toHaveStyle({ borderRadius: 4 });
});

describe('getIconButtonColor - icon color', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1902,7 +1902,7 @@ exports[`renders outlined button with mode 1`] = `
"overflow": "hidden",
},
{
"borderRadius": 20,
"borderRadius": 19,
},
]
}
Expand Down
Loading