Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
9fb1120
Base implementation
m-bert Mar 6, 2026
093630b
Merge branch 'main' into @mbert/clickable
m-bert Mar 6, 2026
f1134cb
Do not use animated button if not necessary
m-bert Mar 6, 2026
9461ecc
Use props
m-bert Mar 6, 2026
ab6ced1
Types
m-bert Mar 6, 2026
27b3d0c
Additional props
m-bert Mar 6, 2026
c0d4960
Working component
m-bert Mar 6, 2026
25ed517
Small refactor
m-bert Mar 6, 2026
5e4262b
Add example
m-bert Mar 6, 2026
5646775
Looks good
m-bert Mar 9, 2026
202a999
Unified, without ripple
m-bert Mar 9, 2026
dd2c3bf
Rippleeeee
m-bert Mar 9, 2026
2cf923b
clear activeState also in onFinalize
m-bert Mar 9, 2026
8059e40
Fix borderless
m-bert Mar 9, 2026
6b043a9
Default ripple color
m-bert Mar 9, 2026
1eb680e
Rename file
m-bert Mar 9, 2026
b2f7e05
Fix index
m-bert Mar 9, 2026
44bbcc6
Split component and types
m-bert Mar 9, 2026
f4d895e
Add presets
m-bert Mar 10, 2026
491c27a
Update gitignore
m-bert Mar 10, 2026
e404f8a
Renames
m-bert Mar 10, 2026
583e864
Remove preset
m-bert Mar 10, 2026
769adb4
Ripple
m-bert Mar 10, 2026
e4bb348
Example
m-bert Mar 10, 2026
96c016b
Rename in example
m-bert Mar 10, 2026
fe9f47f
Opacity fix
m-bert Mar 10, 2026
aca162b
JSDocs
m-bert Mar 10, 2026
c6f27e7
Vibe stress test
m-bert Mar 10, 2026
4200868
First refactor
m-bert Mar 10, 2026
beb6710
Second optimization
m-bert Mar 11, 2026
1a23339
Change props
m-bert Mar 11, 2026
0e5b735
Bring back use callback
m-bert Mar 11, 2026
ef24b2f
Merge branch 'main' into @mbert/clickable
m-bert Mar 11, 2026
d6113c6
Tests
m-bert Mar 12, 2026
ac02b27
Reset longpress outside of if
m-bert Mar 12, 2026
8864e89
Easier stress test
m-bert Mar 12, 2026
b74ce61
Deprecate old buttons
m-bert Mar 12, 2026
822ba83
Add onPressIn and onPressOut
m-bert Mar 12, 2026
c9aba4d
Revert changes in button
m-bert Mar 13, 2026
f2b3bb9
Merge branch 'main' into @mbert/clickable
m-bert Mar 13, 2026
050f122
Fix deprecation message for `RectButton` to reference `underlayActive…
m-bert Mar 16, 2026
270b111
Call onPressIn when e.pointerInsideis true
m-bert Mar 16, 2026
77d411d
Stress test example timeout cleanup
m-bert Mar 16, 2026
08e5e2e
Merge branch '@mbert/clickable' of github.com:software-mansion/react-…
m-bert Mar 16, 2026
d80e5cf
Colooooooors
m-bert Mar 16, 2026
95a887f
Update underlayColor description to require underlayActiveOpacity
m-bert Mar 16, 2026
25126cf
Add default values for underlayInitialOpacity and initialOpacity in C…
m-bert Mar 16, 2026
45ffb2f
Remove unnecessary border styles
m-bert Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ DerivedData

# Android/IntelliJ
#
bin/
build/
.idea
.gradle
Expand Down
6 changes: 6 additions & 0 deletions apps/common-app/src/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,21 @@ export const COLORS = {
offWhite: '#f8f9ff',
headerSeparator: '#eef0ff',
PURPLE: '#b58df1',
DARK_PURPLE: '#7d63d9',
NAVY: '#001A72',
RED: '#A41623',
YELLOW: '#F2AF29',
GREEN: '#0F956F',
DARK_GREEN: '#217838',
GRAY: '#ADB1C2',
KINDA_RED: '#FFB2AD',
DARK_SALMON: '#d97973',
KINDA_YELLOW: '#FFF096',
KINDA_GREEN: '#C4E7DB',
KINDA_BLUE: '#A0D5EF',
LIGHT_BLUE: '#5f97c8',
WEB_BLUE: '#1067c4',
ANDROID: '#34a853',
};

/* eslint-disable react-native/no-unused-styles */
Expand Down
158 changes: 158 additions & 0 deletions apps/common-app/src/new_api/components/clickable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import React from 'react';
import { StyleSheet, Text, View, ScrollView } from 'react-native';
import {
GestureHandlerRootView,
Clickable,
ClickableProps,
} from 'react-native-gesture-handler';
import { COLORS } from '../../../common';

type ButtonWrapperProps = ClickableProps & {
name: string;
color: string;
};

function ClickableWrapper({ name, color, ...rest }: ButtonWrapperProps) {
return (
<Clickable
style={[styles.button, { backgroundColor: color }]}
onPressIn={() => console.log(`[${name}] onPressIn`)}
onPress={() => console.log(`[${name}] onPress`)}
onLongPress={() => console.log(`[${name}] onLongPress`)}
onPressOut={() => console.log(`[${name}] onPressOut`)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider using the feedback component.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think it works here - callbacks are fired one by one so it is easier to read that on console where you see all of the outputs.

{...rest}>
<Text style={styles.buttonText}>{name}</Text>
</Clickable>
);
}

export default function ClickableExample() {
return (
<GestureHandlerRootView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.section}>
<Text style={styles.sectionHeader}>Buttons replacements</Text>
<Text>New component that replaces all buttons and pressables.</Text>

<View style={styles.row}>
<ClickableWrapper name="Base" color={COLORS.DARK_PURPLE} />

<ClickableWrapper
name="Rect"
color={COLORS.WEB_BLUE}
underlayActiveOpacity={0.105}
/>

<ClickableWrapper
name="Borderless"
activeOpacity={0.3}
color={COLORS.RED}
/>
</View>
</View>

<View style={styles.section}>
<Text style={styles.sectionHeader}>Custom animations</Text>
<Text>Animated overlay.</Text>

<View style={styles.row}>
<ClickableWrapper
name="Click me!"
color={COLORS.YELLOW}
underlayActiveOpacity={0.3}
/>

<ClickableWrapper
name="Click me!"
color={COLORS.NAVY}
underlayInitialOpacity={0.7}
underlayActiveOpacity={0.5}
underlayColor={COLORS.DARK_GREEN}
/>
</View>

<Text>Animated component.</Text>

<View style={styles.row}>
<ClickableWrapper
name="Click me!"
color={COLORS.LIGHT_BLUE}
initialOpacity={0.3}
activeOpacity={0.7}
/>

<ClickableWrapper
name="Click me!"
color={COLORS.DARK_SALMON}
initialOpacity={0.7}
activeOpacity={0.5}
/>
</View>
</View>

<View style={styles.section}>
<Text style={styles.sectionHeader}>Android ripple</Text>
<Text>Configurable ripple effect on Clickable component.</Text>

<View style={styles.row}>
<ClickableWrapper
name="Default"
color={COLORS.ANDROID}
androidRipple={{}}
/>

<ClickableWrapper
name="Borderless"
color={COLORS.ANDROID}
androidRipple={{
color: COLORS.KINDA_BLUE,
borderless: true,
radius: 55,
}}
/>
</View>
</View>
</ScrollView>
</GestureHandlerRootView>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
section: {
padding: 20,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#ccc',
alignItems: 'center',
},
row: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 10,
marginTop: 20,
marginBottom: 20,
},
sectionHeader: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 4,
},
button: {
width: 110,
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
buttonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
});
193 changes: 193 additions & 0 deletions apps/common-app/src/new_api/components/clickable_stress/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Profiler, useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Clickable, ScrollView } from 'react-native-gesture-handler';

const CLICK_COUNT = 2000;
const N = 25;
const DROPOUT = 3;

const STRESS_DATA = Array.from(
{ length: CLICK_COUNT },
(_, i) => `stress-${i}`
);

type BenchmarkState =
| { phase: 'idle' }
| { phase: 'running'; run: number }
| { phase: 'done'; results: number[] };

function getTrimmedAverage(results: number[], dropout: number): number {
const sorted = [...results].sort((a, b) => a - b);
const trimCount = Math.min(
dropout,
Math.max(0, Math.floor((sorted.length - 1) / 2))
);
const trimmed =
trimCount > 0 ? sorted.slice(trimCount, sorted.length - trimCount) : sorted;
return trimmed.reduce((sum, v) => sum + v, 0) / trimmed.length;
}

type ClickableListProps = {
run: number;
onMountDuration: (duration: number) => void;
};

function ClickableList({ run, onMountDuration }: ClickableListProps) {
const reportedRef = useRef(-1);

const handleRender = useCallback(
(_id: string, phase: string, actualDuration: number) => {
if (phase === 'mount' && reportedRef.current !== run) {
reportedRef.current = run;
onMountDuration(actualDuration);
}
},
[run, onMountDuration]
);

return (
<Profiler id="ClickableList" onRender={handleRender}>
<ScrollView style={{ flex: 1 }}>
{STRESS_DATA.map((id) => (
// <BaseButton key={id} style={styles.button} />
<Clickable key={id} style={styles.button} />

// <RectButton key={id} style={styles.button} />
// <Clickable
// key={id}
// style={styles.button}
// underlayActiveOpacity={0.105}
// />

// <BorderlessButton key={id} style={styles.button} />
// <Clickable key={id} style={styles.button} activeOpacity={0.3} />
))}
</ScrollView>
</Profiler>
);
}

export default function ClickableStress() {
const [state, setState] = useState<BenchmarkState>({ phase: 'idle' });
const resultsRef = useRef<number[]>([]);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const start = useCallback(() => {
resultsRef.current = [];
setState({ phase: 'running', run: 1 });
}, []);

const handleMountDuration = useCallback((duration: number) => {
resultsRef.current = [...resultsRef.current, duration];
const currentRun = resultsRef.current.length;

if (currentRun >= N) {
setState({ phase: 'done', results: resultsRef.current });
return;
}

// Unmount then remount for next run
setState({ phase: 'idle' });
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setState({ phase: 'running', run: currentRun + 1 });
}, 50);
}, []);

useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);

const isRunning = state.phase === 'running';
const currentRun = state.phase === 'running' ? state.run : 0;
const results = state.phase === 'done' ? state.results : null;
const trimmedAverage = results ? getTrimmedAverage(results, DROPOUT) : null;

return (
<View style={styles.container}>
<Clickable
underlayActiveOpacity={0.105}
style={[styles.startButton, isRunning && styles.startButtonBusy]}
onPress={start}
enabled={!isRunning}>
<Text style={styles.startButtonText}>
{isRunning ? `Running ${currentRun}/${N}...` : 'Start test'}
</Text>
</Clickable>

{results && (
<View style={styles.results}>
<Text style={styles.resultText}>
Runs: {results.length} (trimmed ±{DROPOUT})
</Text>
<Text style={styles.resultText}>
Trimmed avg: {trimmedAverage?.toFixed(2)} ms
</Text>
<Text style={styles.resultText}>
Min: {Math.min(...results).toFixed(2)} ms
</Text>
<Text style={styles.resultText}>
Max: {Math.max(...results).toFixed(2)} ms
</Text>
<Text style={styles.resultText}>
All: {results.map((r) => r.toFixed(1)).join(', ')} ms
</Text>
</View>
)}

{isRunning && (
<ClickableList run={currentRun} onMountDuration={handleMountDuration} />
)}
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
alignItems: 'center',
},
startButton: {
width: 200,
height: 50,
backgroundColor: '#167a5f',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
startButtonBusy: {
backgroundColor: '#7f879b',
},
startButtonText: {
color: 'white',
fontWeight: '700',
},
button: {
width: 200,
height: 50,
backgroundColor: 'lightblue',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
results: {
marginTop: 20,
padding: 16,
borderRadius: 12,
backgroundColor: '#eef3fb',
width: '100%',
gap: 6,
},
resultText: {
color: '#33415c',
fontSize: 13,
},
});
Loading
Loading