Skip to content

Commit 8cd97a9

Browse files
committed
Merge branch '@jpiasecki/refactor-button' into @jpiasecki/add-minimum-animation-duration
2 parents eafff80 + 80bea36 commit 8cd97a9

18 files changed

Lines changed: 773 additions & 48 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)