Skip to content

Commit 74c01a3

Browse files
kacperkapusciaktjzelj-piasecki
authored
fix: Add support for borderRadii on RectButton (#2691)
# Description https://github.com/software-mansion/react-native-gesture-handler/assets/39658211/4ebc5761-c571-4c9a-a9c0-c2e0b7ac0018 This PR adds support for border radius style props like: `borderTopLeftRadius`, `borderTopRightRadius`, `borderBottomLeftRadius`, `borderBottomRightRadius` to RectButton. It implements the border radii by wrapping a RawButton with two Views - the outer one curves the native RawButton and the inner acts like a mask cutting the corners of the button. The radius of the inner view had to be adjusted by subtracting the borderWidth from it - [see this tweet](https://twitter.com/lilykonings/status/1567317037126680576). FIxes #2594 --------- Co-authored-by: Tomasz Żelawski <tomasz.zelawski@swmansion.com> Co-authored-by: Jakub Piasecki <jakub.piasecki@swmansion.com>
1 parent 160b207 commit 74c01a3

File tree

4 files changed

+326
-8
lines changed

4 files changed

+326
-8
lines changed

example/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Fling from './release_tests/fling';
1919
import NestedTouchables from './release_tests/nestedTouchables';
2020
import NestedButtons from './release_tests/nestedButtons';
2121
import NestedGestureHandlerRootViewWithModal from './release_tests/nestedGHRootViewWithModal';
22+
import RoundedButtons from './release_tests/roundedButtons';
2223
import { PinchableBox } from './recipes/scaleAndRotate';
2324
import PanAndScroll from './recipes/panAndScroll';
2425
import { BottomSheet } from './showcase/bottomSheet';
@@ -115,6 +116,7 @@ const EXAMPLES: ExamplesSection[] = [
115116
{ name: 'Fling', component: Fling },
116117
{ name: 'Combo', component: ComboWithGHScroll },
117118
{ name: 'Touchables', component: TouchablesIndex as React.ComponentType },
119+
{ name: 'Rounded buttons', component: RoundedButtons },
118120
],
119121
},
120122
{
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import { View, StyleSheet, Text, SafeAreaView } from 'react-native';
3+
import {
4+
GestureHandlerRootView,
5+
ScrollView,
6+
RectButton,
7+
} from 'react-native-gesture-handler';
8+
9+
const MyButton = RectButton;
10+
11+
export default function ComplexUI() {
12+
return (
13+
<GestureHandlerRootView style={styles.container}>
14+
<SafeAreaView style={styles.container}>
15+
<ScrollView>
16+
<Avatars />
17+
<View style={styles.paddedContainer}>
18+
<Gallery />
19+
<Gallery />
20+
<Gallery />
21+
<Gallery />
22+
<Gallery />
23+
</View>
24+
</ScrollView>
25+
</SafeAreaView>
26+
</GestureHandlerRootView>
27+
);
28+
}
29+
const colors = ['#782AEB', '#38ACDD', '#57B495', '#FF6259', '#FFD61E'];
30+
31+
function Avatars() {
32+
return (
33+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
34+
{colors.map((color) => (
35+
<MyButton
36+
key={color}
37+
style={[styles.avatars, { backgroundColor: color }]}>
38+
<Text style={styles.avatarLabel}>{color.slice(1, 3)}</Text>
39+
</MyButton>
40+
))}
41+
</ScrollView>
42+
);
43+
}
44+
45+
function Gallery() {
46+
return (
47+
<View style={[styles.container, styles.gap, styles.marginBottom]}>
48+
<MyButton style={styles.fullWidthButton} />
49+
<View style={[styles.row, styles.gap]}>
50+
<MyButton style={styles.leftButton} />
51+
<MyButton style={styles.rightButton} />
52+
</View>
53+
</View>
54+
);
55+
}
56+
57+
const styles = StyleSheet.create({
58+
container: {
59+
flex: 1,
60+
},
61+
marginBottom: {
62+
marginBottom: 20,
63+
},
64+
paddedContainer: {
65+
padding: 16,
66+
},
67+
heading: {
68+
fontSize: 40,
69+
fontWeight: 'bold',
70+
marginBottom: 24,
71+
color: 'black',
72+
},
73+
gap: {
74+
gap: 10,
75+
},
76+
listItem: {
77+
flexDirection: 'row',
78+
alignItems: 'center',
79+
justifyContent: 'space-between',
80+
padding: 20,
81+
backgroundColor: '#232736',
82+
marginVertical: 4,
83+
borderRadius: 20,
84+
marginBottom: 8,
85+
},
86+
listItemLabel: {
87+
fontSize: 20,
88+
flex: 1,
89+
color: 'white',
90+
marginLeft: 20,
91+
},
92+
listItemIcon: {
93+
fontSize: 32,
94+
},
95+
row: {
96+
flexDirection: 'row',
97+
},
98+
avatars: {
99+
width: 90,
100+
height: 90,
101+
borderWidth: 2,
102+
borderColor: '#001A72',
103+
borderTopLeftRadius: 30,
104+
borderTopRightRadius: 5,
105+
borderBottomLeftRadius: 5,
106+
borderBottomRightRadius: 30,
107+
marginHorizontal: 4,
108+
alignItems: 'center',
109+
justifyContent: 'center',
110+
},
111+
avatarLabel: {
112+
color: '#F8F9FF',
113+
fontSize: 24,
114+
fontWeight: 'bold',
115+
},
116+
fullWidthButton: {
117+
width: '100%',
118+
height: 160,
119+
backgroundColor: '#FF6259',
120+
borderTopRightRadius: 30,
121+
borderTopLeftRadius: 30,
122+
borderWidth: 1,
123+
},
124+
leftButton: {
125+
flex: 1,
126+
height: 160,
127+
backgroundColor: '#FFD61E',
128+
borderBottomLeftRadius: 30,
129+
borderWidth: 5,
130+
},
131+
rightButton: {
132+
flex: 1,
133+
backgroundColor: '#782AEB',
134+
height: 160,
135+
borderBottomRightRadius: 30,
136+
borderWidth: 8,
137+
},
138+
});

src/components/GestureButtons.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
StyleSheet,
77
StyleProp,
88
ViewStyle,
9+
View,
910
} from 'react-native';
1011

1112
import createNativeWrapper from '../handlers/createNativeWrapper';
@@ -20,6 +21,7 @@ import {
2021
NativeViewGestureHandlerPayload,
2122
NativeViewGestureHandlerProps,
2223
} from '../handlers/NativeViewGestureHandler';
24+
import { splitStyleProp } from './splitStyleProp';
2325

2426
export interface RawButtonProps extends NativeViewGestureHandlerProps {
2527
/**
@@ -63,6 +65,7 @@ export interface RawButtonProps extends NativeViewGestureHandlerProps {
6365
* Set this to true if you don't want the system to play sound when the button is pressed.
6466
*/
6567
touchSoundDisabled?: boolean;
68+
style?: StyleProp<ViewStyle>;
6669
}
6770

6871
export interface BaseButtonProps extends RawButtonProps {
@@ -84,7 +87,6 @@ export interface BaseButtonProps extends RawButtonProps {
8487
* method.
8588
*/
8689
onActiveStateChange?: (active: boolean) => void;
87-
style?: StyleProp<ViewStyle>;
8890
testID?: string;
8991

9092
/**
@@ -218,15 +220,22 @@ export class BaseButton extends React.Component<BaseButtonProps> {
218220
};
219221

220222
render() {
221-
const { rippleColor, ...rest } = this.props;
223+
const { rippleColor, style, ...rest } = this.props;
224+
225+
const { outerStyles, innerStyles, restStyles } = splitStyleProp(style);
222226

223227
return (
224-
<RawButton
225-
rippleColor={processColor(rippleColor)}
226-
{...rest}
227-
onGestureEvent={this.onGestureEvent}
228-
onHandlerStateChange={this.onHandlerStateChange}
229-
/>
228+
<View style={outerStyles}>
229+
<View style={innerStyles}>
230+
<RawButton
231+
rippleColor={processColor(rippleColor)}
232+
style={restStyles}
233+
{...rest}
234+
onGestureEvent={this.onGestureEvent}
235+
onHandlerStateChange={this.onHandlerStateChange}
236+
/>
237+
</View>
238+
</View>
230239
);
231240
}
232241
}

src/components/splitStyleProp.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { StyleProp, StyleSheet, ViewStyle } from 'react-native';
2+
3+
const STYLE_GROUPS = {
4+
borderRadiiStyles: {
5+
borderRadius: true,
6+
borderTopLeftRadius: true,
7+
borderTopRightRadius: true,
8+
borderBottomLeftRadius: true,
9+
borderBottomRightRadius: true,
10+
} as const,
11+
outerStyles: {
12+
borderColor: true,
13+
borderWidth: true,
14+
margin: true,
15+
marginBottom: true,
16+
marginEnd: true,
17+
marginHorizontal: true,
18+
marginLeft: true,
19+
marginRight: true,
20+
marginStart: true,
21+
marginTop: true,
22+
marginVertical: true,
23+
width: true,
24+
height: true,
25+
} as const,
26+
innerStyles: {
27+
alignSelf: true,
28+
display: true,
29+
flexBasis: true,
30+
flexGrow: true,
31+
flexShrink: true,
32+
maxHeight: true,
33+
maxWidth: true,
34+
minHeight: true,
35+
minWidth: true,
36+
zIndex: true,
37+
} as const,
38+
applyToAllStyles: {
39+
flex: true,
40+
position: true,
41+
left: true,
42+
right: true,
43+
top: true,
44+
bottom: true,
45+
start: true,
46+
end: true,
47+
} as const,
48+
} as const;
49+
50+
type BorderRadiiKey = keyof typeof STYLE_GROUPS.borderRadiiStyles;
51+
type OuterKey = keyof typeof STYLE_GROUPS.outerStyles;
52+
type InnerKey = keyof typeof STYLE_GROUPS.innerStyles;
53+
type ApplyToAllKey = keyof typeof STYLE_GROUPS.applyToAllStyles;
54+
55+
type BorderRadiiStyles = Pick<ViewStyle, BorderRadiiKey>;
56+
type OuterStyles = Pick<ViewStyle, OuterKey>;
57+
type InnerStyles = Pick<ViewStyle, InnerKey>;
58+
type ApplyToAllStyles = Pick<ViewStyle, ApplyToAllKey>;
59+
type RestStyles = Omit<
60+
ViewStyle,
61+
BorderRadiiKey | OuterKey | InnerKey | ApplyToAllKey
62+
>;
63+
64+
type GroupedStyles = {
65+
borderRadiiStyles: BorderRadiiStyles;
66+
outerStyles: OuterStyles;
67+
innerStyles: InnerStyles;
68+
applyToAllStyles: ApplyToAllStyles;
69+
restStyles: RestStyles;
70+
};
71+
72+
const groupByStyle = (styles: ViewStyle): GroupedStyles => {
73+
const borderRadiiStyles = {} as Record<string, unknown>;
74+
const outerStyles = {} as Record<string, unknown>;
75+
const innerStyles = {} as Record<string, unknown>;
76+
const applyToAllStyles = {} as Record<string, unknown>;
77+
const restStyles = {} as Record<string, unknown>;
78+
79+
let key: keyof ViewStyle;
80+
81+
for (key in styles) {
82+
if (key in STYLE_GROUPS.borderRadiiStyles) {
83+
borderRadiiStyles[key] = styles[key];
84+
} else if (key in STYLE_GROUPS.outerStyles) {
85+
outerStyles[key] = styles[key];
86+
} else if (key in STYLE_GROUPS.innerStyles) {
87+
innerStyles[key] = styles[key];
88+
} else if (key in STYLE_GROUPS.applyToAllStyles) {
89+
applyToAllStyles[key] = styles[key];
90+
} else {
91+
restStyles[key] = styles[key];
92+
}
93+
}
94+
95+
return {
96+
borderRadiiStyles,
97+
outerStyles,
98+
innerStyles,
99+
applyToAllStyles,
100+
restStyles,
101+
};
102+
};
103+
104+
// if borderWidth was specified it will adjust the border radii
105+
// to remain the same curvature for both inner and outer views
106+
// https://twitter.com/lilykonings/status/1567317037126680576
107+
const shrinkBorderRadiiByBorderWidth = (
108+
borderRadiiStyles: BorderRadiiStyles,
109+
borderWidth: number
110+
) => {
111+
const newBorderRadiiStyles = { ...borderRadiiStyles };
112+
113+
let borderRadiusType: BorderRadiiKey;
114+
115+
for (borderRadiusType in newBorderRadiiStyles) {
116+
newBorderRadiiStyles[borderRadiusType] =
117+
(newBorderRadiiStyles[borderRadiusType] as number) - borderWidth;
118+
}
119+
120+
return newBorderRadiiStyles;
121+
};
122+
123+
export function splitStyleProp<T extends ViewStyle>(
124+
style?: StyleProp<T>
125+
): {
126+
outerStyles: T;
127+
innerStyles: T;
128+
restStyles: T;
129+
} {
130+
const resolvedStyle = StyleSheet.flatten((style ?? {}) as ViewStyle);
131+
132+
let outerStyles = {} as T;
133+
let innerStyles = { overflow: 'hidden', flexGrow: 1 } as T;
134+
let restStyles = { flexGrow: 1 } as T;
135+
136+
const styleGroups = groupByStyle(resolvedStyle);
137+
138+
outerStyles = {
139+
...outerStyles,
140+
...styleGroups.borderRadiiStyles,
141+
...styleGroups.applyToAllStyles,
142+
...styleGroups.outerStyles,
143+
};
144+
innerStyles = {
145+
...innerStyles,
146+
...styleGroups.applyToAllStyles,
147+
...styleGroups.innerStyles,
148+
};
149+
restStyles = {
150+
...restStyles,
151+
...styleGroups.restStyles,
152+
...styleGroups.applyToAllStyles,
153+
};
154+
155+
// if borderWidth was specified it adjusts border radii
156+
// to remain the same curvature for both inner and outer views
157+
if (styleGroups.outerStyles.borderWidth != null) {
158+
const { borderWidth } = styleGroups.outerStyles;
159+
160+
const innerBorderRadiiStyles = shrinkBorderRadiiByBorderWidth(
161+
{ ...styleGroups.borderRadiiStyles },
162+
borderWidth
163+
);
164+
165+
innerStyles = { ...innerStyles, ...innerBorderRadiiStyles };
166+
}
167+
168+
return { outerStyles, innerStyles, restStyles };
169+
}

0 commit comments

Comments
 (0)