Skip to content

Commit 1e87668

Browse files
m-bertCopilot
andauthored
Clickable component (#4018)
## Description This PR introduces new `Clickable` component, which is meant to be a replacement for buttons. > [!NOTE] > Docs for `Clickable` will be added in #4022, as I don't want to release them right away after merging this PR. ### `borderless` For now, `borderless` doesn't work. I've tested clickable with some changes that allow `borderless` ripple to be visible, however we don't want to introduce them here because it would break other things. Also it should be generl fix, not in the PR with new component. ## Stress test Render list with 2000 buttons 25 times (50ms delay between renders), drop 3 best and 3 worst results. Then calculate average. Stress test example is available in this PR. #### Android | | $t_{Button}$ | $t_{Clickable}$ | $\Delta{t}$ | |------------------|--------------|-----------------|--------------| | `BaseButton` | 1196,18 | 1292,3 | 96,12 | | `RectButton` | 1672,6 | 1275,68 | -396,92 | | `BorderlessButton` | 1451,34 | 1290,74 | -160,6 | #### iOS | | $t_{Button}$ | $t_{Clickable}$ | $\Delta{t}$ | |------------------|--------------|-----------------|--------------| | `BaseButton` | 1101,37 | 1154,6 | 53,23 | | `RectButton` | 1528,07 | 1160,07 | -368 | | `BorderlessButton` | 1330,24 | 1172,69 | -157,55 | #### Web | | $t_{Button}$ | $t_{Clickable}$ | $\Delta{t}$ | |------------------|--------------|-----------------|--------------| | `BaseButton` | 64,18 | 95,57 | 31,39 | | `RectButton` | 104,58 | 97,95 | -6,63 | | `BorderlessButton` | 81,11 | 98,64 | 17,53 | ## Test plan New examples --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 9d671cb commit 1e87668

13 files changed

Lines changed: 707 additions & 12 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ DerivedData
2626

2727
# Android/IntelliJ
2828
#
29+
bin/
2930
build/
3031
.idea
3132
.gradle

apps/common-app/src/common.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,21 @@ export const COLORS = {
3636
offWhite: '#f8f9ff',
3737
headerSeparator: '#eef0ff',
3838
PURPLE: '#b58df1',
39+
DARK_PURPLE: '#7d63d9',
3940
NAVY: '#001A72',
4041
RED: '#A41623',
4142
YELLOW: '#F2AF29',
4243
GREEN: '#0F956F',
44+
DARK_GREEN: '#217838',
4345
GRAY: '#ADB1C2',
4446
KINDA_RED: '#FFB2AD',
47+
DARK_SALMON: '#d97973',
4548
KINDA_YELLOW: '#FFF096',
4649
KINDA_GREEN: '#C4E7DB',
4750
KINDA_BLUE: '#A0D5EF',
51+
LIGHT_BLUE: '#5f97c8',
52+
WEB_BLUE: '#1067c4',
53+
ANDROID: '#34a853',
4854
};
4955

5056
/* eslint-disable react-native/no-unused-styles */
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import React from 'react';
2+
import { StyleSheet, Text, View, ScrollView } from 'react-native';
3+
import {
4+
GestureHandlerRootView,
5+
Clickable,
6+
ClickableProps,
7+
} from 'react-native-gesture-handler';
8+
import { COLORS } from '../../../common';
9+
10+
type ButtonWrapperProps = ClickableProps & {
11+
name: string;
12+
color: string;
13+
};
14+
15+
function ClickableWrapper({ name, color, ...rest }: ButtonWrapperProps) {
16+
return (
17+
<Clickable
18+
style={[styles.button, { backgroundColor: color }]}
19+
onPressIn={() => console.log(`[${name}] onPressIn`)}
20+
onPress={() => console.log(`[${name}] onPress`)}
21+
onLongPress={() => console.log(`[${name}] onLongPress`)}
22+
onPressOut={() => console.log(`[${name}] onPressOut`)}
23+
{...rest}>
24+
<Text style={styles.buttonText}>{name}</Text>
25+
</Clickable>
26+
);
27+
}
28+
29+
export default function ClickableExample() {
30+
return (
31+
<GestureHandlerRootView style={styles.container}>
32+
<ScrollView contentContainerStyle={styles.scrollContent}>
33+
<View style={styles.section}>
34+
<Text style={styles.sectionHeader}>Buttons replacements</Text>
35+
<Text>New component that replaces all buttons and pressables.</Text>
36+
37+
<View style={styles.row}>
38+
<ClickableWrapper name="Base" color={COLORS.DARK_PURPLE} />
39+
40+
<ClickableWrapper
41+
name="Rect"
42+
color={COLORS.WEB_BLUE}
43+
activeUnderlayOpacity={0.105}
44+
/>
45+
46+
<ClickableWrapper
47+
name="Borderless"
48+
activeOpacity={0.3}
49+
color={COLORS.RED}
50+
/>
51+
</View>
52+
</View>
53+
54+
<View style={styles.section}>
55+
<Text style={styles.sectionHeader}>Custom animations</Text>
56+
<Text>Animated underlay.</Text>
57+
58+
<View style={styles.row}>
59+
<ClickableWrapper
60+
name="Click me!"
61+
color={COLORS.YELLOW}
62+
activeUnderlayOpacity={0.3}
63+
/>
64+
65+
<ClickableWrapper
66+
name="Click me!"
67+
color={COLORS.NAVY}
68+
defaultUnderlayOpacity={0.7}
69+
activeUnderlayOpacity={0.5}
70+
underlayColor={COLORS.DARK_GREEN}
71+
/>
72+
</View>
73+
74+
<Text>Animated component.</Text>
75+
76+
<View style={styles.row}>
77+
<ClickableWrapper
78+
name="Click me!"
79+
color={COLORS.LIGHT_BLUE}
80+
defaultOpacity={0.3}
81+
activeOpacity={0.7}
82+
/>
83+
84+
<ClickableWrapper
85+
name="Click me!"
86+
color={COLORS.DARK_SALMON}
87+
defaultOpacity={0.7}
88+
activeOpacity={0.5}
89+
/>
90+
</View>
91+
</View>
92+
93+
<View style={styles.section}>
94+
<Text style={styles.sectionHeader}>Android ripple</Text>
95+
<Text>Configurable ripple effect on Clickable component.</Text>
96+
97+
<View style={styles.row}>
98+
<ClickableWrapper
99+
name="Default"
100+
color={COLORS.ANDROID}
101+
androidRipple={{}}
102+
/>
103+
104+
<ClickableWrapper
105+
name="Borderless"
106+
color={COLORS.ANDROID}
107+
androidRipple={{
108+
color: COLORS.KINDA_BLUE,
109+
borderless: true,
110+
radius: 55,
111+
}}
112+
/>
113+
</View>
114+
</View>
115+
</ScrollView>
116+
</GestureHandlerRootView>
117+
);
118+
}
119+
120+
const styles = StyleSheet.create({
121+
container: {
122+
flex: 1,
123+
},
124+
scrollContent: {
125+
paddingBottom: 40,
126+
},
127+
section: {
128+
padding: 20,
129+
borderBottomWidth: StyleSheet.hairlineWidth,
130+
borderBottomColor: '#ccc',
131+
alignItems: 'center',
132+
},
133+
row: {
134+
flexDirection: 'row',
135+
flexWrap: 'wrap',
136+
justifyContent: 'center',
137+
gap: 10,
138+
marginTop: 20,
139+
marginBottom: 20,
140+
},
141+
sectionHeader: {
142+
fontSize: 16,
143+
fontWeight: 'bold',
144+
marginBottom: 4,
145+
},
146+
button: {
147+
width: 110,
148+
height: 50,
149+
borderRadius: 12,
150+
alignItems: 'center',
151+
justifyContent: 'center',
152+
},
153+
buttonText: {
154+
color: 'white',
155+
fontSize: 14,
156+
fontWeight: '600',
157+
},
158+
});
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { Profiler, useCallback, useEffect, useRef, useState } from 'react';
2+
import { StyleSheet, Text, View } from 'react-native';
3+
import { Clickable, ScrollView } from 'react-native-gesture-handler';
4+
5+
const CLICK_COUNT = 2000;
6+
const N = 25;
7+
const DROPOUT = 3;
8+
9+
const STRESS_DATA = Array.from(
10+
{ length: CLICK_COUNT },
11+
(_, i) => `stress-${i}`
12+
);
13+
14+
type BenchmarkState =
15+
| { phase: 'idle' }
16+
| { phase: 'running'; run: number }
17+
| { phase: 'done'; results: number[] };
18+
19+
function getTrimmedAverage(results: number[], dropout: number): number {
20+
const sorted = [...results].sort((a, b) => a - b);
21+
const trimCount = Math.min(
22+
dropout,
23+
Math.max(0, Math.floor((sorted.length - 1) / 2))
24+
);
25+
const trimmed =
26+
trimCount > 0 ? sorted.slice(trimCount, sorted.length - trimCount) : sorted;
27+
return trimmed.reduce((sum, v) => sum + v, 0) / trimmed.length;
28+
}
29+
30+
type ClickableListProps = {
31+
run: number;
32+
onMountDuration: (duration: number) => void;
33+
};
34+
35+
function ClickableList({ run, onMountDuration }: ClickableListProps) {
36+
const reportedRef = useRef(-1);
37+
38+
const handleRender = useCallback(
39+
(_id: string, phase: string, actualDuration: number) => {
40+
if (phase === 'mount' && reportedRef.current !== run) {
41+
reportedRef.current = run;
42+
onMountDuration(actualDuration);
43+
}
44+
},
45+
[run, onMountDuration]
46+
);
47+
48+
return (
49+
<Profiler id="ClickableList" onRender={handleRender}>
50+
<ScrollView style={{ flex: 1 }}>
51+
{STRESS_DATA.map((id) => (
52+
// <BaseButton key={id} style={styles.button} />
53+
<Clickable key={id} style={styles.button} />
54+
55+
// <RectButton key={id} style={styles.button} />
56+
// <Clickable
57+
// key={id}
58+
// style={styles.button}
59+
// activeUnderlayOpacity={0.105}
60+
// />
61+
62+
// <BorderlessButton key={id} style={styles.button} />
63+
// <Clickable key={id} style={styles.button} activeOpacity={0.3} />
64+
))}
65+
</ScrollView>
66+
</Profiler>
67+
);
68+
}
69+
70+
export default function ClickableStress() {
71+
const [state, setState] = useState<BenchmarkState>({ phase: 'idle' });
72+
const resultsRef = useRef<number[]>([]);
73+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74+
75+
const start = useCallback(() => {
76+
resultsRef.current = [];
77+
setState({ phase: 'running', run: 1 });
78+
}, []);
79+
80+
const handleMountDuration = useCallback((duration: number) => {
81+
resultsRef.current = [...resultsRef.current, duration];
82+
const currentRun = resultsRef.current.length;
83+
84+
if (currentRun >= N) {
85+
setState({ phase: 'done', results: resultsRef.current });
86+
return;
87+
}
88+
89+
// Unmount then remount for next run
90+
setState({ phase: 'idle' });
91+
if (timeoutRef.current !== null) {
92+
clearTimeout(timeoutRef.current);
93+
}
94+
timeoutRef.current = setTimeout(() => {
95+
setState({ phase: 'running', run: currentRun + 1 });
96+
}, 50);
97+
}, []);
98+
99+
useEffect(() => {
100+
return () => {
101+
if (timeoutRef.current !== null) {
102+
clearTimeout(timeoutRef.current);
103+
timeoutRef.current = null;
104+
}
105+
};
106+
}, []);
107+
108+
const isRunning = state.phase === 'running';
109+
const currentRun = state.phase === 'running' ? state.run : 0;
110+
const results = state.phase === 'done' ? state.results : null;
111+
const trimmedAverage = results ? getTrimmedAverage(results, DROPOUT) : null;
112+
113+
return (
114+
<View style={styles.container}>
115+
<Clickable
116+
activeUnderlayOpacity={0.105}
117+
style={[styles.startButton, isRunning && styles.startButtonBusy]}
118+
onPress={start}
119+
enabled={!isRunning}>
120+
<Text style={styles.startButtonText}>
121+
{isRunning ? `Running ${currentRun}/${N}...` : 'Start test'}
122+
</Text>
123+
</Clickable>
124+
125+
{results && (
126+
<View style={styles.results}>
127+
<Text style={styles.resultText}>
128+
Runs: {results.length} (trimmed ±{DROPOUT})
129+
</Text>
130+
<Text style={styles.resultText}>
131+
Trimmed avg: {trimmedAverage?.toFixed(2)} ms
132+
</Text>
133+
<Text style={styles.resultText}>
134+
Min: {Math.min(...results).toFixed(2)} ms
135+
</Text>
136+
<Text style={styles.resultText}>
137+
Max: {Math.max(...results).toFixed(2)} ms
138+
</Text>
139+
<Text style={styles.resultText}>
140+
All: {results.map((r) => r.toFixed(1)).join(', ')} ms
141+
</Text>
142+
</View>
143+
)}
144+
145+
{isRunning && (
146+
<ClickableList run={currentRun} onMountDuration={handleMountDuration} />
147+
)}
148+
</View>
149+
);
150+
}
151+
152+
const styles = StyleSheet.create({
153+
container: {
154+
flex: 1,
155+
padding: 20,
156+
alignItems: 'center',
157+
},
158+
startButton: {
159+
width: 200,
160+
height: 50,
161+
backgroundColor: '#167a5f',
162+
borderRadius: 10,
163+
alignItems: 'center',
164+
justifyContent: 'center',
165+
},
166+
startButtonBusy: {
167+
backgroundColor: '#7f879b',
168+
},
169+
startButtonText: {
170+
color: 'white',
171+
fontWeight: '700',
172+
},
173+
button: {
174+
width: 200,
175+
height: 50,
176+
backgroundColor: 'lightblue',
177+
borderRadius: 10,
178+
alignItems: 'center',
179+
justifyContent: 'center',
180+
},
181+
results: {
182+
marginTop: 20,
183+
padding: 16,
184+
borderRadius: 12,
185+
backgroundColor: '#eef3fb',
186+
width: '100%',
187+
gap: 6,
188+
},
189+
resultText: {
190+
color: '#33415c',
191+
fontSize: 13,
192+
},
193+
});

0 commit comments

Comments
 (0)